diff --git a/.editorconfig b/.editorconfig index 26f601e..5772db3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,29 +3,20 @@ root = true [*] charset = utf-8 end_of_line = lf -indent_size = 4 +indent_size = 2 indent_style = space insert_final_newline = true max_line_length = 120 -tab_width = 4 -trim_trailing_whitespace = true - -# Source code -[*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,vue}] -indent_size = 2 tab_width = 2 +trim_trailing_whitespace = true -# Mark-up/down -[*.{html,htm,md}] -indent_size = 2 -tab_width = 2 +[*.{sh,bat}] +indent_size = 4 +indent_style = tab -# Data streams -[*.{json,yaml,yml}] -indent_size = 2 -tab_width = 2 +[*.bat] +end_of_line = crlf -# Style sheets -[*.{css,scss,sass}] -indent_size = 2 -tab_width = 2 +[*.{kt,kts}] +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_no-unused-imports = enabled diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index ae6e386..0000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -*.d.ts -coverage/**/* -dist/**/* -out/**/* diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index adfd55d..0000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-env node */ -'use strict' -const { defineConfig } = require('@sixxgate/lint') -const { memo } = require('radash') - -module.exports = defineConfig(({ useVue, useNode, useTypeScript }) => { - useVue({ version: '3.4', style: 'sass' }) - useNode() - useTypeScript() - - /** @type {() => Partial} */ - const useCommonJsDocRules = memo(() => ({ - // Don't require all sections to be filled out. - 'jsdoc/require-returns': 'off', - 'jsdoc/require-yields': 'off', - // Requires too much configuration to worry about. - 'jsdoc/tag-lines': 'off', - // Don't require DocComments. - 'jsdoc/require-jsdoc': 'off' - })) - - /** @type {() => Partial} */ - const useJsDocRules = memo(() => ({ - ...useCommonJsDocRules(), - // Only require the parameters if we want them. - 'jsdoc/require-param': ['error', { ignoreWhenAllParamsMissing: true }] - })) - - /** @type {() => Partial} */ - const useTsDocRules = memo(() => ({ - ...useCommonJsDocRules(), - // Only require the parameters if we want them. - 'jsdoc/require-param': [ - 'error', - { - ignoreWhenAllParamsMissing: true, - // Has issues with TSDoc - checkDestructuredRoots: false - } - ], - // Has issues with JSDOc. - 'jsdoc/check-param-names': ['error', { checkDestructured: false }], - // TSDoc itself. - 'tsdoc/syntax': 'error' - })) - - return { - root: true, - env: { es2023: true }, - plugins: ['eslint-plugin-tsdoc'], - reportUnusedDisableDirectives: true, - overrides: [ - // CommonJS JavaScript - { - files: ['*.cjs'], - extends: ['plugin:jsdoc/recommended-error'], - rules: { - '@typescript-eslint/no-require-imports': 'off', - ...useJsDocRules() - }, - parserOptions: { - project: './tsconfig.config.json' - } - }, - // Main and preload - { - files: ['src/core/**/*.ts', 'src/main/**/*.ts', 'src/main/**/*.js', 'src/preload/**/*.ts'], - parserOptions: { - project: './tsconfig.node.json' - } - }, - // Main and preload TypeScript - { - files: ['src/core/**/*.ts', 'src/main/**/*.ts', 'src/preload/**/*.ts'], - extends: ['plugin:jsdoc/recommended-typescript-error'], - rules: { ...useTsDocRules() } - }, - // Main and preload JavaScript - { - files: ['src/main/**/*.js'], - extends: ['plugin:jsdoc/recommended-error'], - rules: { ...useJsDocRules() } - }, - // Renderer - { - files: ['src/renderer/**/*.ts', 'src/renderer/**/*.tsx', 'src/renderer/**/*.js', 'src/renderer/**/*.vue'], - parserOptions: { - project: './tsconfig.web.json' - } - }, - // Renderer TypeScript - { - files: ['src/renderer/**/*.ts', 'src/renderer/**/*.tsx', 'src/renderer/**/*.vue'], - extends: ['plugin:jsdoc/recommended-typescript-error'], - rules: { ...useTsDocRules() } - }, - // Renderer JavaScript - { - files: ['src/renderer/**/*.js'], - extends: ['plugin:jsdoc/recommended-error'], - rules: { ...useJsDocRules() } - }, - // Vite configuration - { - files: ['electron.vite.config.ts', 'vite.config.ts'], - extends: ['plugin:jsdoc/recommended-typescript-error'], - rules: { ...useTsDocRules() }, - parserOptions: { - project: './tsconfig.config.json' - } - }, - // Tests - { - files: ['src/tests/**/*.ts'], - extends: ['plugin:jsdoc/recommended-typescript-error'], - rules: { ...useTsDocRules() }, - parserOptions: { - project: './tsconfig.test.json' - } - } - ] - } -}) diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index af3ad12..0000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -/.yarn/** linguist-vendored -/.yarn/releases/* binary -/.yarn/plugins/**/* binary -/.pnp.* binary linguist-generated diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 8664e60..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Test and coverage checks - -on: - push: - branches: [$default-branch, $protected-branches] - pull_request: {} - -jobs: - eslint: - name: Run coverage scanning - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: 'Install Node' - uses: actions/setup-node@v4 - with: - node-version: 20 - check-latest: true - - name: Setup yarn - run: | - corepack enable yarn - corepack install - - name: Install dependencies - run: | - yarn --frozen-lockfile - - name: Check test coverage - run: | - mkdir logs - yarn coverage diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index d7b90f6..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Style and form checks - -on: - push: - branches: [$default-branch, $protected-branches] - pull_request: {} - -jobs: - eslint: - name: Run eslint scanning - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: 'Install Node' - uses: actions/setup-node@v4 - with: - node-version: 20 - check-latest: true - - name: Setup yarn - run: | - corepack enable yarn - corepack install - - name: Install dependencies - run: | - yarn --frozen-lockfile --ignore-scripts - - name: Check code style - run: | - yarn check:prettier - yarn check:lint diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml deleted file mode 100644 index 15a1f5f..0000000 --- a/.github/workflows/typecheck.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: TypeScript checks - -on: - push: - branches: [$default-branch, $protected-branches] - pull_request: {} - -jobs: - eslint: - name: Run type scanning - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: 'Install Node' - uses: actions/setup-node@v4 - with: - node-version: 20 - check-latest: true - - name: Setup yarn - run: | - corepack enable yarn - corepack install - - name: Install dependencies - run: | - yarn --frozen-lockfile --ignore-scripts - - name: Check types - run: | - yarn typecheck:config - yarn typecheck:node - yarn typecheck:web - yarn typecheck:test diff --git a/.gitignore b/.gitignore index 7af4d1e..cfd55b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,6 @@ -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Swap the comments on the following lines if you wish to use zero-installs -# In that case, don't forget to run `yarn config set enableGlobalCache false`! -# Documentation here: https://yarnpkg.com/features/caching#zero-installs - -#!.yarn/cache -node_modules/ -.pnp.* +# Gradle +.gradle +*.iml # Hidden data .DS_Store @@ -19,40 +8,32 @@ node_modules/ # Logs logs *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -cypress/videos/ -cypress/screenshots/ +logs/ -# Editor directories and files -.idea -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +# Android Studio +captures -# We want to carry some VSCode settings with the code. -.vscode/* -!.vscode/extensions.json -!.vscode/settings.json -!.vscode/launch.json +# XCode +xcuserdata +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings # Local environment data -compose.override.yml +local.properties +.kotlin .env .env.* dev-app-update.yml -# Test output -coverage/ -logs/ - # Build output -package.tgz -dist/ -out/ +**/build/ +!src/**/build/ +node_modules/ + +# Generated native code +.externalNativeBuild +.cxx diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 5f1ad91..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -yarn run check diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..553b32d --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,7 @@ +# Default ignored files +/shelf/ +/workspace.xml +/dataSources.xml +/dataSources.*.xml +/dataSources +/queries diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..060db0d --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +BridgeCmdr \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/artifacts/composeApp_jvm.xml b/.idea/artifacts/composeApp_jvm.xml new file mode 100644 index 0000000..aed2b7a --- /dev/null +++ b/.idea/artifacts/composeApp_jvm.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/composeApp/build/libs + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..6a289e6 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..78eb261 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..5b640c3 --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,9 @@ + + + + Extron + Shinybow + ktor + + + diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..57cb69c --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/src/images/logo.svg b/.idea/icon.svg similarity index 100% rename from src/images/logo.svg rename to .idea/icon.svg diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..6e0b56a --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,77 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..79af2f5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/.idea/ktfmt.xml b/.idea/ktfmt.xml new file mode 100644 index 0000000..228bfae --- /dev/null +++ b/.idea/ktfmt.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/ktlint-plugin.xml b/.idea/ktlint-plugin.xml new file mode 100644 index 0000000..e8bd90c --- /dev/null +++ b/.idea/ktlint-plugin.xml @@ -0,0 +1,7 @@ + + + + DISTRACT_FREE + DEFAULT + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3e84093 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e073e13 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/receipt.json b/.idea/receipt.json new file mode 100644 index 0000000..1633829 --- /dev/null +++ b/.idea/receipt.json @@ -0,0 +1,25 @@ +// Project generated by Kotlin Multiplatform Wizard +{ + "spec": { + "template_id": "kmt", + "targets": { + "desktop": { + "ui": [ + "compose" + ] + }, + "web": { + "ui": [ + "compose" + ] + }, + "server": { + "engine": [ + "ktor" + ] + } + }, + "include_tests": true + }, + "timestamp": "2025-12-08T02:24:13.435318237Z" +} \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/_bridgecmdr__desktop_.xml b/.idea/runConfigurations/_bridgecmdr__desktop_.xml new file mode 100644 index 0000000..717321c --- /dev/null +++ b/.idea/runConfigurations/_bridgecmdr__desktop_.xml @@ -0,0 +1,39 @@ + + + + + + + + false + true + false + + + true + true + + + false + false + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/bridgecmdr__android_.xml b/.idea/runConfigurations/bridgecmdr__android_.xml new file mode 100644 index 0000000..2b43464 --- /dev/null +++ b/.idea/runConfigurations/bridgecmdr__android_.xml @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/bridgecmdr__coverage_.xml b/.idea/runConfigurations/bridgecmdr__coverage_.xml new file mode 100644 index 0000000..6621466 --- /dev/null +++ b/.idea/runConfigurations/bridgecmdr__coverage_.xml @@ -0,0 +1,27 @@ + + + + + + + true + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/bridgecmdr__desktop_.xml b/.idea/runConfigurations/bridgecmdr__desktop_.xml new file mode 100644 index 0000000..cde1047 --- /dev/null +++ b/.idea/runConfigurations/bridgecmdr__desktop_.xml @@ -0,0 +1,32 @@ + + + + + + + + true + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/bridgecmdr__tests_.xml b/.idea/runConfigurations/bridgecmdr__tests_.xml new file mode 100644 index 0000000..4bc36f9 --- /dev/null +++ b/.idea/runConfigurations/bridgecmdr__tests_.xml @@ -0,0 +1,27 @@ + + + + + + + true + true + false + false + false + false + false + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..c0e01ca --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..aabe6ec --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/.ncurc.yaml b/.ncurc.yaml deleted file mode 100644 index 153d536..0000000 --- a/.ncurc.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/raineorshine/npm-check-updates/main/src/types/RunOptions.json -target: minor diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 209e3ef..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -20 diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index a7cea0b..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 6271101..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Debug Main Process", - "type": "node", - "request": "launch", - "cwd": "${workspaceRoot}", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", - "runtimeArgs": ["--sourcemap", "--remoteDebuggingPort", "9223"], - "windows": { - "cwd": "${workspaceRoot}", - "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd", - "runtimeArgs": ["--sourcemap", "--remoteDebuggingPort", "9223"] - } - }, - { - "name": "Debug Render Process", - "type": "chrome", - "request": "attach", - "port": 9223, - "urlFilter": "http://localhost:*", - "timeout": 30000, - "webRoot": "${workspaceFolder}/src/renderer" - } - ], - "compounds": [ - { - "name": "Debug", - "configurations": ["Debug Main Process", "Debug Render Process"] - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ed42e20..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "i18n-ally.localesPaths": ["src/renderer/locales"], - "vitest.disableWorkspaceWarning": true, - "i18n-ally.keystyle": "nested", - "cSpell.words": [ - "bridgecmdr", - "extron", - "fgpa", - "pinia", - "radash", - "shinybow", - "sindresorhus", - "sixxgate", - "snes", - "vuelidate", - "vuetify" - ] -} diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 4f14322..0000000 --- a/.yarnrc +++ /dev/null @@ -1 +0,0 @@ ---ignore-engines true diff --git a/.yarnrc.yml b/.yarnrc.yml deleted file mode 100644 index ca80353..0000000 --- a/.yarnrc.yml +++ /dev/null @@ -1,3 +0,0 @@ -# In case we update to Yarn v4 -nmHoistingLimits: dependencies -nodeLinker: node-modules diff --git a/README.md b/README.md index 5ac78bb..9f54b2d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A/V switch and monitor controller ### BridgeCmdr -Copyright ©2019-2020 Matthew Holder +Copyright ©2019-2026 Matthew Holder This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) @@ -44,89 +44,59 @@ You will want a touchscreen, such as the official Raspberry Pi touchscreen, or a keyboard while setting up your configuration, but it is not needed during day-to-day use. You may also need additional USB-to-serial adapters or a serial HAT. Some supported monitors and switches can be -controlled over ethernet. For those you will need an ethernet cable; and if you have more than one such device, -an ethernet hub or switch. See [Wiki](https://github.com/6XGate/bridgecmdr/wiki) for more information on how +controlled over Ethernet. For those you will need an Ethernet cable; and if you have more than one such device, +an Ethernet hub or switch. See [Wiki](https://github.com/6XGate/bridgecmdr/wiki) for more information on how to connect to supported monitors and switches. ## Tools, Frameworks, Libraries, and Assets BridgeCmdr uses the following libraries and frameworks are a major part of its makeup. -| Framework/Library | License | -| -------------------------------------------------- | ------------------------------------------------------------------------ | -| [Electron](https://electronjs.org/) | [MIT](https://github.com/electron/electron/blob/master/LICENSE) | -| [Vue.js](https://vuejs.org/) | [MIT](https://github.com/vuejs/vue/blob/master/LICENSE) | -| [Vuetify](https://vuetifyjs.com/) | [MIT](https://github.com/vuetifyjs/vuetify/blob/master/LICENSE.md) | -| [PouchDB](https://pouchdb.com/) | [Apache 2.0](https://github.com/pouchdb/pouchdb/blob/master/LICENSE) | -| [LevelDOWN](https://github.com/Level/leveldown) | [MIT](https://github.com/Level/leveldown/blob/master/LICENSE) | -| [Vue I18n](https://vue-i18n.intlify.dev/) | [MIT](https://github.com/intlify/vue-i18n/blob/master/LICENSE) | -| [SerialPort](https://serialport.io/) | [MIT](https://github.com/serialport/node-serialport/blob/master/LICENSE) | -| [Vuelidate](https://vuelidate-next.netlify.app/) | [MIT](https://github.com/vuelidate/vuelidate/blob/next/LICENSE) | -| [zip.js](https://gildas-lormeau.github.io/zip.js/) | [BSD](https://github.com/gildas-lormeau/zip.js/blob/master/LICENSE) | +> TODO: Update licenses as dependencies are updated. -For a complete list of dependencies and other utilized libraries, see the `package.json` file. Any other dependencies -not listed above or in the package file are dependencies of those packages. +| Framework/Library | License | +|-------------------| ------- | -BridgeCmdr also uses the [Material Design Icons](https://pictogrammers.com/library/mdi/) SVG graphics which are -licensed under the [Pictogrammers Free License](https://pictogrammers.com/docs/general/license/). +For a complete list of dependencies and other utilized libraries, see the Gradle files. Any other dependencies not +listed above or in the package file are dependencies of those packages. + +BridgeCmdr also uses the [Material Design Icons](https://pictogrammers.com/library/mdi/) SVG graphics which are licensed +under the [Pictogrammers Free License](https://pictogrammers.com/docs/general/license/). ## Building The following tools or libraries are used to build and maintain BridgeCmdr. -- [TypeScript](https://www.typescriptlang.org/). -- [Prettier](https://prettier.io/) -- [ESLint](https://eslint.org/), and the following third-party plug-ins; - - [ESLint TypeScript plug-in](https://typescript-eslint.io/), - - [ESLint Import plug-in](https://github.com/benmosher/eslint-plugin-import), - - [ESLint Promise plug-in](https://github.com/xjamundx/eslint-plugin-promise), - - [ESLint Node plug-in](https://github.com/eslint-community/eslint-plugin-n), - - [ESLint Vue.js plug-in](https://eslint.vuejs.org/). -- [Electron Vite](https://evite.netlify.app/), and the following third-party plug-ins; - - [Vue.js plug-in](https://github.com/vitejs/vite-plugin-vue), - - [Dart Sass](https://sass-lang.com/dart-sass), - - [@intlify/bundle-tools](https://github.com/intlify/bundle-tools). -- [electron-builder)](https://www.electron.build/). -- [VisualStudio Code](https://code.visualstudio.com/) - -For a complete list of tools, see the `package.json` file. Any other dependencies not listed above or in the package -file are dependencies of those packages. +> TODO: Update the list of build requirements. -### Recommended IDE Setup +For a complete list of tools, see the Gradle files. Any other dependencies not listed above or in the package file are +dependencies of those packages. -[VSCode](https://code.visualstudio.com/) with: +### Build and Run Android Application -- [i18n Ally](https://marketplace.visualstudio.com/items?itemName=Lokalise.i18n-ally) -- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) -- [Dotenv + Vault](https://marketplace.visualstudio.com/items?itemName=dotenv.dotenv-vscode) -- [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) -- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) -- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) -- [Todo Tree](https://marketplace.visualstudio.com/items?itemName=Gruntfuggly.todo-tree) -- [YAML](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) +To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s +toolbar or build it directly from the terminal: -#### Type Support for `.vue` Imports in TS +```shell +./gradlew :composeApp:assembleDebug +``` -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for -type checking. +### Build and Run Desktop (JVM) Application -### Development +To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s +toolbar or run it directly from the terminal: -If you want to help with the development of BridgeCmdr, downloading, building, then running the project on a GNU/Linux -based operating system is required. The following steps will get you setup on a Debian-base operating system. +```shell +./gradlew :composeApp:run +``` -1. Install the `build-essential` package; `sudo apt install build-essential git -y` -2. Acquire the source: - - **Preferred**, Fork the [GitHub repository](https://github.com/6XGate/bridgecmdr), you may then issue pull - requests back to the official source code. Also start personal branches from `develop`. - - Download the [source](https://github.com/6XGate/bridgecmdr/archive/develop.zip) and extract it. -3. Open a terminal clone and go to the folder into which source was cloned or extracted. -4. Install the node packages; `yarn` -5. Build and run the app; +### Recommended IDE Setup + +> TODO: Update recommended IDE setup. + +### Development -- For hot-reload development mode: `yarn dev` -- For product builds: `yarn build` -- For packaged application: `yarn make` +> TODO: Update the development instructions. ### Docker and ARM support @@ -140,21 +110,23 @@ you will need to run `yarn` to reinstall the native code dependencies with their ### Packaging the Installer -To package the application, you will need to use `yarn package` steps you acquire the working copy of the source code -on an ARM system or in an ARM Docker container. It is not recommended to build directly on the Raspberry Pi since -the systems can be underpowered for such a purpose. - -#### The Package - -You should now have a package ending in `.AppImage` in the `dist` folder. This package can be run like any other -AppImage file. +> TODO: Update the packaging instructions. ### Releasing - Start the build docker conatiner: `docker compose run --build -it --rm build` -- Fresh install the packages: `yarn --force --frozen-lockfile` -- Package the application: `yarn make` -- Land and tag the release. +- > TODO: Update the release instructions. - Create a release from the tag and copy the following files to the release assets: - `bridgecmdr--armv7l.AppImage` - `latest-linux-arm.yml` + +## Source Code Structure + +This is a Kotlin Multiplatform project targeting Android and Desktop (JVM). + +* [/composeApp](./composeApp/src) is for code that will be shared across the Compose Multiplatform applications. + It contains several subfolders: + - [androidMain](./composeApp/src/androidMain/kotlin) is the code for the Android target. + - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. + - [jvmMain](./composeApp/src/commonMain/kotlin) is the code for the Desktop (JVM) target. + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1a29e0c --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +- [x] Add system to ktor client interactions to determine is a continuous series of failure are occurring, and return to + the scanning view. +- [x] Implement the double click/tap shutdown. +- [x] Implement the device power off. +- [x] macOS is POSIX, and while iOS could be too, it ain't from an app standpoint, right? +- [x] TypeScript version should be able to migrate its button order and settings to the SQLite store. +- [x] Kotlin version should be able to migrate it settings from the SQLite store. +- [x] Need to add v4 import format to v2.3.0 (TypeScript) to remove order and add buttonOrder setting. +- [x] Allow settings import from import functionality, will be similar objects to legacy settings. +- [x] Database migration system for v3, at least supporting the SQLite migration set. +- [x] Migrate the first-run state to the database in v2.3.0, so that v3 can start in the same place. +- [ ] v3 needs the first-start flow. (Auto-start question mainly) +- [ ] Depending on deployment, determine how updates will work, and how auto-start will work. +- [ ] Deployment setup for Kotlin desktop version. +- [ ] Fix the momentary flash of "Unknown error" when dismissing an error modal. +- [ ] Don't ignore the 500 server error as a troubling status code. +- [ ] Release v2.3.0 of TypeScript version. +- [ ] Need short snackbar to show when clicking the Power off button with double-tap enabled. +- [ ] Need an interface flow to explain the mobile remote control apps from the server code view. Maybe the first time + the code view is opened. Maybe even a link to show the information again. QR Codes to the app stores would be + nice. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..84600eb --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kover) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.compose.hotReload) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ktor) apply false +} diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 0000000..0f4d7d4 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,197 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kover) + alias(libs.plugins.android.application) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.hotReload) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + jvm() + + sourceSets { + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + implementation(libs.slf4j) + implementation(libs.uk.slf4j) + implementation(libs.ktor.client.auth) + implementation(libs.codeScanner) + } + + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.kotlin.serialization.json) + implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.navigation.compose) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.serializationJson) + implementation(libs.koin.core) + implementation(libs.koin.logger.slf4j) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.viewmodel.navigation) + implementation(libs.reorderable) + implementation(libs.coil.compose) + implementation(libs.coil.svg) + implementation(libs.konform) + implementation(libs.atlassian.onetime) + implementation(libs.oshai.logging) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.koin.test) + } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.ktor.network) + implementation(libs.ktor.network.tlsCertificates) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.di) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.contentNegotiation) + implementation(libs.ktor.server.requestValidation) + implementation(libs.ktor.server.resources) + implementation(libs.ktor.server.statusPages) + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.migration.core) + implementation(libs.exposed.migration.jdbc) + implementation(libs.koin.ktor) + implementation(libs.dbus.core) + implementation(libs.dbus.transport.nativeUnixsocket) + implementation(libs.filekit.dialogs.compose) + implementation(libs.jserialcomm) + implementation(libs.logback) + implementation(libs.sqlite) + implementation(libs.zxing.core) + } + jvmTest.dependencies { + implementation(libs.ktor.server.testHost) + implementation(libs.kotlin.testJunit) + implementation(libs.koin.test.junit4) + implementation(libs.mockk) + } + } +} + +tasks.withType { + jvmArgs("-XX:+EnableDynamicAgentLoading") +} + +android { + namespace = "org.sleepingcats.bridgecmdr" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + + defaultConfig { + applicationId = "org.sleepingcats.bridgecmdr" + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + targetSdk = + libs.versions.android.targetSdk + .get() + .toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + manifestPlaceholders["usesCleartextTraffic"] = "false" + } + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + debugImplementation(compose.uiTooling) +} + +compose.desktop { + application { + mainClass = "org.sleepingcats.bridgecmdr.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.AppImage) + packageName = "BridgeCmdr" + packageVersion = "3.0.0" + description = "Controller for professional A/V monitors and switches" + copyright = "© 2019-2026 Matthew Holder" + vendor = "Matthew Holder" + licenseFile.set(project.file("LICENSE")) + + windows { + // iconFile.set(project.file("src/jvmMain/resources/drawable/app_icon.ico")) + upgradeUuid = "D4E6F2B2-1C3A-4F5D-8E2A-9C6B7D8E9F0A" + } + + macOS { + // iconFile.set(project.file("src/jvmMain/resources/drawable/app_icon.icns")) + bundleID = "org.sleepingcats.bridgecmdr" + appCategory = "public.app-category.utilities" + minimumSystemVersion = "10.13" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/drawable/app_icon.png")) + packageName = "bridgecmdr" + appRelease = "1" + debMaintainer = "sixxgate@hotmail.com" + rpmLicenseType = "GPL-3.0-or-later" + } + } + + // This is an open-source application, so we can disable + // code obfuscation. + buildTypes.release.proguard { + obfuscate.set(false) + isEnabled = false + } + } +} + +kover { +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..633a251 --- /dev/null +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainActivity.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainActivity.kt new file mode 100644 index 0000000..ed82610 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainActivity.kt @@ -0,0 +1,64 @@ +package org.sleepingcats.bridgecmdr + +import android.content.res.Configuration +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.ui.view.model.ApplicationViewModel + +class MainActivity : ComponentActivity() { + private val viewModel: ApplicationViewModel by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + val appTheme = state.appTheme + val darkMode = + when (appTheme) { + AppTheme.Light -> { + true + } + + AppTheme.Dark -> { + false + } + + AppTheme.System -> { + when (resources.configuration.uiMode) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_NO -> false + else -> false + } + } + } + + WindowCompat + .getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = darkMode + } + } + } + + setContent { + App() + } + } +} + +// @Preview +// @Composable +// fun AppAndroidPreview() { +// App() +// } diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainApplication.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainApplication.kt new file mode 100644 index 0000000..1b70e55 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainApplication.kt @@ -0,0 +1,30 @@ +package org.sleepingcats.bridgecmdr + +import android.app.Application +import io.github.oshai.kotlinlogging.KotlinLogging +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin +import org.koin.core.logger.Level +import org.koin.dsl.module +import org.koin.logger.SLF4JLogger +import org.sleepingcats.bridgecmdr.common.module.remoteServiceModule +import org.sleepingcats.bridgecmdr.ui.module.commonModule +import org.sleepingcats.bridgecmdr.ui.module.platformSpecificModule + +class MainApplication : Application() { + override fun onCreate() { + System.setProperty("kotlin-logging-to-android-native", "true") + super.onCreate() + + startKoin { + logger(SLF4JLogger(Level.INFO)) + androidContext(this@MainApplication) + modules( + module { single { KotlinLogging.logger(Branding.qualifiedName) } }, + remoteServiceModule, + commonModule, + platformSpecificModule, + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.android.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.android.kt new file mode 100644 index 0000000..b84c0e0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.android.kt @@ -0,0 +1,18 @@ +package org.sleepingcats.bridgecmdr.ui + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable +import org.sleepingcats.bridgecmdr.ui.view.MobileSettingsRoute +import org.sleepingcats.bridgecmdr.ui.view.ScannerRoute + +actual fun getStartupRoute(): Route = ScanCode // Dashboard + +actual fun NavGraphBuilder.platformRoutes(navController: NavController) { + composable { ScannerRoute(navController) } + composable { MobileSettingsRoute(navController) } +} + +@Serializable +object ScanCode : Route diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.android.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.android.kt new file mode 100644 index 0000000..718a483 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.android.kt @@ -0,0 +1,12 @@ +package org.sleepingcats.bridgecmdr.ui.module + +import coil3.PlatformContext +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.bind +import org.koin.dsl.module + +val platformContextModule = + module { + // Real platform context for Android. + single { androidContext() } bind PlatformContext::class + } diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.android.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.android.kt new file mode 100644 index 0000000..3f42011 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.android.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.module + +import android.content.Context +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.emptyPreferences +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.bind +import org.koin.dsl.binds +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.common.security.TokenService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.ui.service.MobileConnectionService +import org.sleepingcats.bridgecmdr.ui.view.model.ApplicationViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.MobileDashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.MobileSettingsViewModel +import org.sleepingcats.core.settings.PreferencesDataStore +import kotlin.uuid.ExperimentalUuidApi + +val platformSpecificModule = + module { + // + // Preferences data store + // + + single { + PreferencesDataStore( + PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + ) { + get().filesDir.resolve("${Branding.qualifiedName}.preferences_pb") + }, + ) + } + + // + // Services + // + + singleOf(::TokenService) + singleOf(::MobileConnectionService) binds + arrayOf( + MobileConnectionService::class, + ConnectionService::class, + ) + + // + // View models + // + + viewModelOf(::ApplicationViewModel) + viewModelOf(::MobileDashboardViewModel) bind DashboardViewModel::class + viewModelOf(::MobileSettingsViewModel) + } diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.android.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.android.kt new file mode 100644 index 0000000..a6460c5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.android.kt @@ -0,0 +1,32 @@ +package org.sleepingcats.bridgecmdr.ui.platform + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import io.github.oshai.kotlinlogging.KLogger +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.ScanCode +import org.sleepingcats.bridgecmdr.ui.view.model.DashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.MobileDashboardViewModel + +@Composable +actual fun ColumnScope.PlatformDashboardActions( + goTo: (route: Route) -> Unit, + startOver: (route: Route) -> Unit, +) { + val logger: KLogger = koinInject() + val dashboardViewModel = koinViewModel() as MobileDashboardViewModel + + val connected by dashboardViewModel.connected.collectAsState(false) + LaunchedEffect(connected) { + if (!connected) { + logger.warn { "Connection lost, returning to scanner." } + // If the connection is lost, go to the scanner. + startOver(ScanCode) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt new file mode 100644 index 0000000..98532e5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt @@ -0,0 +1,220 @@ +package org.sleepingcats.bridgecmdr.ui.service + +import android.annotation.SuppressLint +import io.github.oshai.kotlinlogging.KLogger +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.plugin +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.encodeBase64 +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.common.ServerCode +import org.sleepingcats.bridgecmdr.common.security.TokenService +import org.sleepingcats.bridgecmdr.common.security.encryptWith +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import java.net.ConnectException +import java.security.PublicKey +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.SSLProtocolException +import javax.net.ssl.X509TrustManager +import kotlin.time.Duration.Companion.seconds + +@SuppressLint("CustomX509TrustManager") // Using public key validation. +class MobileConnectionService( + private val tokenService: TokenService, + private val logger: KLogger, +) : ConnectionService, + X509TrustManager { + private val client = MutableStateFlow(null) + + suspend fun getClient(): HttpClient = client.filterNotNull().take(1).last() + + private var troubleLevel = 0 + + val connected = client.map { it != null } + + private var publicKey: PublicKey? = null + + /** HTTP status codes that indicate trouble */ + private val troublingCodes = + listOf( + // Device time may be too far askew or the server restarted, + // generating new tokens. + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + // Server may be overloaded or having issues. + HttpStatusCode.InternalServerError, + HttpStatusCode.RequestTimeout, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout, + ) + + private fun updateConnect(newClient: HttpClient?) { + client.update { oldClient -> + if (oldClient != null) { + logger.info { "Re-initializing connection service, closing existing client" } + oldClient.close() + } + + newClient + } + } + + private fun resetTroubleLevel() { + troubleLevel = 0 + } + + private fun raiseTroubleLevel(by: Int = 1) { + troubleLevel += by + if (troubleLevel >= 5) { + logger.warn { "Connection seems troublesome, indicate disconnection" } + updateConnect(null) + } + } + + /** Watches an HTTP request, raising the trouble level if a timeout occurs. */ + override suspend fun watchRequest(block: suspend HttpClient.() -> R): R { + val theClient = getClient() + + return runCatching { theClient.block() } + .onSuccess { resetTroubleLevel() } + .onFailure { cause -> + when (cause) { + // These exceptions are serious enough to raise the trouble + // level significantly, triggering a disconnection. + is CertificateException, + is SSLProtocolException, + is SocketTimeoutException, + is ConnectException, + -> raiseTroubleLevel(5) + + // These exception could occur but resolve later, not being + // serious enough to immediately disconnect. + is HttpRequestTimeoutException, + -> raiseTroubleLevel() + + // All other exceptions should be ignored by this process. + else -> Unit + } + }.getOrThrow() + } + + /** Initializes the connection from a server code. */ + fun initialize(code: ServerCode) { + logger.info { "Connecting to ${code.url} based on server code" } + + val newClient = + HttpClient(CIO) { + engine { + https { + serverName = Url(code.url).host + trustManager = this@MobileConnectionService + } + } + + defaultRequest { url(code.url) } + followRedirects = false + expectSuccess = true + + install(UserAgent) { agent = "${Branding.name}/${Branding.version}" } + install(ContentNegotiation) { json() } + install(HttpTimeout) { requestTimeoutMillis = 5.seconds.inWholeMilliseconds } + install(Auth) { + // Uses TOTP based bearer tokens, encrypted with the server's public key. + + bearer { + realm = Branding.name + loadTokens { + val accessToken = + tokenService + .generateToken(code.tokenSecret) + .first() + .value + .encryptWith(code.publicKey) + .encodeBase64() + + BearerTokens(accessToken, null) + } + refreshTokens { + val accessToken = + tokenService + .generateToken(code.tokenSecret) + .first() + .value + .encryptWith(code.publicKey) + .encodeBase64() + + BearerTokens(accessToken, null) + } + } + } + }.apply { + plugin(HttpSend).intercept { request -> + // If the response has a troubling status code, raise the trouble level. + val original = execute(request) + if (original.response.status in troublingCodes) { + raiseTroubleLevel() + } else { + resetTroubleLevel() + } + + original + } + } + + publicKey = code.publicKey + updateConnect(newClient) + } + + private fun isCertificateTrusted(cert: X509Certificate): Boolean = runCatching { cert.verify(publicKey) }.isSuccess + + private fun checkChainIsTrusted( + chain: Array?, + lazyMessage: () -> String = { "Certificate chain is not trusted" }, + ) { + require(chain != null) { "Certificate chain is null" } + require(chain.isNotEmpty()) { "Certificate chain is empty" } + for (cert in chain) { + if (cert == null) continue + if (isCertificateTrusted(cert)) return + } + + throw CertificateException(lazyMessage()) + } + + override fun checkClientTrusted( + chain: Array?, + authType: String?, + ) { + checkChainIsTrusted(chain) + } + + override fun checkServerTrusted( + chain: Array?, + authType: String?, + ) { + checkChainIsTrusted(chain) + } + + override fun getAcceptedIssuers(): Array = emptyArray() +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/MobileSettingsView.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/MobileSettingsView.kt new file mode 100644 index 0000000..9414ff8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/MobileSettingsView.kt @@ -0,0 +1,104 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.branding_appName +import bridgecmdr.composeapp.generated.resources.information +import bridgecmdr.composeapp.generated.resources.setting_AppTheme +import bridgecmdr.composeapp.generated.resources.setting_IconSize +import bridgecmdr.composeapp.generated.resources.setting_IconSize_Size +import bridgecmdr.composeapp.generated.resources.settings +import bridgecmdr.composeapp.generated.resources.settings_about +import bridgecmdr.composeapp.generated.resources.settings_about_support +import bridgecmdr.composeapp.generated.resources.theme_light_dark +import bridgecmdr.composeapp.generated.resources.view_dashboard +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.SelectModal +import org.sleepingcats.bridgecmdr.ui.view.model.MobileSettingsViewModel + +@Composable +fun MobileSettingsRoute(navController: NavController) = MobileSettingsView(goBack = { navController.popBackStack() }) + +@Composable +private fun MobileSettingsView( + goBack: () -> Unit, + viewModel: MobileSettingsViewModel = koinViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + val scope = rememberCoroutineScope() + + val selectIconSize = + SelectModal( + itemContent = { stringResource(Res.string.setting_IconSize_Size, it.size) }, + items = IconSize.entries, + ) { value -> if (value != null) scope.launch { viewModel.setIconSize(value) } } + + val selectColorTheme = + SelectModal( + itemContent = { stringResource(it.option) }, + items = AppTheme.entries, + ) { value -> if (value != null) scope.launch { viewModel.setAppTheme(value) } } + + val appName = stringResource(Res.string.branding_appName) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings)) }, + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) { + ListItem( + modifier = Modifier.clickable { selectIconSize(state.iconSize) }, + leadingContent = { Icon(painterResource(Res.drawable.view_dashboard), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_IconSize)) }, + supportingContent = { Text(stringResource(Res.string.setting_IconSize_Size, state.iconSize.size)) }, + ) + ListItem( + modifier = Modifier.clickable { selectColorTheme(state.appTheme) }, + leadingContent = { Icon(painterResource(Res.drawable.theme_light_dark), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_AppTheme)) }, + supportingContent = { Text(stringResource(state.appTheme.description)) }, + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.information), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.settings_about, appName)) }, + supportingContent = { Text(stringResource(Res.string.settings_about_support, Branding.version)) }, + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ScannerView.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ScannerView.kt new file mode 100644 index 0000000..1f65e85 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ScannerView.kt @@ -0,0 +1,173 @@ +package org.sleepingcats.bridgecmdr.ui.view + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.navigation.NavController +import com.budiyev.android.codescanner.AutoFocusMode.SAFE +import com.budiyev.android.codescanner.ButtonPosition.TOP_END +import com.budiyev.android.codescanner.ButtonPosition.TOP_START +import com.budiyev.android.codescanner.CodeScanner +import com.budiyev.android.codescanner.CodeScanner.CAMERA_BACK +import com.budiyev.android.codescanner.CodeScannerView +import com.budiyev.android.codescanner.DecodeCallback +import com.budiyev.android.codescanner.ErrorCallback +import com.google.zxing.BarcodeFormat.QR_CODE +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.sleepingcats.bridgecmdr.common.ServerCode +import org.sleepingcats.bridgecmdr.common.extension.navigateAndReplaceStartRoute +import org.sleepingcats.bridgecmdr.ui.Dashboard +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.service.MobileConnectionService + +@Composable +fun ScannerRoute(navController: NavController) { + ScannerView(goTo = { route -> navController.navigateAndReplaceStartRoute(route) }) +} + +@Composable +private fun ScannerView( + goTo: (route: Route) -> Unit, + service: MobileConnectionService = koinInject(), + context: Context = koinInject(), + logger: KLogger = koinInject(), +) { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { results -> + for ((permission, granted) in results) { + if (!granted) logger.error { "Permission denied: $permission" } + } + } + + LaunchedEffect(Unit) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { + logger.info { "Camera permission granted" } + } + + ContextCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) -> { + logger.info { "Near-by devices permission granted" } + } + + else -> { + logger.info { "Requesting camera and near-by devices permissions" } + launcher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.NEARBY_WIFI_DEVICES, + ), + ) + } + } + } + + val scope = rememberCoroutineScope() + + var codeScanner: CodeScanner? = null + LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { + codeScanner?.releaseResources() + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + codeScanner?.startPreview() + } + + val layoutDirection = LocalLayoutDirection.current + with(LocalDensity.current) { + Scaffold { paddingValues -> + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val topStart = + DpOffset( + paddingValues.calculateStartPadding(layoutDirection) + 16.dp, + paddingValues.calculateTopPadding() + 16.dp, + ) + + val topEnd = + DpOffset( + paddingValues.calculateEndPadding(layoutDirection) + 16.dp, + paddingValues.calculateTopPadding() + 16.dp, + ) + + val codeScannerView = + CodeScannerView(ctx).apply { + isAutoFocusButtonVisible = true + autoFocusButtonColor = Color.White.toArgb() + autoFocusButtonPosition = TOP_START + autoFocusButtonPaddingHorizontal = topStart.x.toPx().toInt() + autoFocusButtonPaddingVertical = topStart.y.toPx().toInt() + isFlashButtonVisible = true + flashButtonColor = Color.White.toArgb() + flashButtonPosition = TOP_END + flashButtonPaddingHorizontal = topEnd.x.toPx().toInt() + flashButtonPaddingVertical = topEnd.y.toPx().toInt() + frameColor = Color.White.toArgb() + frameCornersSize = 75 + frameCornersRadius = 25 + frameAspectRatioWidth = 1f + frameAspectRatioHeight = 1f + frameThickness = 5 + frameSize = 0.75f + frameVerticalBias = 0.5f + maskColor = Color.Black.copy(alpha = 0.75f).toArgb() + } + + codeScanner = + CodeScanner(ctx, codeScannerView).apply { + camera = CAMERA_BACK + formats = listOf(QR_CODE) + autoFocusMode = SAFE + + decodeCallback = + DecodeCallback { result -> + this.releaseResources() + service.initialize(ServerCode.fromQrData(result.text)) + // Sometimes this callback is not called on the main thread. + scope.launch(Dispatchers.Main) { goTo(Dashboard) } + } + + errorCallback = + ErrorCallback { error -> + logger.error(error) { "Scan failed" } + // TODO: "Crash" out + } + } + + // Start scanning + codeScanner.startPreview() + + codeScannerView + }, + onRelease = { + codeScanner?.releaseResources() + }, + ) + } + } +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileDashboardViewModel.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileDashboardViewModel.kt new file mode 100644 index 0000000..980bd6e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileDashboardViewModel.kt @@ -0,0 +1,18 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import io.github.oshai.kotlinlogging.KLogger +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.service.MobileConnectionService + +class MobileDashboardViewModel( + logger: KLogger, + baseConnectionService: ConnectionService, + settingsRepository: SettingsRepository, + repository: SourceRepository, +) : DashboardViewModel(logger, settingsRepository, repository) { + private val connectionService = baseConnectionService as MobileConnectionService + + val connected = connectionService.connected +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileSettingsViewModel.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileSettingsViewModel.kt new file mode 100644 index 0000000..3ab0d72 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileSettingsViewModel.kt @@ -0,0 +1,38 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel + +class MobileSettingsViewModel( + logger: KLogger, + private val settings: SettingsRepository, +) : TrackingViewModel(logger) { + data class State( + val isLoading: Boolean = false, + val appTheme: AppTheme = AppTheme.System, + val iconSize: IconSize = IconSize.Normal, + ) + + val state = + settings.data + .map { + State( + appTheme = it.appTheme, + iconSize = it.iconSize, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + suspend fun setAppTheme(newAppTheme: AppTheme) = loadingWhile { settings.setAppTheme(newAppTheme) } + + suspend fun setIconSize(newIconSize: IconSize) = loadingWhile { settings.setIconSize(newIconSize) } +} diff --git a/composeApp/src/androidMain/kotlin/org/sleepingcats/core/Platform.android.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/core/Platform.android.kt new file mode 100644 index 0000000..d60a68e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/core/Platform.android.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.core + +class AndroidPlatform : Platform { + override val name = checkNotNull(System.getProperty("os.name")) + override val version = checkNotNull(System.getProperty("os.version")) + override val arch = checkNotNull(System.getProperty("os.arch")) + override val type = PlatformType.Android +} + +actual fun getPlatform(): Platform = AndroidPlatform() diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_app_shortcut_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_app_shortcut_foreground.xml new file mode 100644 index 0000000..a5f8edc --- /dev/null +++ b/composeApp/src/androidMain/res/drawable-v24/ic_app_shortcut_foreground.xml @@ -0,0 +1,8 @@ + + + diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..e0b051b --- /dev/null +++ b/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,7 @@ + diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..72baecf --- /dev/null +++ b/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7e91a57 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7e91a57 --- /dev/null +++ b/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..79a3a18 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..295ab6d Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..073e227 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..cb385b7 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..b2473fd Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7f0383b Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..50319ad Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c145c24 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..48d200e Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..65f0a09 Binary files /dev/null and b/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/composeApp/src/androidMain/res/values/strings.xml b/composeApp/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..4b9487e --- /dev/null +++ b/composeApp/src/androidMain/res/values/strings.xml @@ -0,0 +1,3 @@ + + BridgeCmdr + diff --git a/composeApp/src/androidMain/resources/uk/uuid/slf4j/android/config.properties b/composeApp/src/androidMain/resources/uk/uuid/slf4j/android/config.properties new file mode 100644 index 0000000..0012de5 --- /dev/null +++ b/composeApp/src/androidMain/resources/uk/uuid/slf4j/android/config.properties @@ -0,0 +1,3 @@ +tag=bridgecmdr +showThread=true +showName=short diff --git a/composeApp/src/commonMain/composeResources/drawable/alert_circle.xml b/composeApp/src/commonMain/composeResources/drawable/alert_circle.xml new file mode 100644 index 0000000..f3e72a4 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/alert_circle.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/arrow_left.xml b/composeApp/src/commonMain/composeResources/drawable/arrow_left.xml new file mode 100644 index 0000000..828f7a8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/arrow_left.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/close.xml b/composeApp/src/commonMain/composeResources/drawable/close.xml new file mode 100644 index 0000000..8863cbf --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/close.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/cog.xml b/composeApp/src/commonMain/composeResources/drawable/cog.xml new file mode 100644 index 0000000..04fcde3 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/cog.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/cogs.xml b/composeApp/src/commonMain/composeResources/drawable/cogs.xml new file mode 100644 index 0000000..1dd9026 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/cogs.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/controller_classic.xml b/composeApp/src/commonMain/composeResources/drawable/controller_classic.xml new file mode 100644 index 0000000..27c47fc --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/controller_classic.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/delete.xml b/composeApp/src/commonMain/composeResources/drawable/delete.xml new file mode 100644 index 0000000..5b69d0b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/delete.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/exit_run.xml b/composeApp/src/commonMain/composeResources/drawable/exit_run.xml new file mode 100644 index 0000000..c41ebfe --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/exit_run.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/export.xml b/composeApp/src/commonMain/composeResources/drawable/export.xml new file mode 100644 index 0000000..dd08822 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/export.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/flask.xml b/composeApp/src/commonMain/composeResources/drawable/flask.xml new file mode 100644 index 0000000..96589e9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/flask.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/gamepad_square.xml b/composeApp/src/commonMain/composeResources/drawable/gamepad_square.xml new file mode 100644 index 0000000..8bac905 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/gamepad_square.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/image_plus.xml b/composeApp/src/commonMain/composeResources/drawable/image_plus.xml new file mode 100644 index 0000000..bf6391e --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/image_plus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/import.xml b/composeApp/src/commonMain/composeResources/drawable/import.xml new file mode 100644 index 0000000..24e39e9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/import.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/information.xml b/composeApp/src/commonMain/composeResources/drawable/information.xml new file mode 100644 index 0000000..4fdd6a6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/information.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/menu_down.xml b/composeApp/src/commonMain/composeResources/drawable/menu_down.xml new file mode 100644 index 0000000..7c73ec1 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/menu_down.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/minus.xml b/composeApp/src/commonMain/composeResources/drawable/minus.xml new file mode 100644 index 0000000..b2e07bd --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/minus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/monitor.xml b/composeApp/src/commonMain/composeResources/drawable/monitor.xml new file mode 100644 index 0000000..2bce60c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/monitor.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/nintendo_game_boy.xml b/composeApp/src/commonMain/composeResources/drawable/nintendo_game_boy.xml new file mode 100644 index 0000000..e7f955d --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/nintendo_game_boy.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/plus.xml b/composeApp/src/commonMain/composeResources/drawable/plus.xml new file mode 100644 index 0000000..b226b01 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/plus.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/power.xml b/composeApp/src/commonMain/composeResources/drawable/power.xml new file mode 100644 index 0000000..f55e512 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/power.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/power_socket.xml b/composeApp/src/commonMain/composeResources/drawable/power_socket.xml new file mode 100644 index 0000000..1c766c2 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/power_socket.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/refresh_auto.xml b/composeApp/src/commonMain/composeResources/drawable/refresh_auto.xml new file mode 100644 index 0000000..11f7fd9 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/refresh_auto.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/server_network.xml b/composeApp/src/commonMain/composeResources/drawable/server_network.xml new file mode 100644 index 0000000..edd7591 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/server_network.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/server_outline.xml b/composeApp/src/commonMain/composeResources/drawable/server_outline.xml new file mode 100644 index 0000000..020b1ce --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/server_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/swap_vertical.xml b/composeApp/src/commonMain/composeResources/drawable/swap_vertical.xml new file mode 100644 index 0000000..c8e560c --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/swap_vertical.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/theme_light_dark.xml b/composeApp/src/commonMain/composeResources/drawable/theme_light_dark.xml new file mode 100644 index 0000000..72638bf --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/theme_light_dark.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/video_switch.xml b/composeApp/src/commonMain/composeResources/drawable/video_switch.xml new file mode 100644 index 0000000..b4c93e6 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/video_switch.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/view_dashboard.xml b/composeApp/src/commonMain/composeResources/drawable/view_dashboard.xml new file mode 100644 index 0000000..3b8ec85 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/view_dashboard.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/drawable/volume_medium.xml b/composeApp/src/commonMain/composeResources/drawable/volume_medium.xml new file mode 100644 index 0000000..76d77df --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/volume_medium.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/actions.xml b/composeApp/src/commonMain/composeResources/values/actions.xml new file mode 100644 index 0000000..8760de1 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/actions.xml @@ -0,0 +1,16 @@ + + + + Add + Back + Collapse + Close + Decrement + Delete + Discard + Edit + Expand + Increment + Keep + Save + diff --git a/composeApp/src/commonMain/composeResources/values/branding.xml b/composeApp/src/commonMain/composeResources/values/branding.xml new file mode 100644 index 0000000..66ea280 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/branding.xml @@ -0,0 +1,7 @@ + + + BridgeCmdr + Matthew Holder + © 2019-2026 Matthew Holder. All rights reserved. + 3.0.0 + diff --git a/composeApp/src/commonMain/composeResources/values/devices.xml b/composeApp/src/commonMain/composeResources/values/devices.xml new file mode 100644 index 0000000..a016310 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/devices.xml @@ -0,0 +1,5 @@ + + + Serial port + Remote host + diff --git a/composeApp/src/commonMain/composeResources/values/response.xml b/composeApp/src/commonMain/composeResources/values/response.xml new file mode 100644 index 0000000..db8dc03 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/response.xml @@ -0,0 +1,9 @@ + + + + Got it + Cancel + OK + No + Yes + diff --git a/composeApp/src/commonMain/composeResources/values/settings.xml b/composeApp/src/commonMain/composeResources/values/settings.xml new file mode 100644 index 0000000..1c8c870 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/settings.xml @@ -0,0 +1,23 @@ + + + + Settings + About %1$s + Version %1$s + + Color theme + Use dark theme + Dark + Use light theme + Light + Use system theme + System theme + Dashboard icon size + %1$d ⨉ %1$d + Power button behavior + The power button will power off the system after + Will power off when tapped or clicked twice + Double-tapping / double-clicking + Will power off when tapped or clicked once + Single-tapping / single-clicking + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..01e2074 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,9 @@ + + + + An unknown error occurred + + + Loading... + Choose image + diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/App.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/App.kt new file mode 100644 index 0000000..bf2e41a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/App.kt @@ -0,0 +1,65 @@ +package org.sleepingcats.bridgecmdr + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.unknown_error +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.getStartupRoute +import org.sleepingcats.bridgecmdr.ui.routes +import org.sleepingcats.bridgecmdr.ui.theme.BridgeCmdrTheme +import org.sleepingcats.bridgecmdr.ui.view.model.ApplicationViewModel + +@Composable +fun App(viewModel: ApplicationViewModel = koinViewModel()) { + val navController = rememberNavController() + val state by viewModel.state.collectAsState() + val useDarkTheme = + when (state.appTheme) { + AppTheme.System -> isSystemInDarkTheme() + AppTheme.Light -> false + AppTheme.Dark -> true + } + + BridgeCmdrTheme(useDarkTheme) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + // Dismiss for a fatal error here MUST exit the application. + // That is the viewModel's responsibility to set that up. + AlertModal( + visible = state.fatalError != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + if (state.fatalError == null) { + NavHost( + navController, + startDestination = getStartupRoute(), + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), + ) { + routes(navController) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Branding.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Branding.kt new file mode 100644 index 0000000..a39616e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Branding.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.bridgecmdr + +import org.sleepingcats.core.ApplicationBranding + +object Branding : ApplicationBranding( + "org.sleepingcats", + "Matthew Holder", + "BridgeCmdr", + "3.0.0", +) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Greeting.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Greeting.kt new file mode 100644 index 0000000..67beffc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Greeting.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr + +import org.sleepingcats.core.Platform + +object Greeting { + fun greet(): String = "Hello, ${Platform.name}!" +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/Mode.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/Mode.kt new file mode 100644 index 0000000..1d62604 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/Mode.kt @@ -0,0 +1,5 @@ +package org.sleepingcats.bridgecmdr.common + +val isProduction by lazy { System.getenv("BRIDGECMDR_MODE")?.lowercase() != "prod" } + +val isDevelopment by lazy { System.getenv("BRIDGECMDR_MODE")?.lowercase() == "dev" } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/ServerCode.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/ServerCode.kt new file mode 100644 index 0000000..d55d1a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/ServerCode.kt @@ -0,0 +1,33 @@ +package org.sleepingcats.bridgecmdr.common + +import com.atlassian.onetime.model.TOTPSecret +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec +import kotlin.io.encoding.Base64 + +class ServerCode( + val url: String, + val tokenSecret: TOTPSecret, + val publicKey: PublicKey, +) { + companion object { + fun fromQrData(data: String): ServerCode { + val parts = data.split("|", limit = 3) + check(parts.size == 3) { "Invalid server code" } + + val (url, encodedTokenSecret, encodedPublicKey) = parts + val tokenSecret = TOTPSecret.fromBase32EncodedString(encodedTokenSecret) + val publicKey = + Base64.decode(encodedPublicKey).run { + val keyFactory = KeyFactory.getInstance("RSA") + val keySpec = X509EncodedKeySpec(this) + keyFactory.generatePublic(keySpec) + } + + return ServerCode(url, tokenSecret, publicKey) + } + } + + val qrCodeData by lazy { "$url|${tokenSecret.base32Encoded}|${Base64.encode(publicKey.encoded)}" } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/NavController.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/NavController.kt new file mode 100644 index 0000000..7bd1c35 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/NavController.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import androidx.navigation.NavController +import org.sleepingcats.bridgecmdr.ui.Route + +fun NavController.navigateAndReplaceStartRoute(route: Route) { + popBackStack(graph.startDestinationId, true) + graph.setStartDestination(route) + navigate(route) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Preferences.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Preferences.kt new file mode 100644 index 0000000..698caae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Preferences.kt @@ -0,0 +1,28 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + +class JsonPreferencesKey( + val name: String, + val serializer: KSerializer, +) { + val key = stringPreferencesKey(name) + val deserializer = serializer +} + +inline fun jsonPreferencesKey(name: String) = JsonPreferencesKey(name, serializer = serializer()) + +inline operator fun Preferences.get(setting: JsonPreferencesKey): T? = + this[setting.key]?.let { stringValue -> Json.decodeFromString(setting.deserializer, stringValue) } + +inline operator fun MutablePreferences.set( + setting: JsonPreferencesKey, + value: T, +) { + this[setting.key] = Json.encodeToString(setting.serializer, value) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/String.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/String.kt new file mode 100644 index 0000000..d998b2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/String.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.extension + +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +fun String.toUuid(): Uuid = Uuid.parse(this) + +fun String.hexToUuid(): Uuid = Uuid.parseHex(this) + +fun String.hexDashToUuid(): Uuid = Uuid.parseHexDash(this) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/module/RemoteServiceModule.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/module/RemoteServiceModule.kt new file mode 100644 index 0000000..ee16e9f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/module/RemoteServiceModule.kt @@ -0,0 +1,33 @@ +package org.sleepingcats.bridgecmdr.common.module + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.bridgecmdr.common.service.LegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.PowerService +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteDeviceService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteDriverService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteLegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.remote.RemotePowerService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteSerialPortService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteSourceService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteTieService +import org.sleepingcats.bridgecmdr.common.service.remote.RemoteUserImageService + +var remoteServiceModule = + module { + singleOf(::RemoteDeviceService) bind DeviceService::class + singleOf(::RemoteDriverService) bind DriverService::class + singleOf(::RemotePowerService) bind PowerService::class + singleOf(::RemoteLegacySettingsService) bind LegacySettingsService::class + singleOf(::RemoteSerialPortService) bind SerialPortService::class + singleOf(::RemoteSourceService) bind SourceService::class + singleOf(::RemoteTieService) bind TieService::class + singleOf(::RemoteUserImageService) bind UserImageService::class + } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/Encryption.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/Encryption.kt new file mode 100644 index 0000000..8ce0fe7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/Encryption.kt @@ -0,0 +1,37 @@ +package org.sleepingcats.bridgecmdr.common.security + +import java.security.Key +import java.security.PublicKey +import javax.crypto.Cipher + +fun getCipher(): Cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA1AndMGF1Padding") + +fun getDecryptor(key: PublicKey): Cipher = getCipher().apply { init(Cipher.DECRYPT_MODE, key) } + +fun getDecryptor(key: Key): Cipher = getCipher().apply { init(Cipher.DECRYPT_MODE, key) } + +fun getEncryptor(key: PublicKey): Cipher = getCipher().apply { init(Cipher.ENCRYPT_MODE, key) } + +fun getEncryptor(key: Key): Cipher = getCipher().apply { init(Cipher.ENCRYPT_MODE, key) } + +fun ByteArray.encryptWith(key: PublicKey): ByteArray = getEncryptor(key).doFinal(this) + +fun ByteArray.encryptWith(key: Key): ByteArray = getEncryptor(key).doFinal(this) + +fun ByteArray.decryptWith(key: PublicKey): ByteArray = getDecryptor(key).doFinal(this) + +fun ByteArray.decryptWith(key: Key): ByteArray = getDecryptor(key).doFinal(this) + +fun String.encryptWith(key: PublicKey): ByteArray = this.toByteArray().encryptWith(key) + +fun String.encryptWith(key: Key): ByteArray = this.toByteArray().encryptWith(key) + +fun String.Companion.decrypt( + data: ByteArray, + key: PublicKey, +): String = String(data.decryptWith(key)) + +fun String.Companion.decrypt( + data: ByteArray, + key: Key, +): String = String(data.decryptWith(key)) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/TokenService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/TokenService.kt new file mode 100644 index 0000000..83ce249 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/TokenService.kt @@ -0,0 +1,33 @@ +package org.sleepingcats.bridgecmdr.common.security + +import com.atlassian.onetime.core.HMACDigest.SHA1 +import com.atlassian.onetime.core.OTPLength.TEN +import com.atlassian.onetime.core.TOTP +import com.atlassian.onetime.core.TOTPGenerator +import com.atlassian.onetime.model.TOTPSecret +import com.atlassian.onetime.service.CPSAsciiRangeSecretProvider +import com.atlassian.onetime.service.DefaultTOTPService +import com.atlassian.onetime.service.TOTPConfiguration +import java.time.Clock +import java.util.GregorianCalendar + +class TokenService { + private val startTime by lazy { GregorianCalendar(2019, 0, 1).time.time.toInt() } + + private val secretGenerator by lazy { CPSAsciiRangeSecretProvider() } + + private val generator by lazy { TOTPGenerator(Clock.systemUTC(), startTime, 30, TEN, SHA1) } + + private val configuration by lazy { TOTPConfiguration(1, 1) } + + private val service by lazy { DefaultTOTPService(generator, configuration) } + + suspend fun generateSecret() = secretGenerator.generateSecret() + + fun generateToken(secret: TOTPSecret) = generator.generate(secret) + + fun verifyToken( + token: TOTP, + secret: TOTPSecret, + ) = service.verify(token, secret) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DeviceService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DeviceService.kt new file mode 100644 index 0000000..e0834cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DeviceService.kt @@ -0,0 +1,29 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.Device +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +interface DeviceService { + suspend fun all(): List + + suspend fun findById(id: Uuid): Device + + suspend fun insert(payload: Device.Payload): Device + + suspend fun upsert(device: Device): Device + + suspend fun updateById( + id: Uuid, + payload: Device.Payload, + ): Device + + suspend fun partialUpdateById( + id: Uuid, + payload: Device.Update, + ): Device + + suspend fun deleteById(id: Uuid): Device +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DriverService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DriverService.kt new file mode 100644 index 0000000..e5fc856 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DriverService.kt @@ -0,0 +1,13 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +interface DriverService { + suspend fun all(): List + + suspend fun findById(id: Uuid): Driver +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/LegacySettingsService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/LegacySettingsService.kt new file mode 100644 index 0000000..e53e9a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/LegacySettingsService.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.LegacySettings + +interface LegacySettingsService { + suspend fun read(): LegacySettings? +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/PowerService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/PowerService.kt new file mode 100644 index 0000000..61407cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/PowerService.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.service + +interface PowerService { + suspend fun powerOn() + + suspend fun powerOff() +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SerialPortService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SerialPortService.kt new file mode 100644 index 0000000..565e178 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SerialPortService.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo + +interface SerialPortService { + suspend fun all(): List +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SourceService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SourceService.kt new file mode 100644 index 0000000..5e8f39b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SourceService.kt @@ -0,0 +1,31 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.Source +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +interface SourceService { + suspend fun all(): List + + suspend fun findById(id: Uuid): Source + + suspend fun insert(payload: Source.Payload): Source + + suspend fun upsert(source: Source): Source + + suspend fun updateById( + id: Uuid, + payload: Source.Payload, + ): Source + + suspend fun partialUpdateById( + id: Uuid, + payload: Source.Update, + ): Source + + suspend fun deleteById(id: Uuid): Source + + suspend fun activateById(id: Uuid) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/TieService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/TieService.kt new file mode 100644 index 0000000..e97a917 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/TieService.kt @@ -0,0 +1,38 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +interface TieService { + suspend fun all(): List + + suspend fun findBySourceId(sourceId: Uuid): List + + suspend fun findByDeviceId(deviceId: Uuid): List + + suspend fun findBySourceAndDeviceId( + sourceId: Uuid, + deviceId: Uuid, + ): List + + suspend fun findById(id: Uuid): Tie + + suspend fun insert(payload: Tie.Payload): Tie + + suspend fun upsert(tie: Tie): Tie + + suspend fun updateById( + id: Uuid, + payload: Tie.Payload, + ): Tie + + suspend fun partialUpdateById( + id: Uuid, + payload: Tie.Update, + ): Tie + + suspend fun deleteById(id: Uuid): Tie +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/UserImageService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/UserImageService.kt new file mode 100644 index 0000000..a176d75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/UserImageService.kt @@ -0,0 +1,19 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service + +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +interface UserImageService { + suspend fun all(): List + + suspend fun findById(id: Uuid): UserImage + + suspend fun tryFindById(id: Uuid): UserImage? + + suspend fun upsert(image: UserImage.New): UserImage + + suspend fun deleteById(id: Uuid): UserImage +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/core/ConnectionService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/core/ConnectionService.kt new file mode 100644 index 0000000..9671bbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/core/ConnectionService.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.service.core + +import io.ktor.client.HttpClient + +interface ConnectionService { + suspend fun watchRequest(block: suspend HttpClient.() -> R): R +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Capabilities.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Capabilities.kt new file mode 100644 index 0000000..35432e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Capabilities.kt @@ -0,0 +1,12 @@ +package org.sleepingcats.bridgecmdr.common.service.model + +object Capabilities { + /** No special capabilities */ + const val NONE = 0 + + /** Supports multiple video outputs */ + const val MULTIPLE_OUTPUTS = 1 shl 0 + + /** Audio can be routed independently of video */ + const val AUDIO_INDEPENDENT = 1 shl 1 +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Device.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Device.kt new file mode 100644 index 0000000..11e3e60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Device.kt @@ -0,0 +1,37 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class Device( + val id: Uuid, + val driverId: Uuid, + val title: String, + val path: String, +) { + companion object { + val Blank = Device(Uuid.NIL, Uuid.NIL, "", "") + } + + @Serializable + data class Payload( + val driverId: Uuid, + val title: String, + val path: String, + ) { + constructor(device: Device) : this(device.driverId, device.title, device.path) + } + + @Serializable + data class Update( + val driverId: Uuid? = null, + val title: String? = null, + val path: String? = null, + ) { + constructor(device: Device) : this(device.driverId, device.title, device.path) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Driver.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Driver.kt new file mode 100644 index 0000000..11528e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Driver.kt @@ -0,0 +1,19 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +open class Driver( + val id: Uuid, + val kind: DriverKind, + val title: String, + val company: String, + val provider: String, + val enabled: Boolean = true, + val experimental: Boolean = false, + val capabilities: Int = 0, +) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/DriverKind.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/DriverKind.kt new file mode 100644 index 0000000..9eb468e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/DriverKind.kt @@ -0,0 +1,6 @@ +package org.sleepingcats.bridgecmdr.common.service.model + +enum class DriverKind { + Monitor, + Switch, +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/LegacySettings.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/LegacySettings.kt new file mode 100644 index 0000000..aa9ce4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/LegacySettings.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class LegacySettings( + val iconSize: Int?, + val colorScheme: LegacyColorScheme?, + val powerOffWhen: LegacyPowerOffTaps?, + val powerOnDevices: Boolean?, + val buttonOrder: List?, +) { + @Serializable + enum class LegacyColorScheme( + val newValue: AppTheme, + ) { + @SerialName("light") + Light(AppTheme.Light), + + @SerialName("dark") + Dark(AppTheme.Dark), + + @SerialName("no-preference") + System(AppTheme.System), + } + + @Serializable + enum class LegacyPowerOffTaps( + val newValue: PowerOffTaps, + ) { + @SerialName("single") + Single(PowerOffTaps.Single), + + @SerialName("double") + Double(PowerOffTaps.Double), + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PathType.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PathType.kt new file mode 100644 index 0000000..215e4ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PathType.kt @@ -0,0 +1,18 @@ +package org.sleepingcats.bridgecmdr.common.service.model + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.device_PathType_Port +import bridgecmdr.composeapp.generated.resources.device_PathType_Remote +import org.jetbrains.compose.resources.StringResource + +enum class PathType( + val label: StringResource, + val prefix: String, +) { + Port(Res.string.device_PathType_Port, "port:"), + Remote(Res.string.device_PathType_Remote, "ip:"), + ; + + val startIndex: Int + get() = prefix.length +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PortInfo.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PortInfo.kt new file mode 100644 index 0000000..5afe763 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PortInfo.kt @@ -0,0 +1,16 @@ +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PortInfo( + val path: String, + val serialNumber: String, + val manufacturer: String, + val title: String, + val name: String, + val description: String, + val location: String, + val productId: String, + val vendorId: String, +) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Source.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Source.kt new file mode 100644 index 0000000..7ca7c74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Source.kt @@ -0,0 +1,34 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class Source( + val id: Uuid, + val title: String, + val image: Uuid?, +) { + companion object { + val Blank = Source(Uuid.NIL, "", null) + } + + @Serializable + data class Payload( + val title: String, + val image: Uuid?, + ) { + constructor(source: Source) : this(source.title, source.image) + } + + @Serializable + data class Update( + val title: String? = null, + val image: Uuid? = null, + ) { + constructor(source: Source) : this(source.title, source.image) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Tie.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Tie.kt new file mode 100644 index 0000000..445619b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Tie.kt @@ -0,0 +1,55 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class Tie( + val id: Uuid, + val sourceId: Uuid, + val deviceId: Uuid, + val inputChannel: Int, + val outputVideoChannel: Int? = null, + val outputAudioChannel: Int? = null, +) { + companion object { + val Blank = Tie(Uuid.NIL, Uuid.NIL, Uuid.NIL, 1, null, null) + } + + @Serializable + data class Payload( + val sourceId: Uuid, + val deviceId: Uuid, + val inputChannel: Int, + val outputVideoChannel: Int? = null, + val outputAudioChannel: Int? = null, + ) { + constructor(tie: Tie) : this( + sourceId = tie.sourceId, + deviceId = tie.deviceId, + inputChannel = tie.inputChannel, + outputVideoChannel = tie.outputVideoChannel, + outputAudioChannel = tie.outputAudioChannel, + ) + } + + @Serializable + data class Update( + val sourceId: Uuid? = null, + val deviceId: Uuid? = null, + val inputChannel: Int? = null, + val outputVideoChannel: Int? = null, + val outputAudioChannel: Int? = null, + ) { + constructor(tie: Tie) : this( + sourceId = tie.sourceId, + deviceId = tie.deviceId, + inputChannel = tie.inputChannel, + outputVideoChannel = tie.outputVideoChannel, + outputAudioChannel = tie.outputAudioChannel, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/UserImage.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/UserImage.kt new file mode 100644 index 0000000..dec048a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/UserImage.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +class UserImage( + val id: Uuid, + val data: ByteArray, + val type: String, + val hash: ByteArray, +) { + @Serializable + class New( + val data: ByteArray, + val type: String, + ) + + @Serializable + class Info( + val id: Uuid, + val type: String, + val hash: ByteArray, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDeviceService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDeviceService.kt new file mode 100644 index 0000000..d629983 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDeviceService.kt @@ -0,0 +1,58 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.Device +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class RemoteDeviceService( + private val connection: ConnectionService, +) : DeviceService { + override suspend fun all(): List = + connection.watchRequest { + get("/devices").body() + } + + override suspend fun findById(id: Uuid): Device = + connection.watchRequest { + get("/devices/$id").body() + } + + override suspend fun insert(payload: Device.Payload): Device = + connection.watchRequest { + post("/devices") { setBody(payload) }.body() + } + + override suspend fun upsert(device: Device): Device = TODO("Upsert is not supported remotely") + + override suspend fun updateById( + id: Uuid, + payload: Device.Payload, + ): Device = + connection.watchRequest { + put("/devices/$id") { setBody(payload) }.body() + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Device.Update, + ): Device = + connection.watchRequest { + patch("/devices/$id") { setBody(payload) }.body() + } + + override suspend fun deleteById(id: Uuid): Device = + connection.watchRequest { + delete("/devices/$id").body() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDriverService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDriverService.kt new file mode 100644 index 0000000..65d7da7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDriverService.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.get +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class RemoteDriverService( + private val connection: ConnectionService, +) : DriverService { + override suspend fun all(): List = + connection.watchRequest { + get("/drivers").body() + } + + override suspend fun findById(id: Uuid): Driver = + connection.watchRequest { + get("/drivers/$id").body() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteLegacySettingsService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteLegacySettingsService.kt new file mode 100644 index 0000000..81b9424 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteLegacySettingsService.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.service.remote + +import org.sleepingcats.bridgecmdr.common.service.LegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.model.LegacySettings + +class RemoteLegacySettingsService : LegacySettingsService { + // Not implemented for remote connections. + override suspend fun read(): LegacySettings? = null +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemotePowerService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemotePowerService.kt new file mode 100644 index 0000000..cafcf71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemotePowerService.kt @@ -0,0 +1,21 @@ +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.request.post +import org.sleepingcats.bridgecmdr.common.service.PowerService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService + +class RemotePowerService( + private val connection: ConnectionService, +) : PowerService { + override suspend fun powerOn() { + connection.watchRequest { + post("/power/on") + } + } + + override suspend fun powerOff() { + connection.watchRequest { + post("/power/off") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSerialPortService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSerialPortService.kt new file mode 100644 index 0000000..68e92be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSerialPortService.kt @@ -0,0 +1,16 @@ +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.get +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo + +class RemoteSerialPortService( + private val connection: ConnectionService, +) : SerialPortService { + override suspend fun all(): List = + connection.watchRequest { + get("/ports").body() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSourceService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSourceService.kt new file mode 100644 index 0000000..e8bf9f4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSourceService.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.Source +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class RemoteSourceService( + private val connection: ConnectionService, +) : SourceService { + override suspend fun all(): List = + connection.watchRequest { + get("/sources").body() + } + + override suspend fun findById(id: Uuid): Source = + connection.watchRequest { + get("/sources/$id").body() + } + + override suspend fun insert(payload: Source.Payload): Source = + connection.watchRequest { + post("/sources") { setBody(payload) }.body() + } + + override suspend fun upsert(source: Source): Source = TODO("Upsert is not supported remotely") + + override suspend fun updateById( + id: Uuid, + payload: Source.Payload, + ): Source = + connection.watchRequest { + put("/sources/$id") { setBody(payload) }.body() + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Source.Update, + ): Source = + connection.watchRequest { + patch("/sources/$id") { setBody(payload) }.body() + } + + override suspend fun deleteById(id: Uuid): Source = + connection.watchRequest { + delete("/sources/$id").body() + } + + override suspend fun activateById(id: Uuid) = + connection.watchRequest { + post("/sources/$id/activate").let { } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteTieService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteTieService.kt new file mode 100644 index 0000000..87a02bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteTieService.kt @@ -0,0 +1,80 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class RemoteTieService( + private val connection: ConnectionService, +) : TieService { + override suspend fun all(): List = + connection.watchRequest { + get("/ties").body() + } + + override suspend fun findBySourceId(sourceId: Uuid): List = + connection.watchRequest { + get("/ties") { parameter("sourceId", sourceId) }.body() + } + + override suspend fun findByDeviceId(deviceId: Uuid): List = + connection.watchRequest { + get("/ties") { parameter("deviceId", deviceId) }.body() + } + + override suspend fun findBySourceAndDeviceId( + sourceId: Uuid, + deviceId: Uuid, + ): List = + connection.watchRequest { + get("/ties") { + parameter("sourceId", sourceId) + parameter("deviceId", deviceId) + }.body() + } + + override suspend fun findById(id: Uuid): Tie = + connection.watchRequest { + get("/ties/$id").body() + } + + override suspend fun insert(payload: Tie.Payload): Tie = + connection.watchRequest { + post("/ties") { setBody(payload) }.body() + } + + override suspend fun upsert(tie: Tie): Tie = TODO("Upsert is not supported remotely") + + override suspend fun updateById( + id: Uuid, + payload: Tie.Payload, + ): Tie = + connection.watchRequest { + put("/ties/$id") { setBody(payload) }.body() + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Tie.Update, + ): Tie = + connection.watchRequest { + patch("/ties/$id") { setBody(payload) }.body() + } + + override suspend fun deleteById(id: Uuid): Tie = + connection.watchRequest { + delete("/ties/$id").body() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteUserImageService.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteUserImageService.kt new file mode 100644 index 0000000..8c8ab67 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteUserImageService.kt @@ -0,0 +1,71 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.remote + +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.HttpHeaders +import io.ktor.http.headers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.sleepingcats.bridgecmdr.common.extension.hexDashToUuid +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.core.ConnectionService +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class RemoteUserImageService( + private val connection: ConnectionService, +) : UserImageService { + override suspend fun all(): List = + withContext(Dispatchers.IO) { + connection.watchRequest { + val ids: List = get("/images").body() + ids.chunked(4).flatMap { ids -> + ids.map { id -> async { findById(id) } }.awaitAll() + } + } + } + + override suspend fun findById(id: Uuid): UserImage { + val response = connection.watchRequest { get("/images/$id") } + val hash = checkNotNull(response.headers["x-hash"]?.hexToByteArray()) { "Missing x-hash header" } + val type = checkNotNull(response.headers[HttpHeaders.ContentType]) { "Missing Content-Type header" } + val data: ByteArray = response.body() + + return UserImage(id, data, type, hash) + } + + override suspend fun tryFindById(id: Uuid): UserImage? = runCatching { findById(id) }.getOrNull() + + override suspend fun upsert(image: UserImage.New): UserImage { + val response = + connection.watchRequest { + post("/images") { + setBody(image.data) + headers { append(HttpHeaders.ContentType, image.type) } + } + } + + val id = checkNotNull(response.headers["x-id"]?.hexDashToUuid()) { "Missing x-id header" } + val hash = checkNotNull(response.headers["x-hash"]?.hexToByteArray()) { "Missing x-hash header" } + + return UserImage(id, image.data, image.type, hash) + } + + override suspend fun deleteById(id: Uuid): UserImage { + val response = connection.watchRequest { delete("/images/$id") } + + val hash = checkNotNull(response.headers["x-hash"]?.hexToByteArray()) { "Missing x-hash header" } + val type = checkNotNull(response.headers[HttpHeaders.ContentType]) { "Missing Content-Type header" } + val data: ByteArray = response.body() + + return UserImage(id, data, type, hash) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/AppTheme.kt new file mode 100644 index 0000000..8523a8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/AppTheme.kt @@ -0,0 +1,19 @@ +package org.sleepingcats.bridgecmdr.common.setting + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_Dark +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_Dark_option +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_Light +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_Light_option +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_System +import bridgecmdr.composeapp.generated.resources.setting_AppTheme_System_option +import org.jetbrains.compose.resources.StringResource + +enum class AppTheme( + val option: StringResource, + val description: StringResource, +) { + System(Res.string.setting_AppTheme_System_option, Res.string.setting_AppTheme_System), + Light(Res.string.setting_AppTheme_Light_option, Res.string.setting_AppTheme_Light), + Dark(Res.string.setting_AppTheme_Dark_option, Res.string.setting_AppTheme_Dark), +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/IconSize.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/IconSize.kt new file mode 100644 index 0000000..768caa8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/IconSize.kt @@ -0,0 +1,13 @@ +package org.sleepingcats.bridgecmdr.common.setting + +enum class IconSize( + val size: Int, + // val description: String = "$size ⨉ $size", +) { + ExtraSmall(48), + Small(64), + Medium(96), + Normal(128), + Large(192), + ExtraLarge(256), +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/PowerOffTaps.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/PowerOffTaps.kt new file mode 100644 index 0000000..2767fc1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/PowerOffTaps.kt @@ -0,0 +1,16 @@ +package org.sleepingcats.bridgecmdr.common.setting + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps_Double +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps_Double_option +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps_Single +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps_Single_option +import org.jetbrains.compose.resources.StringResource + +enum class PowerOffTaps( + val option: StringResource, + val description: StringResource, +) { + Single(Res.string.setting_PowerOffTaps_Single_option, Res.string.setting_PowerOffTaps_Single), + Double(Res.string.setting_PowerOffTaps_Double_option, Res.string.setting_PowerOffTaps_Double), +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.kt new file mode 100644 index 0000000..8ef7007 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.kt @@ -0,0 +1,27 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import kotlinx.serialization.Serializable +import org.sleepingcats.bridgecmdr.ui.view.DashboardRoute +import kotlin.uuid.ExperimentalUuidApi + +expect fun getStartupRoute(): Route + +expect fun NavGraphBuilder.platformRoutes(navController: NavController) + +fun NavGraphBuilder.routes(navController: NavController) { + composable { DashboardRoute(navController) } + platformRoutes(navController) +} + +interface Route + +@Serializable +object Dashboard : Route + +@Serializable +object Settings : Route diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/SourceCache.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/SourceCache.kt new file mode 100644 index 0000000..4a9954a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/SourceCache.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache + +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class SourceCache : DataCache() { + override val key = Source::id +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/UserImageCache.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/UserImageCache.kt new file mode 100644 index 0000000..61acb7d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/UserImageCache.kt @@ -0,0 +1,52 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache + +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.key.Keyer +import coil3.request.Options +import okio.Buffer +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class UserImageCache( + private val service: UserImageService, + private val options: Options, + private val id: Uuid, +) : Fetcher { + class KeyOf : Keyer { + override fun key( + data: Uuid, + options: Options, + ): String = data.toString() + } + + class Factory( + private val service: UserImageService, + ) : Fetcher.Factory { + override fun create( + data: Uuid, + options: Options, + imageLoader: ImageLoader, + ): Fetcher = UserImageCache(service, options, data) + } + + override suspend fun fetch(): FetchResult? { + val userImage = service.tryFindById(id) ?: return null + return SourceFetchResult( + source = + ImageSource( + source = Buffer().apply { write(userImage.data) }, + fileSystem = options.fileSystem, + ), + mimeType = userImage.type, + dataSource = DataSource.NETWORK, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/core/DataCache.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/core/DataCache.kt new file mode 100644 index 0000000..f070d93 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/core/DataCache.kt @@ -0,0 +1,45 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache.core + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import org.sleepingcats.core.extensions.removingFirst +import org.sleepingcats.core.extensions.replacingFirst +import org.sleepingcats.core.extensions.upsertingFirst +import kotlin.reflect.KProperty1 + +abstract class DataCache> { + protected abstract val key: KProperty1 + + private val cache = MutableStateFlow(emptyList()) + + val items: StateFlow> get() = cache.asStateFlow() + + private infix fun T.sameKeyAs(other: T): Boolean = key(this) == key(other) + + private infix fun T.hasKey(key: K): Boolean = key(this) == key + + fun refresh(newItems: List): List = newItems.apply { cache.update { newItems } } + + suspend fun latest(): List = items.take(1).last() + + fun find(key: K): Flow = cache.map { items -> items.find { item -> item hasKey key } } + + suspend fun findLatest(key: K): T? = find(key).take(1).last() + + fun add(item: T): T = item.apply { cache.update { currentItems -> currentItems + item } } + + fun upsert(item: T): T = item.apply { cache.update { items -> items.upsertingFirst(item) { it sameKeyAs item } } } + + fun update(item: T): T = item.apply { cache.update { items -> items.replacingFirst(item) { it sameKeyAs item } } } + + fun remove(item: T): T = item.apply { cache.update { items -> items.removingFirst { it sameKeyAs item } } } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/BackButton.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/BackButton.kt new file mode 100644 index 0000000..2b55341 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/BackButton.kt @@ -0,0 +1,21 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_back +import bridgecmdr.composeapp.generated.resources.arrow_left +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun BackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton(onClick, modifier) { + Icon(painterResource(Res.drawable.arrow_left), contentDescription = stringResource(Res.string.action_back)) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/HideOnScroll.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/HideOnScroll.kt new file mode 100644 index 0000000..a98b834 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/HideOnScroll.kt @@ -0,0 +1,28 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource + +@Composable +fun hideOnScroll(visibilityState: MutableState): NestedScrollConnection { + // val (showActions, setShowActions) = rememberSaveable { mutableStateOf(true) } + return remember { + object : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset { + if (available.y < -1) { + visibilityState.value = false + } else if (available.y > 1) { + visibilityState.value = true + } + return Offset.Zero + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/ImagePreview.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/ImagePreview.kt new file mode 100644 index 0000000..ac941da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/ImagePreview.kt @@ -0,0 +1,120 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.image_chooseImage +import bridgecmdr.composeapp.generated.resources.image_plus +import bridgecmdr.composeapp.generated.resources.nintendo_game_boy +import coil3.ImageLoader +import coil3.compose.AsyncImage +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Composable +fun ImagePreview( + model: Uuid?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: DpSize = DpSize(128.dp, 128.dp), + imageLoader: ImageLoader = koinInject(), + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), + hoverContainerColor: Color = MaterialTheme.colorScheme.surface, + hoverContentColor: Color = contentColorFor(hoverContainerColor), + hoverContent: (@Composable BoxScope.() -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val isHovering by interactionSource.collectIsHoveredAsState() + val blurring by animateDpAsState(if (isHovering) 4.dp else 0.dp) + + Box(modifier = modifier.size(size).clip(MaterialTheme.shapes.medium).background(containerColor)) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + if (model != null) { + CompositionLocalProvider(LocalContentColor provides Color.Unspecified) { + AsyncImage( + model = model, + imageLoader = imageLoader, + contentDescription = null, + filterQuality = FilterQuality.High, + modifier = + Modifier + .fillMaxSize() + .clickable(onClick = onClick) + .hoverable(interactionSource) + .blur(blurring), + ) + } + } else { + Icon( + painterResource(Res.drawable.nintendo_game_boy), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .clickable(onClick = onClick) + .hoverable(interactionSource) + .blur(blurring), + ) + } + } + + AnimatedVisibility(isHovering, enter = fadeIn(), exit = fadeOut()) { + val iconSize = size.width.coerceAtMost(size.height) / 2 + CompositionLocalProvider(LocalContentColor provides hoverContentColor) { + Box( + modifier = + Modifier + .animateEnterExit(enter = fadeIn(), exit = fadeOut()) + .fillMaxSize() + .background(hoverContainerColor.copy(alpha = 0.8f)), + ) { + hoverContent?.invoke(this) ?: Column(modifier = Modifier.align(Center)) { + Icon( + painterResource(Res.drawable.image_plus), + modifier = Modifier.size(iconSize).padding(bottom = 4.dp).align(CenterHorizontally), + contentDescription = null, + ) + Text(stringResource(Res.string.image_chooseImage), style = MaterialTheme.typography.labelMedium) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/InputModal.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/InputModal.kt new file mode 100644 index 0000000..5baf5ea --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/InputModal.kt @@ -0,0 +1,68 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.response_cancel +import bridgecmdr.composeapp.generated.resources.response_confirm +import org.jetbrains.compose.resources.stringResource + +internal object InputModalDefault { + @Composable + fun defaultConfirmContent(onClick: () -> Unit) { + TextButton(onClick = onClick) { Text(stringResource(Res.string.response_confirm)) } + } + + @Composable + fun defaultCancelContent(onClick: () -> Unit) { + TextButton(onClick = onClick) { Text(stringResource(Res.string.response_cancel)) } + } +} + +@Composable +fun InputModal( + confirmContent: @Composable (RowScope.(() -> Unit) -> Unit) = { InputModalDefault.defaultConfirmContent(it) }, + cancelContent: @Composable (RowScope.(() -> Unit) -> Unit) = { InputModalDefault.defaultCancelContent(it) }, + headlineContent: @Composable (RowScope.() -> Unit)? = null, + label: @Composable (() -> Unit)? = null, + onDismiss: (String?) -> Unit, +): (String?) -> Unit { + val (current, setCurrent) = remember { mutableStateOf(null) } + val showModal = + PopupModal( + onDismiss = onDismiss, + headlineContent = headlineContent, + actions = { dismiss -> + cancelContent { dismiss(null) } + confirmContent { dismiss(current) } + }, + ) { + // Bugfix for layout issue with OutlinedTextField and minLines/maxLines/singleLine + val minHeight = minHeighBugFixForTextField() + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + TextField( + label = label, + value = current ?: "", + onValueChange = { setCurrent(it) }, + modifier = Modifier.fillMaxWidth().height(minHeight), + singleLine = true, + ) + } + } + + return { newValue -> + setCurrent(newValue) + showModal() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/LoadingOverlay.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/LoadingOverlay.kt new file mode 100644 index 0000000..db036f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/LoadingOverlay.kt @@ -0,0 +1,57 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.zIndex + +@Composable +fun AnimatedVisibilityScope.defaultLoadingModalContent() { + Box( + contentAlignment = Center, + modifier = + Modifier + .animateEnterExit(enter = fadeIn(), exit = fadeOut()) + .background(color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) + .fillMaxSize() + .zIndex(100f), + ) { + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + } +} + +@Composable +fun LoadingOverlay( + isLoading: Boolean, + content: @Composable AnimatedVisibilityScope.() -> Unit = AnimatedVisibilityScope::defaultLoadingModalContent, +) { + val properties = + remember { + PopupProperties( + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + clippingEnabled = false, + ) + } + + AnimatedVisibility(isLoading) { + Popup(onDismissRequest = { }, properties = properties) { + content() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MediaImage.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MediaImage.kt new file mode 100644 index 0000000..1740c03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MediaImage.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.nintendo_game_boy +import coil3.ImageLoader +import coil3.compose.AsyncImage +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Composable +fun MediaImage( + data: Uuid?, + contentDescription: String?, + modifier: Modifier = Modifier, + size: DpSize = DpSize(56.dp, 56.dp), + imageLoader: ImageLoader = koinInject(), + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), +) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + if (data != null) { + AsyncImage( + model = data, + imageLoader = imageLoader, + contentDescription = contentDescription, + filterQuality = FilterQuality.High, + modifier = + modifier + .size(size) + .clip(MaterialTheme.shapes.medium) + .background(containerColor), + ) + } else { + Icon( + painterResource(Res.drawable.nintendo_game_boy), + contentDescription = contentDescription, + modifier = + modifier + .size(size) + .clip(MaterialTheme.shapes.medium) + .background(containerColor), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MessageModals.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MessageModals.kt new file mode 100644 index 0000000..024a446 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MessageModals.kt @@ -0,0 +1,227 @@ +@file:OptIn(ExperimentalContracts::class, ExperimentalExtendedContracts::class) + +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.window.DialogProperties +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.response_no +import bridgecmdr.composeapp.generated.resources.response_understood +import bridgecmdr.composeapp.generated.resources.response_yes +import org.jetbrains.compose.resources.stringResource +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.ExperimentalExtendedContracts + +@Composable +fun defaultCloseButton(close: () -> Unit) { + TextButton(onClick = close) { Text(stringResource(Res.string.response_understood)) } +} + +@Composable +fun AlertModal( + visible: Boolean, + onClose: () -> Unit, + modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, + title: @Composable () -> Unit, + text: @Composable (() -> Unit)? = null, + closeButton: @Composable (() -> Unit) -> Unit = ::defaultCloseButton, + shape: Shape = AlertDialogDefaults.shape, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + properties: DialogProperties = DialogProperties(), +) { + AnimatedVisibility(visible) { + AlertDialog( + onDismissRequest = onClose, + confirmButton = { closeButton(onClose) }, + modifier = modifier.animateEnterExit(), + icon = icon, + title = title, + text = text, + shape = shape, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + properties = properties, + ) + } +} + +@Composable +fun defaultConfirmButton(confirm: () -> Unit) { + TextButton(onClick = confirm) { Text(stringResource(Res.string.response_yes)) } +} + +@Composable +fun defaultCancelButton(cancel: () -> Unit) { + TextButton(onClick = cancel) { Text(stringResource(Res.string.response_no)) } +} + +@Composable +fun ConfirmModal( + visible: Boolean, + onResponse: (Boolean?) -> Unit, + modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, + title: @Composable () -> Unit, + text: @Composable (() -> Unit)? = null, + confirmButton: @Composable (() -> Unit) -> Unit = ::defaultConfirmButton, + cancelButton: @Composable (() -> Unit) -> Unit = ::defaultCancelButton, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + properties: DialogProperties = DialogProperties(), +) { + AnimatedVisibility(visible) { + AlertDialog( + onDismissRequest = { onResponse(null) }, + confirmButton = { confirmButton { onResponse(true) } }, + dismissButton = { cancelButton { onResponse(false) } }, + modifier = modifier.animateEnterExit(), + icon = icon, + title = title, + text = text, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + properties = properties, + ) + } +} + +@Composable +fun ConfirmModal( + modifier: Modifier = Modifier, + icon: @Composable ((T) -> Unit)? = null, + title: @Composable (T) -> Unit, + text: @Composable ((T) -> Unit)? = null, + confirmButton: @Composable (() -> Unit) -> Unit = ::defaultConfirmButton, + cancelButton: @Composable (() -> Unit) -> Unit = ::defaultCancelButton, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + properties: DialogProperties = DialogProperties(), + onResponded: (T, Boolean?) -> Unit, +): (T) -> Unit { + val (visible, setVisible) = remember { mutableStateOf(false) } + var input by remember { mutableStateOf(null) } + + val dismiss: (Boolean?) -> Unit = { value -> + setVisible(false) + onResponded(checkNotNull(input), value) + } + + ConfirmModal( + visible = visible, + onResponse = dismiss, + modifier = modifier, + icon = icon?.let { icon -> { icon(checkNotNull(input)) } }, + title = { title(checkNotNull(input)) }, + text = text?.let { text -> { text(checkNotNull(input)) } }, + confirmButton = confirmButton, + cancelButton = cancelButton, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + properties = properties, + ) + + return { + input = it + setVisible(true) + } +} + +@Composable +fun ConfirmModal( + modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, + title: @Composable () -> Unit, + text: @Composable (() -> Unit)? = null, + confirmButton: @Composable (() -> Unit) -> Unit = ::defaultConfirmButton, + cancelButton: @Composable (() -> Unit) -> Unit = ::defaultCancelButton, + containerColor: Color = AlertDialogDefaults.containerColor, + iconContentColor: Color = AlertDialogDefaults.iconContentColor, + titleContentColor: Color = AlertDialogDefaults.titleContentColor, + textContentColor: Color = AlertDialogDefaults.textContentColor, + tonalElevation: Dp = AlertDialogDefaults.TonalElevation, + properties: DialogProperties = DialogProperties(), + onResponded: (Boolean?) -> Unit, +): () -> Unit { + val (visible, setVisible) = remember { mutableStateOf(false) } + + val dismiss: (Boolean?) -> Unit = + remember { + { value -> + setVisible(false) + onResponded(value) + } + } + + ConfirmModal( + visible = visible, + onResponse = dismiss, + modifier = modifier, + icon = icon, + title = title, + text = text, + confirmButton = confirmButton, + cancelButton = cancelButton, + containerColor = containerColor, + iconContentColor = iconContentColor, + titleContentColor = titleContentColor, + textContentColor = textContentColor, + tonalElevation = tonalElevation, + properties = properties, + ) + + return { setVisible(true) } +} + +/* + +fun confirmAction(...) +fun confirmAction(...) +fun confirmAction(...) +fun confirmAction(...) + +val onDelete = confirmAction { device, driver -> + title = { Text("Delete device ${device.title}?") }, + text = { Text("Are you sure you want to delete this device? This action cannot be undone.") }, + confirmButton = { confirm -> TextButton(onClick = confirm) { Text("Delete") } }, +} + +onDelete(device, driver) { confirmed -> + if (confirmed == true) { + viewModel.deleteDevice(device) + } +} + +*/ diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedNumericField.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedNumericField.kt new file mode 100644 index 0000000..8b22016 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedNumericField.kt @@ -0,0 +1,363 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldLabelScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_decrement +import bridgecmdr.composeapp.generated.resources.action_increment +import bridgecmdr.composeapp.generated.resources.minus +import bridgecmdr.composeapp.generated.resources.plus +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +internal fun CharSequence.isDigitsOnly(): Boolean { + for (c in this) if (!c.isDigit()) return false + return true +} + +@Composable +internal fun defaultDecrementIcon( + colors: TextFieldColors, + focusRequested: FocusRequester, + isError: Boolean, + focused: Boolean, + enabled: Boolean, + dec: () -> Unit, +) { + Icon( + painterResource(Res.drawable.minus), + contentDescription = if (enabled) stringResource(Res.string.action_decrement) else null, + modifier = + Modifier + .clip(CircleShape) + .pointerHoverIcon(if (enabled) PointerIcon.Hand else PointerIcon.Default) + .clickable(enabled = enabled) { + dec() + focusRequested.requestFocus() + }, + tint = + when { + !enabled -> colors.disabledLeadingIconColor + isError -> colors.errorLeadingIconColor + focused -> colors.focusedLeadingIconColor + else -> colors.unfocusedLeadingIconColor + }, + ) +} + +@Composable +internal fun defaultIncrementIcon( + colors: TextFieldColors, + focusRequested: FocusRequester, + isError: Boolean, + focused: Boolean, + enabled: Boolean, + inc: () -> Unit, +) { + Icon( + painterResource(Res.drawable.plus), + contentDescription = if (enabled) stringResource(Res.string.action_increment) else null, + modifier = + Modifier + .clip(CircleShape) + .pointerHoverIcon(if (enabled) PointerIcon.Hand else PointerIcon.Default) + .clickable(enabled = enabled) { + inc() + focusRequested.requestFocus() + }, + tint = + when { + !enabled -> colors.disabledTrailingIconColor + isError -> colors.errorTrailingIconColor + focused -> colors.focusedTrailingIconColor + else -> colors.unfocusedTrailingIconColor + }, + ) +} + +class NumericFieldInputTransform( + private val minValue: Int, + private val maxValue: Int, +) : InputTransformation { + override val keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + + override fun TextFieldBuffer.transformInput() { + if (!asCharSequence().isDigitsOnly()) { + revertAllChanges() + } + + // Prevent leading zeros + while (length > 1 && asCharSequence()[0] == '0') { + replace(0, 1, "") + } + + val currentValue = asCharSequence().toString().toIntOrNull() ?: return revertAllChanges() + if (currentValue < minValue) return revertAllChanges() + if (currentValue > maxValue) return revertAllChanges() + } +} + +internal fun TextFieldBuffer.setText(text: String) { + replace(0, length, text) +} + +@Composable +internal fun OutlinedNumericFieldCore( + // Text field specific + value: Int?, + onValueChanged: (Int?) -> Unit, + modifier: Modifier, + enabled: Boolean, + textStyle: TextStyle, + label: @Composable (TextFieldLabelScope.() -> Unit)?, + placeholder: @Composable (() -> Unit)?, + leadingIcon: @Composable (() -> Unit)?, + trailingIcon: @Composable (() -> Unit)?, + prefix: @Composable (() -> Unit)?, + suffix: @Composable (() -> Unit)?, + supportingText: @Composable ((Int?) -> Unit)?, + isError: Boolean, + colors: TextFieldColors, + shape: Shape, + interactionSource: MutableInteractionSource, + // Numeric field specific + minValue: Int, + maxValue: Int, + decrementIcon: @Composable (Boolean, () -> Unit) -> Unit, + incrementIcon: @Composable (Boolean, () -> Unit) -> Unit, +) { + check(minValue >= 0) { "minValue must be non-negative" } + check(minValue <= maxValue) { "minValue must be less than or equal to maxValue" } + + // Track the last value to detect external changes. + val lastValue by produceState(value, value) { this.value = value } + + val textFieldState = rememberTextFieldState(initialText = value?.toString() ?: "") + val inputTransformation = NumericFieldInputTransform(minValue, maxValue) + val currentIntValue = textFieldState.text.toString().toIntOrNull() + + SideEffect { + // If the current value and latest integer value are in sync, nothing needs to be done. + if (value == currentIntValue) return@SideEffect + + if (lastValue != value) { + // The external value has changed, update the text field. + textFieldState.edit { setText(value?.toString() ?: "") } + } else { + // The internal value has changed, notify the external listener. + onValueChanged(currentIntValue) + } + } + + fun updateIntValue(newValue: Int) { + onValueChanged(newValue) + val newText = newValue.toString() + if (textFieldState.text.toString() != newText) { + textFieldState.edit { setText(newText) } + } + } + + val decrementEnabled = enabled && (currentIntValue ?: minValue) > minValue + val incrementEnabled = enabled && (currentIntValue ?: minValue) < maxValue + + fun incrementIntValue() { + val newValue = (currentIntValue ?: minValue) + 1 + if (newValue <= maxValue) { + updateIntValue(newValue) + } + } + + fun decrementIntValue() { + val newValue = (currentIntValue ?: minValue) - 1 + if (newValue >= minValue) { + updateIntValue(newValue) + } + } + + fun onKeyPress(ev: KeyEvent): Boolean { + if (ev.type == KeyEventType.KeyDown) { + when (ev.key) { + Key.NumPadAdd, Key.Plus, Key.Equals, Key.DirectionUp -> { + if (incrementEnabled) incrementIntValue() + return true + } + + Key.NumPadSubtract, Key.Minus, Key.DirectionDown -> { + if (decrementEnabled) decrementIntValue() + return true + } + + else -> { + return false + } + } + } else { + return false + } + } + + OutlinedTextField( + state = textFieldState, + modifier = modifier.onPreviewKeyEvent(::onKeyPress), + enabled = enabled, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon ?: { decrementIcon(decrementEnabled) { decrementIntValue() } }, + trailingIcon = trailingIcon ?: { incrementIcon(incrementEnabled) { incrementIntValue() } }, + prefix = prefix, + suffix = suffix, + supportingText = supportingText?.let { cb -> { cb(currentIntValue) } }, + isError = isError, + colors = colors, + shape = shape, + inputTransformation = inputTransformation, + interactionSource = interactionSource, + ) +} + +@Composable +fun OutlinedNumericField( + // Text field specific + value: Int?, + onValueChanged: (Int?) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (TextFieldLabelScope.() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable ((Int?) -> Unit)? = null, + isError: Boolean = false, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors(), + shape: Shape = OutlinedTextFieldDefaults.shape, + // Numeric field specific + minValue: Int = 0, + maxValue: Int = Int.MAX_VALUE, + decrementIcon: (@Composable (Boolean, () -> Unit) -> Unit)? = null, + incrementIcon: (@Composable (Boolean, () -> Unit) -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val focusRequester = remember { FocusRequester() } + val focused by interactionSource.collectIsFocusedAsState() + OutlinedNumericFieldCore( + value = value, + onValueChanged = onValueChanged, + modifier = modifier.focusRequester(focusRequester), + enabled = enabled, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + colors = colors, + shape = shape, + interactionSource = interactionSource, + minValue = minValue, + maxValue = maxValue, + decrementIcon = + decrementIcon ?: { enabled, dec -> + defaultDecrementIcon(colors, focusRequester, isError, focused, enabled, dec) + }, + incrementIcon = + incrementIcon ?: { enabled, inc -> + defaultIncrementIcon(colors, focusRequester, isError, focused, enabled, inc) + }, + ) +} + +@Composable +fun OutlinedNumericField( + // Text field specific + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (TextFieldLabelScope.() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable ((Int?) -> Unit)? = null, + isError: Boolean = false, + colors: TextFieldColors = OutlinedTextFieldDefaults.colors(), + shape: Shape = OutlinedTextFieldDefaults.shape, + // Numeric field specific + minValue: Int = 0, + maxValue: Int = Int.MAX_VALUE, + decrementIcon: (@Composable (Boolean, () -> Unit) -> Unit)? = null, + incrementIcon: (@Composable (Boolean, () -> Unit) -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val focusRequester = remember { FocusRequester() } + val focused by interactionSource.collectIsFocusedAsState() + OutlinedNumericFieldCore( + value = state.value, + onValueChanged = { state.value = it }, + modifier = modifier.focusRequester(focusRequester), + enabled = enabled, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + colors = colors, + shape = shape, + interactionSource = interactionSource, + minValue = minValue, + maxValue = maxValue, + decrementIcon = + decrementIcon ?: { enabled, dec -> + defaultDecrementIcon(colors, focusRequester, isError, focused, enabled, dec) + }, + incrementIcon = + incrementIcon ?: { enabled, inc -> + defaultIncrementIcon(colors, focusRequester, isError, focused, enabled, inc) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedSelectField.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedSelectField.kt new file mode 100644 index 0000000..a17be43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedSelectField.kt @@ -0,0 +1,236 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.MenuItemColors +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_collapse +import bridgecmdr.composeapp.generated.resources.action_expand +import bridgecmdr.composeapp.generated.resources.menu_down +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun defaultExpandTrailingIcon( + expanded: Boolean, + modifier: Modifier = Modifier, +) { + // Similar to ExposedDropdownMenuDefaults.TrailingIcon(expanded), but + // with rotation animation. + val arrowRotation by animateFloatAsState(if (expanded) 180f else 0f) + Icon( + painterResource(Res.drawable.menu_down), + modifier = modifier.rotate(arrowRotation), + contentDescription = + if (expanded) { + stringResource(Res.string.action_collapse) + } else { + stringResource(Res.string.action_expand) + }, + ) +} + +@Composable +fun OutlinedSelectField( + // Text field specific + value: T?, + onValueChanged: (T?) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable ((T?) -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable ((T?) -> Unit)? = null, + isError: Boolean = false, + colors: TextFieldColors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + shape: Shape = OutlinedTextFieldDefaults.shape, + // Drop menu specific + valueText: @Composable (T) -> String = { it.toString() }, + options: List, + optionKey: (T) -> Any = { it as Any }, + optionContent: @Composable (T) -> Unit = { Text(valueText(it)) }, + expandIcon: @Composable ((Boolean) -> Unit) = ::defaultExpandTrailingIcon, + menuColors: MenuItemColors = MenuDefaults.itemColors(), + menuSelectedColors: MenuItemColors = + MenuDefaults.itemColors( + MaterialTheme.colorScheme.onPrimaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + ), + menuContainerColor: Color = MenuDefaults.containerColor, + menuSelectedContainerColor: Color = MaterialTheme.colorScheme.primaryContainer, +) { + val (expanded, setExpanded) = remember { mutableStateOf(false) } + + fun dismiss(value: T?) { + setExpanded(false) + onValueChanged(value) + } + + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + // Supporting text, but won't interfere with the dropdown menu anchoring. + val supportDecoration: @Composable (content: @Composable () -> Unit) -> Unit = + if (supportingText == null) { + { content -> content() } + } else { + { content -> + Column { + content() + ProvideTextStyle( + MaterialTheme.typography.bodySmall.copy( + color = + when { + !enabled -> colors.disabledSupportingTextColor + isError -> colors.errorSupportingTextColor + isFocused -> colors.focusedSupportingTextColor + else -> colors.unfocusedSupportingTextColor + }, + ), + ) { + Box(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp)) { + supportingText(value) + } + } + } + } + } + + supportDecoration { + val valueText = value?.let { valueText(it) } ?: "" + ExposedDropdownMenuBox(expanded, onExpandedChange = { setExpanded(it) }) { + OutlinedTextField( + value = valueText, + onValueChange = {}, + modifier = + modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .pointerHoverIcon(PointerIcon.Hand, true), + enabled = enabled, + textStyle = textStyle, + label = label?.let { cb -> { cb(value) } }, + placeholder = placeholder, + leadingIcon = leadingIcon, + prefix = prefix, + suffix = suffix, + colors = colors, + shape = shape, + readOnly = true, + isError = isError, + trailingIcon = trailingIcon ?: { expandIcon(expanded) }, + ) + ExposedDropdownMenu(expanded, onDismissRequest = { setExpanded(false) }) { + for (option in options) { + key(optionKey(option)) { + DropdownMenuItem( + colors = if (option == value) menuSelectedColors else menuColors, + modifier = Modifier.background(if (option == value) menuSelectedContainerColor else menuContainerColor), + text = { optionContent(option) }, + onClick = { dismiss(option) }, + ) + } + } + } + } + } +} + +@Composable +fun OutlinedSelectField( + // Text field specific + state: MutableState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable ((T?) -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable ((T?) -> Unit)? = null, + isError: Boolean = false, + colors: TextFieldColors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + shape: Shape = OutlinedTextFieldDefaults.shape, + // Drop menu specific + valueText: @Composable (T) -> String = { it.toString() }, + options: List, + optionKey: (T) -> Any = { it as Any }, + optionContent: @Composable (T) -> Unit = { Text(valueText(it)) }, + expandIcon: @Composable ((Boolean) -> Unit) = ::defaultExpandTrailingIcon, + menuColors: MenuItemColors = MenuDefaults.itemColors(), + menuSelectedColors: MenuItemColors = + MenuDefaults.itemColors( + MaterialTheme.colorScheme.onPrimaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + ), + menuContainerColor: Color = MenuDefaults.containerColor, + menuSelectedContainerColor: Color = MaterialTheme.colorScheme.primaryContainer, +) { + OutlinedSelectField( + value = state.value, + onValueChanged = { state.value = it }, + modifier = modifier, + enabled = enabled, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + isError = isError, + colors = colors, + shape = shape, + valueText = valueText, + options = options, + optionContent = optionContent, + expandIcon = expandIcon, + menuColors = menuColors, + menuSelectedColors = menuSelectedColors, + menuContainerColor = menuContainerColor, + menuSelectedContainerColor = menuSelectedContainerColor, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/PopupModal.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/PopupModal.kt new file mode 100644 index 0000000..1574dbf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/PopupModal.kt @@ -0,0 +1,80 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun PopupModal( + visible: Boolean, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(), + onDismiss: (T?) -> Unit, + headlineContent: @Composable (RowScope.() -> Unit)? = null, + actions: @Composable (RowScope.((T?) -> Unit) -> Unit)? = null, + content: @Composable ColumnScope.((T?) -> Unit) -> Unit, +) { + AnimatedVisibility(visible) { + Dialog(properties = properties, onDismissRequest = { onDismiss(null) }) { + Card(modifier = Modifier.animateEnterExit()) { + Column(modifier = modifier.padding(24.dp)) { + headlineContent?.let { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.headlineSmall) { + Row(modifier = Modifier.padding(bottom = 16.dp).align(CenterHorizontally)) { this.it() } + } + } + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyMedium) { + this.content(onDismiss) + } + actions?.let { Row(modifier = Modifier.align(Alignment.End).padding(top = 24.dp)) { this.it(onDismiss) } } + } + } + } + } +} + +@Composable +fun PopupModal( + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties(), + onDismiss: (T?) -> Unit, + headlineContent: @Composable (RowScope.() -> Unit)? = null, + actions: @Composable (RowScope.((T?) -> Unit) -> Unit)? = null, + content: @Composable ColumnScope.((T?) -> Unit) -> Unit, +): () -> Unit { + val (visible, setVisible) = remember { mutableStateOf(false) } + + val dismiss: (T?) -> Unit = { value -> + setVisible(false) + onDismiss(value) + } + + PopupModal( + visible = visible, + modifier = modifier, + properties = properties, + onDismiss = dismiss, + headlineContent = headlineContent, + actions = actions, + content = content, + ) + + return { setVisible(true) } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SelectModal.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SelectModal.kt new file mode 100644 index 0000000..17a838d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SelectModal.kt @@ -0,0 +1,84 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.response_cancel +import org.jetbrains.compose.resources.stringResource + +internal object SelectModalDefault { + @Composable + fun defaultCancelContent(onClick: () -> Unit) { + TextButton(onClick = onClick) { Text(stringResource(Res.string.response_cancel)) } + } +} + +@Composable +fun SelectModal( + itemContent: @Composable (T) -> String, + items: List, + cancelContent: @Composable (RowScope.(() -> Unit) -> Unit) = { SelectModalDefault.defaultCancelContent(it) }, + headlineContent: @Composable (RowScope.() -> Unit)? = null, + supportContent: @Composable (RowScope.() -> Unit)? = null, + onDismiss: (T?) -> Unit, +): (T?) -> Unit { + val (current, setCurrent) = remember { mutableStateOf(null) } + val showModal = + PopupModal( + onDismiss = onDismiss, + headlineContent = headlineContent, + actions = { dismiss -> cancelContent { dismiss(null) } }, + ) { dismiss -> + Column(modifier = Modifier.selectableGroup()) { + supportContent?.let { Row(modifier = Modifier.padding(bottom = 16.dp)) { it() } } + for (item in items) { + key(item) { + Row(modifier = Modifier.fillMaxWidth().height(45.dp), verticalAlignment = Alignment.CenterVertically) { + val interactionSource = remember { MutableInteractionSource() } + RadioButton( + selected = item == current, + onClick = { dismiss(item) }, + interactionSource = interactionSource, + ) + Text( + text = itemContent(item), + modifier = + Modifier + // .padding(start = 8.dp) + .selectable( + selected = current == item, + onClick = { dismiss(item) }, + interactionSource = interactionSource, + indication = null, + role = Role.RadioButton, + ), + ) + } + } + } + } + } + + return { value -> + setCurrent(value) + showModal() + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SimpleTooltip.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SimpleTooltip.kt new file mode 100644 index 0000000..90e953d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SimpleTooltip.kt @@ -0,0 +1,30 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.TooltipState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun SimpleTooltip( + tooltip: @Composable () -> Unit, + modifier: Modifier = Modifier, + position: TooltipAnchorPosition = TooltipAnchorPosition.Below, + state: TooltipState = rememberTooltipState(), + content: @Composable () -> Unit, +) { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(positioning = position), + modifier = modifier, + state = state, + tooltip = { PlainTooltip(content = tooltip) }, + content = content, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Utilities.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Utilities.kt new file mode 100644 index 0000000..889c3ca --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Utilities.kt @@ -0,0 +1,34 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun minHeighBugFixForOutlinedTextField( + withLabel: Boolean = true, + withSupportText: Boolean = false, +): Dp = + with(LocalDensity.current) { + val smallLineHeight = MaterialTheme.typography.bodySmall.lineHeight + remember { + val lineHeight = if (withSupportText) 4.dp + smallLineHeight.toDp() else 0.dp + val labelPadding = if (withLabel) 8.dp else 0.dp + OutlinedTextFieldDefaults.MinHeight + labelPadding + lineHeight + } + } + +@Composable +fun minHeighBugFixForTextField(withSupportText: Boolean = false): Dp = + with(LocalDensity.current) { + val smallLineHeight = MaterialTheme.typography.bodySmall.lineHeight + remember { + val lineHeight = if (withSupportText) 4.dp + smallLineHeight.toDp() else 0.dp + TextFieldDefaults.MinHeight + lineHeight + } + } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Validation.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Validation.kt new file mode 100644 index 0000000..2f36421 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Validation.kt @@ -0,0 +1,50 @@ +package org.sleepingcats.bridgecmdr.ui.component + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.alert_circle +import io.konform.validation.ValidationError +import io.konform.validation.messagesAtPath +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +fun firstErrorOf( + errors: List?, + validationPath: Any, +) = errors?.messagesAtPath(validationPath)?.let { + if (it.isEmpty()) return@let null else it[0] +} + +fun isErrored(error: String?) = error != null + +@Composable +fun errorIconOf(error: String?): (@Composable () -> Unit)? = + error?.let { + { Icon(painterResource(Res.drawable.alert_circle), contentDescription = error) } + } + +class Hints( + private val hints: Map, +) { + companion object { + @Composable + operator fun invoke(vararg messages: StringResource) = + Hints( + messages.associate { it.key to stringResource(it) }, + ) + } + + operator fun get(resource: StringResource) = get(resource.key) + + operator fun get(key: String?) = hints[key] +} + +@Composable +fun hintsOf(vararg messages: StringResource) = + Hints( + messages.associate { it.key to stringResource(it) }, + ) + +infix fun String?.from(hints: Hints) = hints[this] diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/CommonModule.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/CommonModule.kt new file mode 100644 index 0000000..96489be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/CommonModule.kt @@ -0,0 +1,48 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.module + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.request.crossfade +import coil3.size.Precision +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.ui.cache.SourceCache +import org.sleepingcats.bridgecmdr.ui.cache.UserImageCache +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import kotlin.uuid.ExperimentalUuidApi + +val commonModule = + module { + // + // User image cache + // + + singleOf(UserImageCache::KeyOf) + singleOf(UserImageCache::Factory) + singleOf({ context: PlatformContext, factory: UserImageCache.Factory, keyOf: UserImageCache.KeyOf -> + ImageLoader + .Builder(context) + .components { + add(keyOf) + add(factory) + }.precision(Precision.EXACT) + .crossfade(true) + .build() + }) + + // + // Caches + // + + singleOf(::SourceCache) + + // + // Repositories + // + + singleOf(::SettingsRepository) + singleOf(::SourceRepository) + } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.kt new file mode 100644 index 0000000..24911a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.kt @@ -0,0 +1,11 @@ +package org.sleepingcats.bridgecmdr.ui.platform + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import org.sleepingcats.bridgecmdr.ui.Route + +@Composable +expect fun ColumnScope.PlatformDashboardActions( + goTo: (route: Route) -> Unit, + startOver: (route: Route) -> Unit, +) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SettingsRepository.kt new file mode 100644 index 0000000..5a375fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SettingsRepository.kt @@ -0,0 +1,127 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.repository + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import org.sleepingcats.bridgecmdr.common.extension.get +import org.sleepingcats.bridgecmdr.common.extension.jsonPreferencesKey +import org.sleepingcats.bridgecmdr.common.extension.set +import org.sleepingcats.bridgecmdr.common.service.LegacySettingsService +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import org.sleepingcats.core.settings.PreferencesDataStore +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class SettingsRepository( + private val legacySettings: LegacySettingsService, + private val dataStore: PreferencesDataStore, + private val logger: KLogger, +) { + data class Data( + val appTheme: AppTheme, + val iconSize: IconSize, + val powerOnDevicesAtStart: Boolean, + val powerOffTaps: PowerOffTaps, + val runServer: Boolean, + val buttonOrder: List, + ) + + private val keys = + object { + val firstRunSteps = intPreferencesKey("firstRunSteps") + val legacyLoaded = booleanPreferencesKey("legacyLoaded") + val appTheme = jsonPreferencesKey("appTheme") + val iconSize = jsonPreferencesKey("iconSize") + val powerOnDevicesAtStart = booleanPreferencesKey("powerOnDevicesAtStart") + val powerOffTaps = jsonPreferencesKey("powerOffTaps") + val runServer = booleanPreferencesKey("runServer") + val buttonOrder = jsonPreferencesKey>("buttonOrder") + } + + val data: Flow = + dataStore.data.map { preferences -> + logger.info { "legacyLoaded=${preferences[keys.legacyLoaded]}" } + logger.info { "appTheme=${preferences[keys.appTheme]}" } + logger.info { "iconSize=${preferences[keys.iconSize]}" } + logger.info { "powerOnDevicesAtStart=${preferences[keys.powerOnDevicesAtStart]}" } + logger.info { "powerOffTaps=${preferences[keys.powerOffTaps]}" } + logger.info { "runServer=${preferences[keys.runServer]}" } + logger.info { "buttonOrder=${preferences[keys.buttonOrder]}" } + Data( + appTheme = preferences[keys.appTheme] ?: AppTheme.System, + iconSize = preferences[keys.iconSize] ?: IconSize.Normal, + powerOnDevicesAtStart = preferences[keys.powerOnDevicesAtStart] ?: false, + powerOffTaps = preferences[keys.powerOffTaps] ?: PowerOffTaps.Single, + runServer = preferences[keys.runServer] ?: false, + buttonOrder = preferences[keys.buttonOrder] ?: emptyList(), + ) + } + + val firstRunSteps: Flow = + dataStore.data.map { preferences -> + preferences[keys.firstRunSteps] ?: 0 + } + + private val isLegacyLoaded = + dataStore.data.map { preferences -> + preferences[keys.legacyLoaded] ?: false + } + + suspend fun loadLegacySettings() { + if (isLegacyLoaded.take(1).last()) return + logger.info { "Importing legacy settings" } + dataStore.edit { prefs -> prefs[keys.legacyLoaded] = true } + legacySettings.read()?.let { settings -> + setAppTheme(settings.colorScheme?.newValue ?: AppTheme.System) + setIconSize(settings.iconSize?.let { size -> IconSize.entries.find { it.size == size } } ?: IconSize.Normal) + setPowerOnDevicesAtStart(settings.powerOnDevices ?: false) + setPowerOffTaps(settings.powerOffWhen?.newValue ?: PowerOffTaps.Single) + settings.buttonOrder?.let { order -> setButtonOrder(order) } + } + } + + suspend fun setAppTheme(newAppTheme: AppTheme) { + dataStore.edit { preferences -> + preferences[keys.appTheme] = newAppTheme + } + } + + suspend fun setIconSize(newIconSize: IconSize) { + dataStore.edit { preferences -> + preferences[keys.iconSize] = newIconSize + } + } + + suspend fun setPowerOnDevicesAtStart(powerOn: Boolean) { + dataStore.edit { preferences -> + preferences[keys.powerOnDevicesAtStart] = powerOn + } + } + + suspend fun setPowerOffTaps(taps: PowerOffTaps) { + dataStore.edit { preferences -> + preferences[keys.powerOffTaps] = taps + } + } + + suspend fun setRunServer(runServer: Boolean) { + dataStore.edit { preferences -> + preferences[keys.runServer] = runServer + } + } + + suspend fun setButtonOrder(buttonOrder: List) { + dataStore.edit { preferences -> + preferences[keys.buttonOrder] = buttonOrder + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SourceRepository.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SourceRepository.kt new file mode 100644 index 0000000..77edffb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SourceRepository.kt @@ -0,0 +1,54 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.repository + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.cache.SourceCache +import org.sleepingcats.bridgecmdr.ui.repository.core.DataRepository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class SourceRepository( + cache: SourceCache, + private val service: SourceService, +) : DataRepository(cache) { + // Tracking here so we can keep up with it if the viewModel is released. + private val _active = MutableStateFlow(null) + val active = _active.asStateFlow() + + override suspend fun refresh() = + cache.refresh(service.all()).apply { + _active.update { value -> if (value != null) this.find { it.id == value.id } else null } + } + + override suspend fun add(item: Source) = cache.add(service.insert(Source.Payload(item))) + + override suspend fun upsert(item: Source) = + cache.upsert(service.upsert(item)).apply { + _active.update { value -> if (value?.id == item.id) this else value } + } + + override suspend fun update(item: Source) = + cache.update(service.updateById(item.id, Source.Payload(item))).apply { + _active.update { value -> if (value?.id == item.id) this else value } + } + + override suspend fun remove(item: Source) = + cache.remove(service.deleteById(item.id)).apply { + _active.update { value -> if (value?.id == item.id) null else value } + } + + suspend fun activate(item: Source) { + _active.update { item } + service.activateById(item.id) + } + + suspend fun partialUpdate( + id: Uuid, + updates: Source.Update, + ) = cache.update(service.partialUpdateById(id, updates)) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/CachingRepository.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/CachingRepository.kt new file mode 100644 index 0000000..e2252b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/CachingRepository.kt @@ -0,0 +1,17 @@ +package org.sleepingcats.bridgecmdr.ui.repository.core + +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache + +abstract class CachingRepository, C : DataCache>( + protected val cache: C, +) { + val items = cache.items + + suspend fun latest() = cache.latest() + + fun find(key: K) = cache.find(key) + + suspend fun findLatest(key: K) = cache.findLatest(key) + + abstract suspend fun refresh(): List +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/DataRepository.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/DataRepository.kt new file mode 100644 index 0000000..d0de985 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/DataRepository.kt @@ -0,0 +1,15 @@ +package org.sleepingcats.bridgecmdr.ui.repository.core + +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache + +abstract class DataRepository, C : DataCache>( + cache: C, +) : CachingRepository(cache) { + abstract suspend fun add(item: T): T + + abstract suspend fun upsert(item: T): T + + abstract suspend fun update(item: T): T + + abstract suspend fun remove(item: T): T +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/scaffold/FullscreenModalScaffold.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/scaffold/FullscreenModalScaffold.kt new file mode 100644 index 0000000..aaec5b8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/scaffold/FullscreenModalScaffold.kt @@ -0,0 +1,54 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.scaffold + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun FullscreenModalScaffold( + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + titleContent: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: Color = MaterialTheme.colorScheme.contentColorFor(containerColor), + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = containerColor, + topBar = { + TopAppBar( + colors = + TopAppBarDefaults.topAppBarColors().copy( + containerColor = containerColor, + navigationIconContentColor = contentColor, + titleContentColor = contentColor, + actionIconContentColor = contentColor, + ), + navigationIcon = navigationIcon, + title = titleContent, + actions = actions, + ) + }, + ) { paddingValues -> + Column( + modifier = modifier.padding(paddingValues).padding(horizontal = 24.dp), + content = content, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Color.kt new file mode 100644 index 0000000..2086456 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Color.kt @@ -0,0 +1,219 @@ +package org.sleepingcats.bridgecmdr.ui.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFF006874) +val onPrimaryLight = Color(0xFFFFFFFF) +val primaryContainerLight = Color(0xFF9EEFFD) +val onPrimaryContainerLight = Color(0xFF004F58) +val secondaryLight = Color(0xFF4A6267) +val onSecondaryLight = Color(0xFFFFFFFF) +val secondaryContainerLight = Color(0xFFCDE7EC) +val onSecondaryContainerLight = Color(0xFF334B4F) +val tertiaryLight = Color(0xFF525E7D) +val onTertiaryLight = Color(0xFFFFFFFF) +val tertiaryContainerLight = Color(0xFFDAE2FF) +val onTertiaryContainerLight = Color(0xFF3B4664) +val errorLight = Color(0xFFBA1A1A) +val onErrorLight = Color(0xFFFFFFFF) +val errorContainerLight = Color(0xFFFFDAD6) +val onErrorContainerLight = Color(0xFF93000A) +val backgroundLight = Color(0xFFF5FAFB) +val onBackgroundLight = Color(0xFF171D1E) +val surfaceLight = Color(0xFFF5FAFB) +val onSurfaceLight = Color(0xFF171D1E) +val surfaceVariantLight = Color(0xFFDBE4E6) +val onSurfaceVariantLight = Color(0xFF3F484A) +val outlineLight = Color(0xFF6F797A) +val outlineVariantLight = Color(0xFFBFC8CA) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFF2B3133) +val inverseOnSurfaceLight = Color(0xFFECF2F3) +val inversePrimaryLight = Color(0xFF82D3E0) +val surfaceDimLight = Color(0xFFD5DBDC) +val surfaceBrightLight = Color(0xFFF5FAFB) +val surfaceContainerLowestLight = Color(0xFFFFFFFF) +val surfaceContainerLowLight = Color(0xFFEFF5F6) +val surfaceContainerLight = Color(0xFFE9EFF0) +val surfaceContainerHighLight = Color(0xFFE3E9EA) +val surfaceContainerHighestLight = Color(0xFFDEE3E5) + +val primaryLightMediumContrast = Color(0xFF003C44) +val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) +val primaryContainerLightMediumContrast = Color(0xFF187884) +val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val secondaryLightMediumContrast = Color(0xFF223A3E) +val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) +val secondaryContainerLightMediumContrast = Color(0xFF597176) +val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryLightMediumContrast = Color(0xFF2A3553) +val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightMediumContrast = Color(0xFF616C8D) +val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) +val errorLightMediumContrast = Color(0xFF740006) +val onErrorLightMediumContrast = Color(0xFFFFFFFF) +val errorContainerLightMediumContrast = Color(0xFFCF2C27) +val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) +val backgroundLightMediumContrast = Color(0xFFF5FAFB) +val onBackgroundLightMediumContrast = Color(0xFF171D1E) +val surfaceLightMediumContrast = Color(0xFFF5FAFB) +val onSurfaceLightMediumContrast = Color(0xFF0C1213) +val surfaceVariantLightMediumContrast = Color(0xFFDBE4E6) +val onSurfaceVariantLightMediumContrast = Color(0xFF2F3839) +val outlineLightMediumContrast = Color(0xFF4B5456) +val outlineVariantLightMediumContrast = Color(0xFF656F70) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFF2B3133) +val inverseOnSurfaceLightMediumContrast = Color(0xFFECF2F3) +val inversePrimaryLightMediumContrast = Color(0xFF82D3E0) +val surfaceDimLightMediumContrast = Color(0xFFC2C7C9) +val surfaceBrightLightMediumContrast = Color(0xFFF5FAFB) +val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightMediumContrast = Color(0xFFEFF5F6) +val surfaceContainerLightMediumContrast = Color(0xFFE3E9EA) +val surfaceContainerHighLightMediumContrast = Color(0xFFD8DEDF) +val surfaceContainerHighestLightMediumContrast = Color(0xFFCDD3D4) + +val primaryLightHighContrast = Color(0xFF003238) +val onPrimaryLightHighContrast = Color(0xFFFFFFFF) +val primaryContainerLightHighContrast = Color(0xFF00515A) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF173034) +val onSecondaryLightHighContrast = Color(0xFFFFFFFF) +val secondaryContainerLightHighContrast = Color(0xFF354D51) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFF202B48) +val onTertiaryLightHighContrast = Color(0xFFFFFFFF) +val tertiaryContainerLightHighContrast = Color(0xFF3D4867) +val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) +val errorLightHighContrast = Color(0xFF600004) +val onErrorLightHighContrast = Color(0xFFFFFFFF) +val errorContainerLightHighContrast = Color(0xFF98000A) +val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) +val backgroundLightHighContrast = Color(0xFFF5FAFB) +val onBackgroundLightHighContrast = Color(0xFF171D1E) +val surfaceLightHighContrast = Color(0xFFF5FAFB) +val onSurfaceLightHighContrast = Color(0xFF000000) +val surfaceVariantLightHighContrast = Color(0xFFDBE4E6) +val onSurfaceVariantLightHighContrast = Color(0xFF000000) +val outlineLightHighContrast = Color(0xFF252E2F) +val outlineVariantLightHighContrast = Color(0xFF414B4C) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFF2B3133) +val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) +val inversePrimaryLightHighContrast = Color(0xFF82D3E0) +val surfaceDimLightHighContrast = Color(0xFFB4BABB) +val surfaceBrightLightHighContrast = Color(0xFFF5FAFB) +val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) +val surfaceContainerLowLightHighContrast = Color(0xFFECF2F3) +val surfaceContainerLightHighContrast = Color(0xFFDEE3E5) +val surfaceContainerHighLightHighContrast = Color(0xFFCFD5D6) +val surfaceContainerHighestLightHighContrast = Color(0xFFC2C7C9) + +val primaryDark = Color(0xFF82D3E0) +val onPrimaryDark = Color(0xFF00363D) +val primaryContainerDark = Color(0xFF004F58) +val onPrimaryContainerDark = Color(0xFF9EEFFD) +val secondaryDark = Color(0xFFB1CBD0) +val onSecondaryDark = Color(0xFF1C3438) +val secondaryContainerDark = Color(0xFF334B4F) +val onSecondaryContainerDark = Color(0xFFCDE7EC) +val tertiaryDark = Color(0xFFBAC6EA) +val onTertiaryDark = Color(0xFF24304D) +val tertiaryContainerDark = Color(0xFF3B4664) +val onTertiaryContainerDark = Color(0xFFDAE2FF) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFF93000A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF0E1415) +val onBackgroundDark = Color(0xFFDEE3E5) +val surfaceDark = Color(0xFF0E1415) +val onSurfaceDark = Color(0xFFDEE3E5) +val surfaceVariantDark = Color(0xFF3F484A) +val onSurfaceVariantDark = Color(0xFFBFC8CA) +val outlineDark = Color(0xFF899294) +val outlineVariantDark = Color(0xFF3F484A) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFDEE3E5) +val inverseOnSurfaceDark = Color(0xFF2B3133) +val inversePrimaryDark = Color(0xFF006874) +val surfaceDimDark = Color(0xFF0E1415) +val surfaceBrightDark = Color(0xFF343A3B) +val surfaceContainerLowestDark = Color(0xFF090F10) +val surfaceContainerLowDark = Color(0xFF171D1E) +val surfaceContainerDark = Color(0xFF1B2122) +val surfaceContainerHighDark = Color(0xFF252B2C) +val surfaceContainerHighestDark = Color(0xFF303637) + +val primaryDarkMediumContrast = Color(0xFF98E9F7) +val onPrimaryDarkMediumContrast = Color(0xFF002A30) +val primaryContainerDarkMediumContrast = Color(0xFF499CA9) +val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) +val secondaryDarkMediumContrast = Color(0xFFC7E1E6) +val onSecondaryDarkMediumContrast = Color(0xFF10292D) +val secondaryContainerDarkMediumContrast = Color(0xFF7C959A) +val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) +val tertiaryDarkMediumContrast = Color(0xFFD1DCFF) +val onTertiaryDarkMediumContrast = Color(0xFF192541) +val tertiaryContainerDarkMediumContrast = Color(0xFF8490B2) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF0E1415) +val onBackgroundDarkMediumContrast = Color(0xFFDEE3E5) +val surfaceDarkMediumContrast = Color(0xFF0E1415) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF3F484A) +val onSurfaceVariantDarkMediumContrast = Color(0xFFD4DEE0) +val outlineDarkMediumContrast = Color(0xFFAAB4B5) +val outlineVariantDarkMediumContrast = Color(0xFF889294) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFDEE3E5) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF252B2C) +val inversePrimaryDarkMediumContrast = Color(0xFF005059) +val surfaceDimDarkMediumContrast = Color(0xFF0E1415) +val surfaceBrightDarkMediumContrast = Color(0xFF3F4647) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF040809) +val surfaceContainerLowDarkMediumContrast = Color(0xFF191F20) +val surfaceContainerDarkMediumContrast = Color(0xFF23292A) +val surfaceContainerHighDarkMediumContrast = Color(0xFF2D3435) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF393F40) + +val primaryDarkHighContrast = Color(0xFFCDF7FF) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFF7ECFDC) +val onPrimaryContainerDarkHighContrast = Color(0xFF000E10) +val secondaryDarkHighContrast = Color(0xFFDAF5FA) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFFADC7CC) +val onSecondaryContainerDarkHighContrast = Color(0xFF000E10) +val tertiaryDarkHighContrast = Color(0xFFEDEFFF) +val onTertiaryDarkHighContrast = Color(0xFF000000) +val tertiaryContainerDarkHighContrast = Color(0xFFB6C2E6) +val onTertiaryContainerDarkHighContrast = Color(0xFF000925) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF0E1415) +val onBackgroundDarkHighContrast = Color(0xFFDEE3E5) +val surfaceDarkHighContrast = Color(0xFF0E1415) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF3F484A) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFE8F2F3) +val outlineVariantDarkHighContrast = Color(0xFFBBC4C6) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFDEE3E5) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF005059) +val surfaceDimDarkHighContrast = Color(0xFF0E1415) +val surfaceBrightDarkHighContrast = Color(0xFF4B5152) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF1B2122) +val surfaceContainerDarkHighContrast = Color(0xFF2B3133) +val surfaceContainerHighDarkHighContrast = Color(0xFF363C3E) +val surfaceContainerHighestDarkHighContrast = Color(0xFF424849) diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Theme.kt new file mode 100644 index 0000000..df9c739 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Theme.kt @@ -0,0 +1,277 @@ +package org.sleepingcats.bridgecmdr.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +private val lightScheme = + lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, + ) + +private val darkScheme = + darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, + ) + +private val mediumContrastLightColorScheme = + lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, + ) + +private val highContrastLightColorScheme = + lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, + ) + +private val mediumContrastDarkColorScheme = + darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, + ) + +private val highContrastDarkColorScheme = + darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, + ) + +@Immutable +data class ColorFamily( + val color: Color, + val onColor: Color, + val colorContainer: Color, + val onColorContainer: Color, +) + +val unspecified_scheme = + ColorFamily( + Color.Unspecified, + Color.Unspecified, + Color.Unspecified, + Color.Unspecified, + ) + +@Composable +fun BridgeCmdrTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = + when { + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content, + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Type.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Type.kt new file mode 100644 index 0000000..392f887 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package org.sleepingcats.bridgecmdr.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DashboardView.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DashboardView.kt new file mode 100644 index 0000000..5d4291c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DashboardView.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.cog +import bridgecmdr.composeapp.generated.resources.settings +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.extension.navigateAndReplaceStartRoute +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.Settings +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.MediaImage +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.platform.PlatformDashboardActions +import org.sleepingcats.bridgecmdr.ui.view.model.DashboardViewModel +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyGridState +import kotlin.uuid.ExperimentalUuidApi + +@Composable +fun DashboardRoute(navController: NavController) { + DashboardView( + goTo = { r -> navController.navigate(r) }, + startOver = { r -> navController.navigateAndReplaceStartRoute(r) }, + ) +} + +@Composable +private fun DashboardView( + goTo: (Route) -> Unit, + startOver: (Route) -> Unit, + viewModel: DashboardViewModel = koinViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val buttonSize = DpSize(state.iconSize.size.dp, state.iconSize.size.dp) + + val normalContainerColor = MaterialTheme.colorScheme.surfaceContainer + val activatedContainerColor = MaterialTheme.colorScheme.primaryContainer + + val lazyGridState = rememberLazyGridState() + val reorderState = + rememberReorderableLazyGridState(lazyGridState) { from, to -> + viewModel.moveButton(from.index, to.index) + } + + fun activateSource(source: Source) = viewModel.activateSource(source) + + LoadingOverlay(state.isLoading) + + Scaffold( + modifier = Modifier.fillMaxSize(), + floatingActionButton = { + Column(horizontalAlignment = End) { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.settings)) }, + ) { + FloatingActionButton( + modifier = Modifier.padding(top = 16.dp), + onClick = { goTo(Settings) }, + ) { Icon(painterResource(Res.drawable.cog), contentDescription = stringResource(Res.string.settings)) } + } + PlatformDashboardActions(goTo, startOver) + } + }, + ) { paddingValues -> + LazyVerticalGrid( + state = lazyGridState, + modifier = Modifier.fillMaxSize().padding(paddingValues), + columns = GridCells.FixedSize(buttonSize.width + 20.dp), + ) { + items(state.sources, key = { it.id }) { source -> + ReorderableItem(reorderState, key = source.id) { isDragging -> + Box( + modifier = + Modifier + .padding(start = 16.dp, top = 16.dp) + .draggableHandle() + .dropShadow(MaterialTheme.shapes.medium) { + if (isDragging) { + alpha = 0.5f + radius = 4f + spread = 4f + } else { + alpha = 0f + } + }.clip(MaterialTheme.shapes.medium) + .alpha(if (state.dragged?.id == source.id) 0.25f else 1f) + .background(if (source.id == state.active?.id) activatedContainerColor else normalContainerColor) + .selectable(selected = source.id == state.active?.id, onClick = { activateSource(source) }) + .padding(4.dp) + .size(buttonSize), + ) { + MediaImage( + source.image, + contentDescription = source.title, + containerColor = if (source.id == state.active?.id) activatedContainerColor else normalContainerColor, + size = buttonSize, + ) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ApplicationViewModel.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ApplicationViewModel.kt new file mode 100644 index 0000000..27c3127 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ApplicationViewModel.kt @@ -0,0 +1,30 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel + +open class ApplicationViewModel( + logger: KLogger, + settingsRepository: SettingsRepository, +) : TrackingViewModel(logger) { + data class State( + val error: ViewError? = null, + val fatalError: ViewError? = null, + val appTheme: AppTheme = AppTheme.System, + ) + + val state = + combine( + error, + fatalError, + settingsRepository.data.map { it.appTheme }, + ::State, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DashboardViewModel.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DashboardViewModel.kt new file mode 100644 index 0000000..37bbf03 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DashboardViewModel.kt @@ -0,0 +1,102 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +open class DashboardViewModel( + logger: KLogger, + private val settingsRepository: SettingsRepository, + private val repository: SourceRepository, +) : TrackingViewModel(logger) { + data class State( + val isLoading: Boolean = false, + val sources: List = emptyList(), + val active: Source? = null, + val dragged: Source? = null, + val iconSize: IconSize = IconSize.Normal, + val powerOffTaps: PowerOffTaps = PowerOffTaps.Single, + val buttonOrder: List = emptyList(), + ) + + private val dragged = MutableStateFlow(null) + + val state = + combine( + isLoading, + repository.items, + repository.active, + dragged, + settingsRepository.data, + ) { loading, sources, active, dragged, settings -> + val sortedSources = + settings.buttonOrder + .mapNotNull { id -> sources.find { it.id == id } } + .run { + if (size < sources.size) { + plus(sources.filter { it.id !in settings.buttonOrder }) + } else { + this + } + } + + State( + isLoading = loading, + sources = sortedSources, + active = active, + dragged = dragged, + iconSize = settings.iconSize, + powerOffTaps = settings.powerOffTaps, + buttonOrder = sortedSources.map { it.id }, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + init { + launchLoading { refreshSources() } + } + + suspend fun refreshSources(onError: (suspend (cause: Throwable) -> Unit)? = null) = + loadingWhile { + try { + repository.refresh() + } catch (cause: Throwable) { + logger.error(cause) { "Source refresh failed" } + onError?.invoke(cause) + } + } + + fun activateSource(source: Source) { + viewModelScope.launch { + try { + repository.activate(source) + } catch (cause: Throwable) { + logger.error(cause) { "Source activation failed" } + } + } + } + + suspend fun moveButton( + from: Int, + to: Int, + ) { + settingsRepository.setButtonOrder( + state.value.buttonOrder.toMutableList().apply { + this[to] = this[from].also { this[from] = this[to] } + }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/core/TrackingViewModel.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/core/TrackingViewModel.kt new file mode 100644 index 0000000..2b4c871 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/core/TrackingViewModel.kt @@ -0,0 +1,94 @@ +package org.sleepingcats.bridgecmdr.ui.view.model.core + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource +import org.koin.core.component.KoinComponent +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +abstract class TrackingViewModel( + protected val logger: KLogger, +) : ViewModel(), + KoinComponent { + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + class ViewError( + val resource: StringResource, + val cause: Throwable, + val onDismiss: (() -> ViewError?)? = null, + ) + + protected val error = MutableStateFlow(null) + + protected val fatalError = MutableStateFlow(null) + + protected suspend fun loadingWhile(block: suspend () -> R): R { + try { + _isLoading.update { true } + return block() + } finally { + _isLoading.update { false } + } + } + + protected fun launchLoading( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, + ) = viewModelScope.launch(context, start) { loadingWhile { block() } } + + protected suspend fun loadingCoroutineScope(block: suspend CoroutineScope.() -> R): R = + loadingWhile { coroutineScope(block) } + + protected suspend fun loadingWithContext( + context: CoroutineContext, + block: suspend CoroutineScope.() -> R, + ): R = loadingWhile { withContext(context, block) } + + fun dismissError() { + // fatalError.update { null } // Should not be able to dismiss a + // fatal error. The dismiss action should be attached to the + // error in cases where the view does not want to handle + // it. + fatalError.update { current -> + if (current?.onDismiss != null) { + current.onDismiss() + } else { + current + } + } + + error.update { null } + } + + protected fun pushFatalError( + throwable: Throwable, + lazyMessage: () -> StringResource, + ) { + val resource = lazyMessage() + // NOTE: Log will not resolve to the message, just the resource ID. + logger.error(throwable) { resource } + fatalError.update { ViewError(resource, throwable) } + } + + protected fun pushError( + throwable: Throwable, + lazyMessage: () -> StringResource, + ) { + val resource = lazyMessage() + // NOTE: Log will not resolve to the message, just the resource ID. + logger.error(throwable) { resource } + error.update { ViewError(resource, throwable) } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ApplicationBranding.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ApplicationBranding.kt new file mode 100644 index 0000000..120194c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ApplicationBranding.kt @@ -0,0 +1,42 @@ +package org.sleepingcats.core + +open class ApplicationBranding( + val qualifier: String, + val company: String, + val name: String, + val version: String, +) { + companion object { + private val pattern = Regex("[^A-Za-z0-9]+") + private val trimStart = Regex("^[^A-Za-z0-9]+") + private val trimEnd = Regex("[^A-Za-z0-9]+$") + + private fun String.qualify(separator: String) = + this.replace(pattern, separator).replace(trimStart, "").replace(trimEnd, "") + } + + val qualifiedQualifier = qualifier.lowercase() + + val companyQualifiedName = + when { + Platform.isWindows -> company.qualify(" ") + Platform.isMac -> company.qualify("-").lowercase() + else -> company.qualify("").lowercase() + } + + val qualifiedName = + when { + Platform.isWindows -> name.qualify(" ") + Platform.isMac -> name.qualify("-").lowercase() + else -> name.qualify("").lowercase() + } + + val applicationId = "$qualifiedQualifier.$qualifiedName" + + val qualifiedPath = + when { + Platform.isWindows -> listOf(companyQualifiedName, qualifiedName) + Platform.isMac -> listOf(applicationId) + else -> listOf(qualifiedName) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ErrorHandling.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ErrorHandling.kt new file mode 100644 index 0000000..c73416f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/ErrorHandling.kt @@ -0,0 +1,6 @@ +package org.sleepingcats.core + +typealias ErrorHandler = (String) -> Nothing + +// TODO: +object ErrorHandling diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Memoize.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Memoize.kt new file mode 100644 index 0000000..3b0bf75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Memoize.kt @@ -0,0 +1,44 @@ +package org.sleepingcats.core + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +inline fun memoize(noinline func: () -> R): () -> R { + var cache: R? = null + return { + cache ?: func().also { + @Suppress("AssignedValueIsNeverRead") // Used in subsequent calls + cache = it + } + } +} + +inline fun memoizeSuspend(noinline func: suspend () -> R): suspend () -> R { + val mutex = Mutex() + var cache: R? = null + return { + mutex.withLock { + cache ?: func().also { + @Suppress("AssignedValueIsNeverRead") // Used in subsequent calls + cache = it + } + } + } +} + +fun cache( + by: ((T) -> Any?) = { it.toString() }, + func: (T) -> R, +): (T) -> R { + val cache = mutableMapOf() + return { arg -> by(arg).let { key -> cache[key] ?: func(arg).also { cache[key] = it } } } +} + +fun cacheSuspend( + by: ((T) -> Any?) = { it.toString() }, + func: suspend (T) -> R, +): suspend (T) -> R { + val mutex = Mutex() + val cache = mutableMapOf() + return { arg -> mutex.withLock { by(arg).let { key -> cache[key] ?: func(arg).also { cache[key] = it } } } } +} diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Platform.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Platform.kt new file mode 100644 index 0000000..a81e21a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/Platform.kt @@ -0,0 +1,47 @@ +package org.sleepingcats.core + +// TODO: WasmWasi? + +enum class PlatformType { + /** Native platforms: Linux, Windows, macOS, FreeBSD, etc. */ + Native, + + /** Desktop JVM: Linux, Windows, macOS, FreeBSD, etc. */ + Jvm, + + /** JavaScript */ + Web, + + /** WebAssembly */ + Wasm, + + /** iOS, iPadOS, watchOS, visionOs, and macOS Catalyst */ + DarwinMobile, + + /** Android */ + Android, +} + +interface Platform { + val name: String + val version: String + val arch: String + val type: PlatformType + + companion object : Platform { + val current = getPlatform() + + override val name = current.name + override val version = current.version + override val arch = current.arch + override val type = current.type + + val isWindows = current.name.lowercase().startsWith("windows") + + val isMac = current.name.lowercase().startsWith("mac os x") + + val isPosix = !isWindows || isMac + } +} + +expect fun getPlatform(): Platform diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/Flow.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/Flow.kt new file mode 100644 index 0000000..27fff0a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/Flow.kt @@ -0,0 +1,24 @@ +package org.sleepingcats.core.extensions + +import kotlinx.coroutines.flow.Flow + +fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = + kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> -> + @Suppress("UNCHECKED_CAST") // Similar to the standard library's implementation. + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + ) + } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/List.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/List.kt new file mode 100644 index 0000000..a2c9957 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/List.kt @@ -0,0 +1,31 @@ +package org.sleepingcats.core.extensions + +fun List.removingFirst(predicate: (T) -> Boolean): List = + this.indexOfFirst(predicate).let { index -> + when { + index >= 0 -> this.subList(0, index) + this.subList(index + 1, this.size) + else -> this + } + } + +fun List.upsertingFirst( + newItem: T, + predicate: (T) -> Boolean, +): List = + this.indexOfFirst(predicate).let { index -> + when { + index >= 0 -> this.subList(0, index) + newItem + this.subList(index + 1, this.size) + else -> this + newItem + } + } + +fun List.replacingFirst( + newItem: T, + predicate: (T) -> Boolean, +): List = + this.indexOfFirst(predicate).let { index -> + when { + index >= 0 -> this.subList(0, index) + newItem + this.subList(index + 1, this.size) + else -> this + } + } diff --git a/composeApp/src/commonMain/kotlin/org/sleepingcats/core/settings/PreferencesDataStore.kt b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/settings/PreferencesDataStore.kt new file mode 100644 index 0000000..8a3cd07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/sleepingcats/core/settings/PreferencesDataStore.kt @@ -0,0 +1,8 @@ +package org.sleepingcats.core.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +class PreferencesDataStore( + ds: DataStore, +) : DataStore by ds diff --git a/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ApplicationTest.kt b/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ApplicationTest.kt new file mode 100644 index 0000000..77649d4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ApplicationTest.kt @@ -0,0 +1,17 @@ +package org.sleepingcats.bridgecmdr + +// import kotlin.test.Test +// import kotlin.test.assertEquals +// +class ApplicationTest { +// @Test +// fun testRoot() = +// testApplication { +// application { +// module() +// } +// val response = client.get("/") +// assertEquals(HttpStatusCode.OK, response.status) +// assertEquals("Ktor: ${Greeting.greet()}", response.bodyAsText()) +// } +} diff --git a/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ComposeAppCommonTest.kt b/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ComposeAppCommonTest.kt new file mode 100644 index 0000000..0894eea --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ComposeAppCommonTest.kt @@ -0,0 +1,11 @@ +package org.sleepingcats.bridgecmdr + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ComposeAppCommonTest { + @Test + fun example() { + assertEquals(3, 1 + 2) + } +} diff --git a/composeApp/src/jvmMain/composeResources/drawable/app_icon.png b/composeApp/src/jvmMain/composeResources/drawable/app_icon.png new file mode 100644 index 0000000..03cffad Binary files /dev/null and b/composeApp/src/jvmMain/composeResources/drawable/app_icon.png differ diff --git a/composeApp/src/jvmMain/composeResources/drawable/qrcode.xml b/composeApp/src/jvmMain/composeResources/drawable/qrcode.xml new file mode 100644 index 0000000..44fcaa6 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/drawable/qrcode.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/composeApp/src/jvmMain/composeResources/values/backup.xml b/composeApp/src/jvmMain/composeResources/values/backup.xml new file mode 100644 index 0000000..fe11481 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/backup.xml @@ -0,0 +1,11 @@ + + + Export settings + Exports settings to an archive + Failed to export back-up + Import settings + Imports settings from an archive + Failed to import back-up + Load backup + BridgeCmdr.Backup + diff --git a/composeApp/src/jvmMain/composeResources/values/devices.xml b/composeApp/src/jvmMain/composeResources/values/devices.xml new file mode 100644 index 0000000..d05bbb7 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/devices.xml @@ -0,0 +1,41 @@ + + + + Unknown device + + + Delete monitor + Delete switch + Delete device + Add switch or monitor + + Delete this monitor? + Delete this switch? + Delete this device? + This will also delete any related source ties. + + Failed to get devices + Failed to delete device + + + Add device + Edit device + + Title + Driver + Connection type + Port + Hostname + Experimental driver + + Failed to get device + Failed to add device + Failed to update device + + Device title cannot be blank + Device title cannot exceed 255 characters + Device driver must be selected + Hostname cannot be blank + Serial port must be selected + Make a selection + diff --git a/composeApp/src/jvmMain/composeResources/values/drivers.xml b/composeApp/src/jvmMain/composeResources/values/drivers.xml new file mode 100644 index 0000000..441e5e9 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/drivers.xml @@ -0,0 +1,22 @@ + + + + Unknown driver + + + Extron Electronics + ShinybowUSA + Sony Corporation + Tesla Elec Technology Co.,Ltd + Extron SIS-compatible matrix switch + Shinybow v2.0 compatible matrix switch + Shinybow v3.0 compatible matrix switch + Sony RS-485 controllable monitor + Tesla smart KVM-compatible switch + Tesla smart matrix-compatible switch + Tesla smart SDI-compatible switch + TESmart KVM-compatible switch + TESmart matrix-compatible switch + TESmart SDI-compatible switch + New tie + diff --git a/composeApp/src/jvmMain/composeResources/values/errors.xml b/composeApp/src/jvmMain/composeResources/values/errors.xml new file mode 100644 index 0000000..1d39ad9 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/errors.xml @@ -0,0 +1,4 @@ + + + Failed to open or create settings database + diff --git a/composeApp/src/jvmMain/composeResources/values/server.xml b/composeApp/src/jvmMain/composeResources/values/server.xml new file mode 100644 index 0000000..94f6e6c --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/server.xml @@ -0,0 +1,12 @@ + + + + Remote server code + Open %1$s on your mobile device and\nscan this QR code for remote control + + + Stopped + Starting + Running + Stopping + diff --git a/composeApp/src/jvmMain/composeResources/values/settings.xml b/composeApp/src/jvmMain/composeResources/values/settings.xml new file mode 100644 index 0000000..f187535 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/settings.xml @@ -0,0 +1,30 @@ + + + + General + Basic settings for %1$s + Sources + + 1 source + %1$d sources + + Switches and Monitors + + 1 device + %1$d devices + + Backup settings + Import or export setting as a file + + Automatic start-up + %1$s will not start automatically with your system + %1$s will start automatically with your system + Device automatic power-on + Leave switches & monitors as is when %1$s starts + Switches & monitors will power on when %1$s starts + Remote control server + Use the remote control server to controller your equipment from your mobile device + Status: %1$s + Close %1$s + Close %1$s without powering down + diff --git a/composeApp/src/jvmMain/composeResources/values/sources.xml b/composeApp/src/jvmMain/composeResources/values/sources.xml new file mode 100644 index 0000000..bb176fd --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/sources.xml @@ -0,0 +1,28 @@ + + + + Unknown source + + + Delete source + Add source + + Delete this source? + + Failed to get sources + Failed to delete source + + + Add source + Edit source + + Title + + Failed to get source + Failed to add source + Failed to update source + Failed to upload image + + Source title cannot be blank + Source title cannot exceed 255 characters + diff --git a/composeApp/src/jvmMain/composeResources/values/strings.xml b/composeApp/src/jvmMain/composeResources/values/strings.xml new file mode 100644 index 0000000..e4cd194 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/strings.xml @@ -0,0 +1,5 @@ + + + + Power off + diff --git a/composeApp/src/jvmMain/composeResources/values/ties.xml b/composeApp/src/jvmMain/composeResources/values/ties.xml new file mode 100644 index 0000000..3a23d73 --- /dev/null +++ b/composeApp/src/jvmMain/composeResources/values/ties.xml @@ -0,0 +1,37 @@ + + + + Unknown tie + + + Delete tie + Add tie + + Source + Ties + No ties, add some + Input channel + Video output channel + Video output channel + + Delete this tie? + + Failed to get ties + Failed to upload image + Failed to update source image + Failed to update source title + Failed to delete tie + + + Add tie + Edit tie + + Device must be selected + Input channel must be at least 1 + Video output channel must be at least 1 + Audio output channel must be at least 1 + + Failed to get tie + Failed to add tie + Failed to update tie + diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Environment.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Environment.kt new file mode 100644 index 0000000..d4d933c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Environment.kt @@ -0,0 +1,5 @@ +package org.sleepingcats.bridgecmdr + +import org.sleepingcats.core.ApplicationEnvironment + +object Environment : ApplicationEnvironment(Branding) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Main.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Main.kt new file mode 100644 index 0000000..caac772 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Main.kt @@ -0,0 +1,59 @@ +package org.sleepingcats.bridgecmdr + +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.window.application +import io.github.oshai.kotlinlogging.KotlinLogging +import io.github.vinceglb.filekit.FileKit +import org.koin.compose.KoinApplication +import org.koin.compose.koinInject +import org.koin.core.logger.Level +import org.koin.dsl.module +import org.koin.logger.SLF4JLogger +import org.sleepingcats.bridgecmdr.common.module.databaseModule +import org.sleepingcats.bridgecmdr.common.module.localServiceModule +import org.sleepingcats.bridgecmdr.server.module.serverModule +import org.sleepingcats.bridgecmdr.ui.MainWindow +import org.sleepingcats.bridgecmdr.ui.module.commonModule +import org.sleepingcats.bridgecmdr.ui.module.platformContextModule +import org.sleepingcats.bridgecmdr.ui.module.platformSpecificModule +import kotlin.system.exitProcess + +fun main() { + // TODO: Localize the app-name here. + System.setProperty("apple.awt.application.appearance", "system") + System.setProperty("apple.awt.application.name", Branding.name) + FileKit.init( + appId = Branding.applicationId, + filesDir = + Environment.directories.user.data + .toFile(), + cacheDir = + Environment.directories.user.cache + .toFile(), + ) + application { + val scope = rememberCoroutineScope() + + fun exitApp(code: Int) { + when (code) { + 0 -> exitApplication() + else -> exitProcess(code) + } + } + + KoinApplication(application = { + logger(SLF4JLogger(Level.INFO)) + modules( + module { single { KotlinLogging.logger(Branding.qualifiedName) } }, + platformContextModule, + databaseModule, + localServiceModule, + serverModule(scope), + commonModule, + platformSpecificModule(::exitApp), + ) + }) { + MainWindow(koinInject()) { App() } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Exporter.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Exporter.kt new file mode 100644 index 0000000..e07d85d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Exporter.kt @@ -0,0 +1,112 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup + +import io.github.oshai.kotlinlogging.KLogger +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.backup.model.Version4 +import org.sleepingcats.bridgecmdr.common.backup.model.Version5 +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.io.path.outputStream +import kotlin.uuid.ExperimentalUuidApi + +class Exporter( + private val logger: KLogger, + private val settingsRepository: SettingsRepository, + private val userImageService: UserImageService, + private val sourceService: SourceService, + private val deviceService: DeviceService, + private val tieService: TieService, +) { + private fun mimeTypeToExt(type: String) = + when (MimeType.parse(type).subtype) { + "png" -> "png" + "jpeg" -> "jpg" + "gif" -> "gif" + "svg+xml" -> "svg" + else -> "bin" + } + + suspend operator fun invoke(path: Path) = + withContext(Dispatchers.IO) { + ZipOutputStream(path.outputStream()).use { zipStream -> + val settings = settingsRepository.data.take(1).last() + val sources = async { sourceService.all() } + val devices = async { deviceService.all() } + val ties = async { tieService.all() } + val images = async { userImageService.all() } + + val imageNames = + images.await().associate { image -> + val id = image.id + val extension = mimeTypeToExt(image.type) + val name = "$id.$extension" + + zipStream.putNextEntry(ZipEntry("${image.id.toHexDashString()}.$extension")) + AutoCloseable { zipStream.closeEntry() }.use { zipStream.write(image.data) } + Pair(id, name) + } + + val backup = + Version5.Backup( + version = 5, + settings = + Version5.Settings( + appTheme = settings.appTheme, + iconSize = settings.iconSize, + powerOnDevicesAtStart = settings.powerOnDevicesAtStart, + powerOffTaps = settings.powerOffTaps, + buttonOrder = settings.buttonOrder, + ), + layouts = + Version4.Layouts( + sources = + sources.await().map { source -> + Version4.Source( + id = source.id, + title = source.title, + image = source.image?.let { imageNames[it] }, + ) + }, + devices = + devices.await().map { device -> + Version4.Device( + id = device.id, + driverId = device.driverId, + title = device.title, + path = device.path, + ) + }, + ties = + ties.await().map { tie -> + Version4.Tie( + id = tie.id, + sourceId = tie.sourceId, + deviceId = tie.deviceId, + inputChannel = tie.inputChannel, + outputVideoChannel = tie.outputVideoChannel, + outputAudioChannel = tie.outputAudioChannel, + ) + }, + ), + ) + + zipStream.putNextEntry(ZipEntry("config.json")) + AutoCloseable { zipStream.closeEntry() } + .use { zipStream.write(Json.encodeToString(backup).toByteArray(Charsets.UTF_8)) } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Importer.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Importer.kt new file mode 100644 index 0000000..998976f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Importer.kt @@ -0,0 +1,163 @@ +@file:OptIn(ExperimentalPathApi::class, ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.backup.model.Version1 +import org.sleepingcats.bridgecmdr.common.backup.model.Version2 +import org.sleepingcats.bridgecmdr.common.backup.model.Version3 +import org.sleepingcats.bridgecmdr.common.backup.model.Version4 +import org.sleepingcats.bridgecmdr.common.backup.model.Version5 +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.repository.TieRepository +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipInputStream +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.inputStream +import kotlin.uuid.ExperimentalUuidApi + +class Importer( + private val logger: KLogger, + private val settingsRepository: SettingsRepository, + private val userImageService: UserImageService, + private val sourceRepository: SourceRepository, + private val deviceRepository: DeviceRepository, + private val tieRepository: TieRepository, +) { + @Serializable + data class BackupBase( + val version: Int = 1, + ) + + private val partialJson = Json { ignoreUnknownKeys = true } + + private fun getType(name: String) = + when { + name.endsWith(".png") -> "image/png" + name.endsWith(".jpg") || name.endsWith(".jpeg") -> "image/jpeg" + name.endsWith(".svg") -> "image/svg+xml" + name.endsWith(".gif") -> "image/gif" + else -> "application/octet-stream" + } + + suspend operator fun invoke(path: Path) = + withContext(Dispatchers.IO) { + val tempPath = + checkNotNull(Files.createTempDirectory("bridgecmdr-import-${path.fileName}")) { + "Failed to create temp directory for import" + } + + // Read the ZIP file and extract its contents. This should be all the images, and the configuration file. + val imageCache = mutableMapOf() + val zipStream = ZipInputStream(path.inputStream()) + val backup = + zipStream.use { + var rawConfig: String? = null + var backupBase: BackupBase? = null + while (true) { + val entry = zipStream.nextEntry ?: break + AutoCloseable { zipStream.closeEntry() }.use closeEntry@{ + if (entry.isDirectory) return@closeEntry + + when { + // Loading the configuration. + entry.name == "config.json" -> { + rawConfig = zipStream.readBytes().toString(Charsets.UTF_8) + backupBase = partialJson.decodeFromString(rawConfig) + } + + // Saving the images to image cache. + else -> { + imageCache[entry.name] = zipStream.readBytes() + } + } + } + } + + checkNotNull(rawConfig) { "Failed to find configuration information from import file" } + checkNotNull(backupBase) { "Failed to find configuration information from import file" } + + logger.info { "Importing backup version ${backupBase.version}" } + when (backupBase.version) { + 1 -> Version1.parseBackup(rawConfig) + 2 -> Version2.parseBackup(rawConfig) + 3 -> Version3.parseBackup(rawConfig) + 4 -> Version4.parseBackup(rawConfig) + 5 -> Version5.parseBackup(rawConfig) + else -> throw NotImplementedError("Unsupported backup version: ${backupBase.version}") + } + } + + // Import images first, associated with their names for referencing in sources. + val images = + imageCache.mapValues { (name, data) -> + userImageService.upsert(UserImage.New(data = data, type = getType(name))) + } + + // Import sources, run sequentially to prevent SQLite exclusive lock errors. + for (source in backup.layouts.sources) { + runCatching { + sourceRepository.upsert( + Source( + id = source.id, + title = source.title, + image = source.image?.let { name -> images[name]?.id }, + ), + ) + }.onFailure { throwable -> logger.error(throwable) { "Failed to import Source ${source.title}" } } + } + + // Import devices, run sequentially to prevent SQLite exclusive lock errors. + for (device in backup.layouts.devices) { + runCatching { + deviceRepository.upsert( + Device( + id = device.id, + driverId = device.driverId, + title = device.title, + path = device.path, + ), + ) + }.onFailure { throwable -> logger.error(throwable) { "Failed to import device ${device.title}" } } + } + + // Import ties, run sequentially to prevent SQLite exclusive lock errors. + for (tie in backup.layouts.ties) { + runCatching { + tieRepository.upsert( + Tie( + id = tie.id, + sourceId = tie.sourceId, + deviceId = tie.deviceId, + inputChannel = tie.inputChannel, + outputVideoChannel = tie.outputVideoChannel, + outputAudioChannel = tie.outputAudioChannel, + ), + ) + }.onFailure { throwable -> logger.error(throwable) { "Failed to import Tie ${tie.id}" } } + } + + // Finally, import the settings. + settingsRepository.apply { + backup.settings?.let { settings -> + setAppTheme(settings.appTheme) + setIconSize(settings.iconSize) + setPowerOnDevicesAtStart(settings.powerOnDevicesAtStart) + setPowerOffTaps(settings.powerOffTaps) + setButtonOrder(settings.buttonOrder) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/BackupParser.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/BackupParser.kt new file mode 100644 index 0000000..1eda25c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/BackupParser.kt @@ -0,0 +1,5 @@ +package org.sleepingcats.bridgecmdr.common.backup.model + +interface BackupParser { + fun parseBackup(raw: String): LatestVersion +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version1.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version1.kt new file mode 100644 index 0000000..0f9e494 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version1.kt @@ -0,0 +1,97 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object Version1 : BackupParser { + @Serializable + data class Source( + @SerialName("_id") + val id: Uuid, + val title: String, + val image: String?, + ) + + @Serializable + data class Switch( + @SerialName("_id") + val id: Uuid, + val driverId: Uuid, + val title: String, + val path: String, + ) + + @Serializable + data class Tie( + @SerialName("_id") + val id: Uuid, + val sourceId: Uuid, + val switchId: Uuid, + val inputChannel: Int, + val outputChannels: OutputChannels = OutputChannels(), + ) { + @Serializable + data class OutputChannels( + val video: Int? = null, + val audio: Int? = null, + ) + } + + @Serializable + data class Backup( + val version: Int = 1, + val sources: List, + val switches: List, + val ties: List, + ) + + fun upgradeBackup(backup: Backup) = + Version5.Backup( + version = 5, + settings = + Version5.Settings( + buttonOrder = backup.sources.map { it.id }, + ), + layouts = + Version4.Layouts( + sources = + backup.sources.map { + Version4.Source( + id = it.id, + title = it.title, + image = it.image, + ) + }, + devices = + backup.switches.map { + Version4.Device( + id = it.id, + driverId = it.driverId, + title = it.title, + path = it.path, + ) + }, + ties = + backup.ties.map { + Version4.Tie( + id = it.id, + sourceId = it.sourceId, + deviceId = it.switchId, + inputChannel = it.inputChannel, + outputVideoChannel = it.outputChannels.video, + outputAudioChannel = it.outputChannels.audio, + ) + }, + ), + ) + + override fun parseBackup(raw: String) = + upgradeBackup( + Json.decodeFromString(raw).also { check(it.version == 1) { "Wrong backup version, expected 1" } }, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version2.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version2.kt new file mode 100644 index 0000000..90b5760 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version2.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import kotlin.uuid.ExperimentalUuidApi +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps as NewPowerOffTaps + +object Version2 : BackupParser { + @Serializable + enum class ColorScheme( + val newValue: AppTheme, + ) { + @SerialName("no-preference") + NoPreference(AppTheme.System), + + @SerialName("light") + Light(AppTheme.Light), + + @SerialName("dark") + Dark(AppTheme.Dark), + } + + @Serializable + enum class PowerOffTaps( + val newValue: NewPowerOffTaps, + ) { + @SerialName("single") + Single(NewPowerOffTaps.Single), + + @SerialName("double") + Double(NewPowerOffTaps.Double), + } + + @Serializable + data class Settings( + val iconSize: Int = 128, + val colorScheme: ColorScheme = ColorScheme.NoPreference, + val powerOnSwitchesAtStart: Boolean = false, + val powerOffWhen: PowerOffTaps = PowerOffTaps.Single, + ) + + @Serializable + data class Layouts( + val sources: List, + val switches: List, + val ties: List, + ) + + @Serializable + data class Backup( + val version: Int, + val settings: Settings = Settings(), + val layouts: Layouts, + ) + + fun upgradeBackup(backup: Backup) = + Version5.Backup( + version = 5, + settings = + Version5.Settings( + appTheme = backup.settings.colorScheme.newValue, + iconSize = IconSize.entries.find { entry -> entry.size == backup.settings.iconSize } ?: IconSize.Normal, + powerOnDevicesAtStart = backup.settings.powerOnSwitchesAtStart, + powerOffTaps = backup.settings.powerOffWhen.newValue, + buttonOrder = backup.layouts.sources.map { it.id }, + ), + layouts = + Version4.Layouts( + sources = + backup.layouts.sources.map { + Version4.Source( + id = it.id, + title = it.title, + image = it.image, + ) + }, + devices = + backup.layouts.switches.map { + Version4.Device( + id = it.id, + driverId = it.driverId, + title = it.title, + path = it.path, + ) + }, + ties = + backup.layouts.ties.map { + Version4.Tie( + id = it.id, + sourceId = it.sourceId, + deviceId = it.switchId, + inputChannel = it.inputChannel, + outputVideoChannel = it.outputChannels.video, + outputAudioChannel = it.outputChannels.audio, + ) + }, + ), + ) + + override fun parseBackup(raw: String) = + upgradeBackup( + Json.decodeFromString(raw).also { + check(it.version == 2) { "Wrong backup version, expected 2" } + }, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version3.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version3.kt new file mode 100644 index 0000000..bf877ce --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version3.kt @@ -0,0 +1,114 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object Version3 : BackupParser { + @Serializable + data class Source( + @SerialName("_id") + val id: Uuid, + val order: Int = 0, + val title: String, + val image: String? = null, + ) + + @Serializable + data class Device( + @SerialName("_id") + val id: Uuid, + val driverId: Uuid, + val title: String, + val path: String, + ) + + @Serializable + data class Tie( + @SerialName("_id") + val id: Uuid, + val sourceId: Uuid, + val deviceId: Uuid, + val inputChannel: Int, + val outputChannels: OutputChannels = OutputChannels(), + ) { + @Serializable + data class OutputChannels( + val video: Int? = null, + val audio: Int? = null, + ) + } + + @Serializable + data class Layouts( + val sources: List, + val devices: List, + val ties: List, + ) + + @Serializable + data class Backup( + val version: Int, + val settings: Version2.Settings = Version2.Settings(), + val layouts: Layouts, + ) + + fun upgradeBackup(backup: Backup) = + Version5.Backup( + version = 5, + settings = + Version5.Settings( + appTheme = backup.settings.colorScheme.newValue, + iconSize = IconSize.entries.find { entry -> entry.size == backup.settings.iconSize } ?: IconSize.Normal, + powerOnDevicesAtStart = backup.settings.powerOnSwitchesAtStart, + powerOffTaps = backup.settings.powerOffWhen.newValue, + buttonOrder = + backup.layouts.sources + .sortedBy { it.order } + .map { it.id }, + ), + layouts = + Version4.Layouts( + sources = + backup.layouts.sources.map { + Version4.Source( + id = it.id, + title = it.title, + image = it.image, + ) + }, + devices = + backup.layouts.devices.map { + Version4.Device( + id = it.id, + driverId = it.driverId, + title = it.title, + path = it.path, + ) + }, + ties = + backup.layouts.ties.map { + Version4.Tie( + id = it.id, + sourceId = it.sourceId, + deviceId = it.deviceId, + inputChannel = it.inputChannel, + outputVideoChannel = it.outputChannels.video, + outputAudioChannel = it.outputChannels.audio, + ) + }, + ), + ) + + override fun parseBackup(raw: String) = + upgradeBackup( + Json.decodeFromString(raw).also { + check(it.version == 3) { "Wrong backup version, expected 3" } + }, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version4.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version4.kt new file mode 100644 index 0000000..823e778 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version4.kt @@ -0,0 +1,82 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.backup.model.Version2.ColorScheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object Version4 : BackupParser { + @Serializable + data class Source( + val id: Uuid, + val title: String, + val image: String? = null, + ) + + @Serializable + data class Device( + val id: Uuid, + val driverId: Uuid, + val title: String, + val path: String, + ) + + @Serializable + data class Tie( + val id: Uuid, + val sourceId: Uuid, + val deviceId: Uuid, + val inputChannel: Int, + val outputVideoChannel: Int? = 0, + val outputAudioChannel: Int? = 0, + ) + + @Serializable + data class Layouts( + val sources: List, + val devices: List, + val ties: List, + ) + + @Serializable + data class Settings( + val iconSize: Int = 128, + val colorScheme: ColorScheme = ColorScheme.NoPreference, + val powerOnSwitchesAtStart: Boolean = false, + val powerOffWhen: Version2.PowerOffTaps = Version2.PowerOffTaps.Single, + val buttonOrder: List = emptyList(), + ) + + @Serializable + data class Backup( + val version: Int, + val settings: Settings = Settings(), + val layouts: Layouts, + ) + + fun upgradeBackup(backup: Backup) = + Version5.Backup( + version = 5, + settings = + Version5.Settings( + appTheme = backup.settings.colorScheme.newValue, + iconSize = IconSize.entries.find { entry -> entry.size == backup.settings.iconSize } ?: IconSize.Normal, + powerOnDevicesAtStart = backup.settings.powerOnSwitchesAtStart, + powerOffTaps = backup.settings.powerOffWhen.newValue, + buttonOrder = backup.settings.buttonOrder, + ), + layouts = backup.layouts, + ) + + override fun parseBackup(raw: String): Version5.Backup = + upgradeBackup( + Json.decodeFromString(raw).also { + check(it.version == 4) { "Wrong backup version, expected 4" } + }, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version5.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version5.kt new file mode 100644 index 0000000..6a39149 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version5.kt @@ -0,0 +1,34 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.backup.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object Version5 : BackupParser { + @Serializable + data class Settings( + val appTheme: AppTheme = AppTheme.System, + val iconSize: IconSize = IconSize.Normal, + val powerOnDevicesAtStart: Boolean = false, + val powerOffTaps: PowerOffTaps = PowerOffTaps.Single, + val buttonOrder: List = emptyList(), + ) + + @Serializable + data class Backup( + val version: Int, + val settings: Settings? = null, + val layouts: Version4.Layouts, + ) + + override fun parseBackup(raw: String): Backup = + Json.decodeFromString(raw).also { + check(it.version == 5) { "Wrong backup version, expected 5" } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverBuilder.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverBuilder.kt new file mode 100644 index 0000000..2b188ed --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverBuilder.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.core + +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DriverBuilder : KoinComponent { + var id: Uuid? = null + var kind: DriverKind? = null + var title: String? = null + var company: String? = null + var provider: String? = null + var enabled: Boolean = true + var experimental: Boolean = false + var capabilities: Int = Capabilities.NONE + var protocol: DriverProtocol? = null +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverModule.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverModule.kt new file mode 100644 index 0000000..3a2d6f5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverModule.kt @@ -0,0 +1,27 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.core + +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import kotlin.uuid.ExperimentalUuidApi + +class DriverModule( + builder: DriverBuilder, +) : Driver( + checkNotNull(builder.id) { "Driver ID must be provided" }, + checkNotNull(builder.kind) { "Driver kind must be provided" }, + checkNotNull(builder.title) { "Driver title must be provided" }, + checkNotNull(builder.company) { "Driver company must be provided" }, + checkNotNull(builder.provider) { "Driver provider must be provided" }, + builder.enabled, + builder.experimental, + builder.capabilities, + ), + DriverProtocol by checkNotNull(builder.protocol, { "Driver protocol must be provided" }), + KoinComponent { + companion object { + fun define(builder: suspend DriverBuilder.() -> Unit): suspend () -> DriverModule = + { DriverModule(DriverBuilder().apply { this.builder() }) } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverProtocol.kt new file mode 100644 index 0000000..c0a9905 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverProtocol.kt @@ -0,0 +1,17 @@ +package org.sleepingcats.bridgecmdr.common.core + +import kotlinx.coroutines.Deferred +import org.koin.core.component.get + +interface DriverProtocol { + suspend fun powerOn(uri: String): Deferred + + suspend fun powerOff(uri: String): Deferred + + suspend fun activate( + uri: String, + input: Int, + videoOutput: Int = input, + audioOutput: Int = videoOutput, + ): Deferred +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/extron/ExtronSisDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/extron/ExtronSisDriver.kt new file mode 100644 index 0000000..59cd663 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/extron/ExtronSisDriver.kt @@ -0,0 +1,27 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.extron + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_extron +import bridgecmdr.composeapp.generated.resources.driver_extron_sis_matrix +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.ExtronSisProtocol +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val ExtronSisDriver = + DriverModule.define { + id = Uuid.parseHexDash("4C8F2838-C91D-431E-84DD-3666D14A6E2C") + kind = DriverKind.Switch + title = getString(Res.string.driver_extron_sis_matrix) + company = getString(Res.string.company_extron) + provider = getString(Res.string.internal_contributor) + capabilities = Capabilities.MULTIPLE_OUTPUTS or Capabilities.AUDIO_INDEPENDENT + + protocol = ExtronSisProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion2Driver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion2Driver.kt new file mode 100644 index 0000000..174785b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion2Driver.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.shinybow + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_shinybow +import bridgecmdr.composeapp.generated.resources.driver_shinybow_v2_0_matrix +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.ShinybowProtocol +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val ShinybowVersion2Driver = + DriverModule.define { + id = Uuid.parseHexDash("75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C") + kind = DriverKind.Switch + title = getString(Res.string.driver_shinybow_v2_0_matrix) + company = getString(Res.string.company_shinybow) + provider = getString(Res.string.internal_contributor) + experimental = true + capabilities = Capabilities.MULTIPLE_OUTPUTS + + protocol = ShinybowProtocol(2) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion3Driver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion3Driver.kt new file mode 100644 index 0000000..b2e1fe3 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion3Driver.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.shinybow + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_shinybow +import bridgecmdr.composeapp.generated.resources.driver_shinybow_v3_0_matrix +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.ShinybowProtocol +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val ShinybowVersion3Driver = + DriverModule.define { + id = Uuid.parseHexDash("BBED08A1-C749-4733-8F2E-96C9B56C0C41") + kind = DriverKind.Switch + title = getString(Res.string.driver_shinybow_v3_0_matrix) + company = getString(Res.string.company_shinybow) + provider = getString(Res.string.internal_contributor) + experimental = true + capabilities = Capabilities.MULTIPLE_OUTPUTS or Capabilities.AUDIO_INDEPENDENT + + protocol = ShinybowProtocol(3) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/sony/SonyRs485Driver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/sony/SonyRs485Driver.kt new file mode 100644 index 0000000..cd96f78 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/sony/SonyRs485Driver.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.sony + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_sony +import bridgecmdr.composeapp.generated.resources.driver_sony_rs_485_monitor +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.SonyProtocol +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val SonyRs485Driver = + DriverModule.define { + id = Uuid.parseHexDash("8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0") + kind = DriverKind.Monitor + title = getString(Res.string.driver_sony_rs_485_monitor) + company = getString(Res.string.company_sony) + provider = getString(Res.string.internal_contributor) + + protocol = SonyProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartKvmDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartKvmDriver.kt new file mode 100644 index 0000000..827d4d0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartKvmDriver.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.teslasmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_telsasmart_kvm +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecKvmProtocol +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeslaSmartKvmDriver = + DriverModule.define { + id = Uuid.parseHexDash("91D5BC95-A8E2-4F58-BCAC-A77BA1054D61") + kind = DriverKind.Switch + title = getString(Res.string.driver_telsasmart_kvm) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + + protocol = TeslaElecKvmProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartMatrixDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartMatrixDriver.kt new file mode 100644 index 0000000..58444ab --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartMatrixDriver.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.teslasmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_telsasmart_matrix +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecMatrixProtocol +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeslaSmartMatrixDriver = + DriverModule.define { + id = Uuid.parseHexDash("671824ED-0BC4-43A6-85CC-4877890A7722") + kind = DriverKind.Switch + title = getString(Res.string.driver_telsasmart_matrix) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + capabilities = Capabilities.MULTIPLE_OUTPUTS + + protocol = TeslaElecMatrixProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartSdiDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartSdiDriver.kt new file mode 100644 index 0000000..8ae6960 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartSdiDriver.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.teslasmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_telsasmart_sdi +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecSdiProtocol +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeslaSmartSdiDriver = + DriverModule.define { + id = Uuid.parseHexDash("DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD") + kind = DriverKind.Switch + title = getString(Res.string.driver_telsasmart_sdi) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + + protocol = TeslaElecSdiProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartKvmDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartKvmDriver.kt new file mode 100644 index 0000000..701d95e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartKvmDriver.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.tesmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_tesmart_kvm +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecKvmProtocol +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeSmartSmartKvmDriver = + DriverModule.define { + id = Uuid.parseHexDash("2B4EDB8E-D2D6-4809-BA18-D5B1785DA028") + kind = DriverKind.Switch + title = getString(Res.string.driver_tesmart_kvm) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + + protocol = TeslaElecKvmProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartMatrixDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartMatrixDriver.kt new file mode 100644 index 0000000..a965ab1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartMatrixDriver.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.tesmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_tesmart_matrix +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecMatrixProtocol +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeSmartSmartMatrixDriver = + DriverModule.define { + id = Uuid.parseHexDash("01B8884C-1D7D-4451-883D-3C8F18E17B14") + kind = DriverKind.Switch + title = getString(Res.string.driver_tesmart_matrix) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + capabilities = Capabilities.MULTIPLE_OUTPUTS + + protocol = TeslaElecMatrixProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartSdiDriver.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartSdiDriver.kt new file mode 100644 index 0000000..7287865 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartSdiDriver.kt @@ -0,0 +1,26 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.driver.tesmart + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.company_teslaelec +import bridgecmdr.composeapp.generated.resources.driver_tesmart_sdi +import bridgecmdr.composeapp.generated.resources.internal_contributor +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecSdiProtocol +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +val TeSmartSmartSdiDriver = + DriverModule.define { + id = Uuid.parseHexDash("8C524E65-83EF-4AEF-B0DA-29C4582AA4A0") + kind = DriverKind.Switch + title = getString(Res.string.driver_tesmart_sdi) + company = getString(Res.string.company_teslaelec) + provider = getString(Res.string.internal_contributor) + experimental = true + + protocol = TeslaElecSdiProtocol() + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteArray.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteArray.kt new file mode 100644 index 0000000..dc92a53 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteArray.kt @@ -0,0 +1,24 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorAlphaType.PREMUL +import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.ColorSpace.Companion.sRGB +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo + +fun ByteArray.toImage( + width: Int, + height: Int, + alphaType: ColorAlphaType = PREMUL, + colorSpace: ColorSpace = sRGB, +): Image = Image.makeRaster(ImageInfo.makeN32(width, height, alphaType, colorSpace), this, width * 4) + +fun ByteArray.toImageBitmap( + width: Int, + height: Int, + alphaType: ColorAlphaType = PREMUL, + colorSpace: ColorSpace = sRGB, +): ImageBitmap = toImage(width, height, alphaType, colorSpace).toComposeImageBitmap() diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteMatrix.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteMatrix.kt new file mode 100644 index 0000000..7b263d9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteMatrix.kt @@ -0,0 +1,33 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toComposeImageBitmap +import com.google.zxing.common.BitMatrix +import org.jetbrains.skia.Image + +fun BitMatrix.toImage( + background: Color = Color.White, + color: Color = Color.Black, +): Image { + val bytes = ByteArray(4 * width * height) + + var offset = 0 + for (y in 0 until height) { + for (x in 0 until width) { + val color = if (get(x, y)) color.toArgb() else background.toArgb() + bytes[offset++] = (color shr 16 and 0xFF).toByte() // R + bytes[offset++] = (color shr 8 and 0xFF).toByte() // G + bytes[offset++] = (color and 0xFF).toByte() // B + bytes[offset++] = (color shr 24 and 0xFF).toByte() // A + } + } + + return bytes.toImage(width, height) +} + +fun BitMatrix.toImageBitmap( + background: Color = Color.White, + color: Color = Color.Black, +): ImageBitmap = toImage(background, color).toComposeImageBitmap() diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/FileKitResultLauncher.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/FileKitResultLauncher.kt new file mode 100644 index 0000000..815d366 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/FileKitResultLauncher.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import io.github.vinceglb.filekit.dialogs.compose.PickerResultLauncher + +operator fun PickerResultLauncher.invoke() { + this.launch() +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Table.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Table.kt new file mode 100644 index 0000000..6125b9b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Table.kt @@ -0,0 +1,15 @@ +package org.sleepingcats.bridgecmdr.common.extension + +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder +import org.jetbrains.exposed.v1.jdbc.insertIgnoreAndGetId +import org.jetbrains.exposed.v1.jdbc.select + +fun > T.insertOrGetId( + predicate: T.() -> Op, + body: T.(UpdateBuilder<*>) -> Unit, +) = insertIgnoreAndGetId(body) ?: select(id) + .where { predicate() } + .firstOrNull() + ?.get(id) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/DatabaseModule.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/DatabaseModule.kt new file mode 100644 index 0000000..589fb71 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/DatabaseModule.kt @@ -0,0 +1,12 @@ +package org.sleepingcats.bridgecmdr.common.module + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.migration.MigrationService + +var databaseModule = + module { + singleOf(::MigrationService) + singleOf(::DatabaseService) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/LocalServiceModule.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/LocalServiceModule.kt new file mode 100644 index 0000000..7ff7be1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/LocalServiceModule.kt @@ -0,0 +1,35 @@ +package org.sleepingcats.bridgecmdr.common.module + +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.binds +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.bridgecmdr.common.service.LegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.PowerService +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.local.LocalDeviceService +import org.sleepingcats.bridgecmdr.common.service.local.LocalDriverService +import org.sleepingcats.bridgecmdr.common.service.local.LocalLegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.local.LocalPowerService +import org.sleepingcats.bridgecmdr.common.service.local.LocalSerialPortService +import org.sleepingcats.bridgecmdr.common.service.local.LocalSourceService +import org.sleepingcats.bridgecmdr.common.service.local.LocalTieService +import org.sleepingcats.bridgecmdr.common.service.local.LocalUserImageService + +val localServiceModule: Module = + module { + singleOf(::LocalDriverService) binds arrayOf(LocalDriverService::class, DriverService::class) + singleOf(::LocalDeviceService) binds arrayOf(LocalDeviceService::class, DeviceService::class) + singleOf(::LocalLegacySettingsService) binds + arrayOf(LocalLegacySettingsService::class, LegacySettingsService::class) + singleOf(::LocalPowerService) binds arrayOf(LocalPowerService::class, PowerService::class) + singleOf(::LocalSerialPortService) binds arrayOf(LocalSerialPortService::class, SerialPortService::class) + singleOf(::LocalSourceService) binds arrayOf(LocalSourceService::class, SourceService::class) + singleOf(::LocalTieService) binds arrayOf(LocalTieService::class, TieService::class) + singleOf(::LocalUserImageService) binds arrayOf(LocalUserImageService::class, UserImageService::class) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ExtronSisProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ExtronSisProtocol.kt new file mode 100644 index 0000000..2ddedfd --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ExtronSisProtocol.kt @@ -0,0 +1,25 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol + +class ExtronSisProtocol : AbstractDriverProtocol("extron/sis") { + private val stream = ProtocolStream.create(name) + + suspend fun sendCommand( + uri: String, + command: String, + ) = stream.sendCommand(uri, command) + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + logger.info { "$name/tie($input, $videoOutput, $audioOutput) -> $uri" } + val videoCommand = "$input*$videoOutput%" + val audioCommand = "$input*$audioOutput$" + sendCommand(uri, "$videoCommand\r\n$audioCommand\r\n") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt new file mode 100644 index 0000000..79e7340 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt @@ -0,0 +1,53 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol + +class ShinybowProtocol( + version: Int, +) : AbstractDriverProtocol("shinybow/v$version.0") { + private val stream = ProtocolStream.create(name) + + private val argRange by lazy { + when (version) { + 2 -> 1..99 + 3 -> 1..999 + else -> throw IllegalArgumentException("Unsupported Shinybow protocol version: $version") + } + } + + private val toArg: (input: Int) -> String by lazy { + when (version) { + 2 -> { input -> input.toString().padStart(2, '0') } + 3 -> { input -> input.toString().padStart(3, '0') } + else -> throw IllegalArgumentException("Unsupported Shinybow protocol version: $version") + } + } + + suspend fun sendCommand( + uri: String, + command: String, + ) = stream.sendCommand(uri, command) + + override suspend fun sendPowerOn(uri: String) { + logger.info { "$name/powerOn -> $uri" } + sendCommand(uri, "POWER ${toArg(1)};\r\n") + } + + override suspend fun sendPowerOff(uri: String) { + logger.info { "$name/powerOff -> $uri" } + sendCommand(uri, "POWER ${toArg(0)};\r\n") + } + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + require(input in argRange) { "Input out of range ($argRange): $input" } + require(videoOutput in argRange) { "Input out of range ($argRange): $input" } + logger.info { "$name/tie($input, $videoOutput) -> $uri" } + sendCommand(uri, "OUTPUT${toArg(videoOutput)} ${toArg(input)};\r\n") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt new file mode 100644 index 0000000..9ef1fa0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt @@ -0,0 +1,59 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.Parity +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolOptions +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.Address +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.AddressKind +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.Command + +class SonyProtocol : AbstractDriverProtocol("sony/bvm") { + private val stream = + ProtocolStream.create( + name, + options = + ProtocolOptions( + baudRate = 38400, + parity = Parity.Odd, + ), + ) + + private suspend fun sendCommand( + uri: String, + command: Command, + arg0: Int? = null, + arg1: Int? = null, + ) { + val packet = + Command.createPacket( + destination = Address.create(AddressKind.All, 0), + source = Address.create(AddressKind.All, 0), + command = command, + arg0 = arg0, + arg1 = arg1, + ) + stream.sendCommand(uri, packet.bytes) + } + + override suspend fun sendPowerOn(uri: String) { + logger.info { "$name/powerOn -> $uri" } + sendCommand(uri, Command.PowerOn) + } + + override suspend fun sendPowerOff(uri: String) { + logger.info { "$name/powerOff -> $uri" } + sendCommand(uri, Command.PowerOff) + } + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + require(input in 1..255) { "Input out of range (1..255): $input" } + logger.info { "$name/channel($input) -> $uri" } + sendCommand(uri, Command.SetChannel, 1, input) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt new file mode 100644 index 0000000..4d8208e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt @@ -0,0 +1,25 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol + +class TeslaElecKvmProtocol : AbstractDriverProtocol("teslaElec/kvm") { + private val stream = ProtocolStream.create(name) + + private suspend fun sendCommand( + uri: String, + command: ByteArray, + ) = stream.sendCommand(uri, command) + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + require(input in 1..255) { "Input out of range (1..255): $input" } + logger.info { "$name/switch($input) -> $uri" } + val command = byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0x03, 0x01, input.toByte(), 0xee.toByte()) + sendCommand(uri, command) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt new file mode 100644 index 0000000..7a3a211 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt @@ -0,0 +1,27 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol + +class TeslaElecMatrixProtocol : AbstractDriverProtocol("teslaElec/matrix") { + private val stream = ProtocolStream.create(name) + + private fun toArg(input: Int) = input.toString().padStart(2, '0') + + private suspend fun sendCommand( + uri: String, + command: String, + ) = stream.sendCommand(uri, command) + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + require(input in 1..99) { "Input out of range (1..99): $input" } + require(videoOutput in 1..99) { "Input out of range (1..99): $input" } + logger.info { "$name/tie($input, $videoOutput) -> $uri" } + sendCommand(uri, "MT00SW${toArg(input)}${toArg(videoOutput)}NT\r\n") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt new file mode 100644 index 0000000..793abab --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt @@ -0,0 +1,24 @@ +package org.sleepingcats.bridgecmdr.common.protocol + +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import org.sleepingcats.bridgecmdr.common.protocol.support.AbstractDriverProtocol + +class TeslaElecSdiProtocol : AbstractDriverProtocol("teslaElec/sdi") { + private val stream = ProtocolStream.create(name) + + private suspend fun sendCommand( + uri: String, + command: ByteArray, + ) = stream.sendCommand(uri, command) + + override suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) { + require(input in 1..255) { "Input out of range (1..255): $input" } + logger.info { "$name/channel($input) -> $uri" } + sendCommand(uri, byteArrayOf(0xaa.toByte(), 0xcc.toByte(), 0x01, input.toByte())) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/AbstractCommandStream.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/AbstractCommandStream.kt new file mode 100644 index 0000000..c49f0df --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/AbstractCommandStream.kt @@ -0,0 +1,27 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +abstract class AbstractCommandStream : CommandStream { + private val listeners = + object { + val data = mutableSetOf<(ByteArray) -> Unit>() + val error = mutableSetOf<(Throwable) -> Unit>() + } + + protected fun emitData(data: ByteArray) { + listeners.data.forEach { listener -> listener(data) } + } + + protected fun emitError(cause: Throwable) { + listeners.error.forEach { listener -> listener(cause) } + } + + override fun onData(listener: (ByteArray) -> Unit): () -> Unit { + listeners.data.add(listener) + return { listeners.data.remove(listener) } + } + + override fun onError(listener: (Throwable) -> Unit): () -> Unit { + listeners.error.add(listener) + return { listeners.error.remove(listener) } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStream.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStream.kt new file mode 100644 index 0000000..cc4b7d0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStream.kt @@ -0,0 +1,68 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import com.fazecast.jSerialComm.SerialPort +import com.fazecast.jSerialComm.SerialPort.TIMEOUT_READ_SEMI_BLOCKING +import com.fazecast.jSerialComm.SerialPort.TIMEOUT_WRITE_BLOCKING +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import org.sleepingcats.bridgecmdr.common.service.model.PathType +import java.nio.charset.Charset + +interface CommandStream : AutoCloseable { + companion object { + val selector = SelectorManager(Dispatchers.IO) + + val hostWithOptionalPortPattern = Regex("^((\\[[A-Fa-f0-9.:]+])|([\\p{N}\\p{L}.-]+))(?::([1-9][0-9]*))?$") + + private fun createSerial( + path: String, + options: CommandStreamOptions, + ): CommandStream { + val port = + SerialPort.getCommPorts().find { it.systemPortPath == path } + ?: throw IllegalArgumentException("Unknown path: $path") + + port.baudRate = options.baudRate + port.numDataBits = options.dataBits.value + port.numStopBits = options.stopBits.value + port.parity = options.parity.value + port.setComPortTimeouts(TIMEOUT_READ_SEMI_BLOCKING or TIMEOUT_WRITE_BLOCKING, options.timeout, options.timeout) + + return SerialCommandStream(port) + } + + private suspend fun createNet( + hostname: String, + options: CommandStreamOptions, + ): CommandStream { + val matches = hostWithOptionalPortPattern.matchEntire(hostname) + val host = matches?.groupValues?.getOrNull(0) ?: throw IllegalArgumentException("Unknown host: $hostname") + val port = matches.groupValues.getOrNull(1)?.toIntOrNull() ?: 23 + + val socket = aSocket(selector).tcp().connect(host, port) + return NetCommandStream(socket) + } + + suspend fun create( + path: String, + options: CommandStreamOptions, + ): CommandStream = + when { + path.startsWith(PathType.Remote.prefix) -> createNet(path.substring(PathType.Remote.prefix.length), options) + path.startsWith(PathType.Port.prefix) -> createSerial(path.substring(PathType.Port.prefix.length), options) + else -> throw IllegalArgumentException("Unknown path $path") + } + } + + suspend fun write(data: ByteArray): Int + + suspend fun write( + data: String, + charset: Charset = Charsets.US_ASCII, + ): Int = write(data.toByteArray(charset)) + + fun onData(listener: (ByteArray) -> Unit): () -> Unit + + fun onError(listener: (Throwable) -> Unit): () -> Unit +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStreamOptions.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStreamOptions.kt new file mode 100644 index 0000000..bb45a93 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStreamOptions.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +data class CommandStreamOptions( + val baudRate: Int, + val dataBits: DataBits, + val stopBits: StopBits, + val parity: Parity, + val timeout: Int, +) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/DataBits.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/DataBits.kt new file mode 100644 index 0000000..852c676 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/DataBits.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +enum class DataBits( + val value: Int, +) { + Five(5), + Six(6), + Seven(7), + Eight(8), +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/NetCommandStream.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/NetCommandStream.kt new file mode 100644 index 0000000..4a6a248 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/NetCommandStream.kt @@ -0,0 +1,56 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.asByteWriteChannel +import io.ktor.utils.io.copyTo +import io.ktor.utils.io.core.readFully +import io.ktor.utils.io.writeByteArray +import kotlinx.coroutines.launch +import kotlinx.io.Buffer +import kotlinx.io.RawSink + +class NetCommandStream( + private val socket: Socket, +) : AbstractCommandStream() { + private val readChannel = socket.openReadChannel() + private val writeChannel = socket.openWriteChannel() + + private class EventProxy( + private val emitter: (ByteArray) -> Unit, + ) : RawSink { + override fun write( + source: Buffer, + byteCount: Long, + ) { + if (byteCount > Int.MAX_VALUE) throw IllegalArgumentException("byteCount too large: $byteCount") + val buffer = ByteArray(byteCount.toInt()) + source.readFully(buffer) + + emitter(buffer) + } + + override fun flush() { + // Does nothing here. + } + + override fun close() { + // Does nothing here. + } + } + + init { + val proxy = EventProxy(emitter = { data -> emitData(data) }) + socket.launch { readChannel.copyTo(proxy.asByteWriteChannel()) } + } + + override suspend fun write(data: ByteArray): Int { + writeChannel.writeByteArray(data) + return data.size + } + + override fun close() { + socket.close() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/Parity.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/Parity.kt new file mode 100644 index 0000000..363eb5d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/Parity.kt @@ -0,0 +1,17 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import com.fazecast.jSerialComm.SerialPort.EVEN_PARITY +import com.fazecast.jSerialComm.SerialPort.MARK_PARITY +import com.fazecast.jSerialComm.SerialPort.NO_PARITY +import com.fazecast.jSerialComm.SerialPort.ODD_PARITY +import com.fazecast.jSerialComm.SerialPort.SPACE_PARITY + +enum class Parity( + val value: Int, +) { + None(NO_PARITY), + Even(EVEN_PARITY), + Odd(ODD_PARITY), + Mark(MARK_PARITY), + Space(SPACE_PARITY), +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolOptions.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolOptions.kt new file mode 100644 index 0000000..b8186aa --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolOptions.kt @@ -0,0 +1,26 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +open class ProtocolOptions( + val baudRate: Int? = null, + val dataBits: DataBits? = null, + val stopBits: StopBits? = null, + val parity: Parity? = null, + val timeout: Int? = null, +) { + companion object : ProtocolOptions() + + fun orTheseDefaults( + baudRate: Int, + dataBits: DataBits, + stopBits: StopBits, + parity: Parity, + timeout: Int, + ): CommandStreamOptions = + CommandStreamOptions( + baudRate = this.baudRate ?: baudRate, + dataBits = this.dataBits ?: dataBits, + stopBits = this.stopBits ?: stopBits, + parity = this.parity ?: parity, + timeout = this.timeout ?: timeout, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolStream.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolStream.kt new file mode 100644 index 0000000..65e1ee4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolStream.kt @@ -0,0 +1,62 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.Marker +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import java.nio.charset.Charset + +class ProtocolStream( + val name: String, + private val logger: KLogger, + private val options: CommandStreamOptions, +) : KoinComponent { + companion object : KoinComponent { + fun create( + name: String, + logger: KLogger = get(), + options: ProtocolOptions = ProtocolOptions, + ) = ProtocolStream( + name = name, + logger = logger, + options = + options.orTheseDefaults( + baudRate = 9600, + dataBits = DataBits.Eight, + stopBits = StopBits.One, + parity = Parity.None, + timeout = 5000, + ), + ) + } + + private val marker = + object : Marker { + override fun getName() = name + } + + suspend fun sendCommand( + uri: String, + data: ByteArray, + ) { + val stream = CommandStream.create(uri, options) + stream.use { + stream.onData { data -> + logger.debug(throwable = null, marker) { "Received data: ${data.decodeToString()}" } + } + stream.onError { throwable -> + logger.error(throwable, marker) { "Error in command stream" } + } + + stream.write(data) + } + } + + suspend fun sendCommand( + uri: String, + data: String, + charset: Charset = Charsets.US_ASCII, + ) { + sendCommand(uri, data.toByteArray(charset)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/SerialCommandStream.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/SerialCommandStream.kt new file mode 100644 index 0000000..0c561c8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/SerialCommandStream.kt @@ -0,0 +1,38 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import com.fazecast.jSerialComm.SerialPort +import com.fazecast.jSerialComm.SerialPortDataListener +import com.fazecast.jSerialComm.SerialPortEvent +import kotlinx.io.IOException + +class SerialCommandStream( + private val port: SerialPort, +) : AbstractCommandStream() { + init { + port.openPort() || throw IOException("Failed to open ${port.systemPortPath}") + port.addDataListener( + object : SerialPortDataListener { + override fun getListeningEvents(): Int = SerialPort.LISTENING_EVENT_DATA_AVAILABLE + + override fun serialEvent(event: SerialPortEvent?) { + if (event?.eventType != SerialPort.LISTENING_EVENT_DATA_AVAILABLE) return + + val availableBytes = port.bytesAvailable() + if (availableBytes == 0) return + + val buffer = ByteArray(availableBytes) + val numRead = port.readBytes(buffer, buffer.size) + if (numRead == 0) return + + emitData(buffer) + } + }, + ) + } + + override suspend fun write(data: ByteArray): Int = port.writeBytes(data, data.size) + + override fun close() { + port.closePort() || throw Exception("Failed to close port at path: ${port.systemPortPath}") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/StopBits.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/StopBits.kt new file mode 100644 index 0000000..4997172 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/StopBits.kt @@ -0,0 +1,13 @@ +package org.sleepingcats.bridgecmdr.common.protocol.stream + +import com.fazecast.jSerialComm.SerialPort.ONE_POINT_FIVE_STOP_BITS +import com.fazecast.jSerialComm.SerialPort.ONE_STOP_BIT +import com.fazecast.jSerialComm.SerialPort.TWO_STOP_BITS + +enum class StopBits( + val value: Int, +) { + One(ONE_STOP_BIT), + OnePointFive(ONE_POINT_FIVE_STOP_BITS), + Two(TWO_STOP_BITS), +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/AbstractDriverProtocol.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/AbstractDriverProtocol.kt new file mode 100644 index 0000000..7023402 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/AbstractDriverProtocol.kt @@ -0,0 +1,70 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeout +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.sleepingcats.bridgecmdr.common.core.DriverProtocol +import kotlin.getValue + +abstract class AbstractDriverProtocol( + protected val name: String, +) : DriverProtocol, + KoinComponent { + companion object { + /** Timeout for local driver commands, should be shorter than any remote client timeout. */ + private const val TIMEOUT = 2_000L + } + + protected val logger: KLogger by inject() + + open suspend fun sendPowerOn(uri: String) { + logger.info { "$name/powerOn; no-op" } + // Most protocols do not require power on + } + + open suspend fun sendPowerOff(uri: String) { + logger.info { "$name/powerOff; no-op" } + // Most protocols do not require power off + } + + abstract suspend fun sendActivate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) + + final override suspend fun powerOn(uri: String) = + coroutineScope { + async { + runCatching { withTimeout(TIMEOUT) { sendPowerOn(uri) } } + .onFailure { logger.error(it) { "$name/powerOff: failed to send power on" } } + .getOrDefault(Unit) + } + } + + final override suspend fun powerOff(uri: String) = + coroutineScope { + async { + runCatching { withTimeout(TIMEOUT) { sendPowerOff(uri) } } + .onFailure { logger.error(it) { "$name/powerOff: failed to send power off" } } + .getOrDefault(Unit) + } + } + + final override suspend fun activate( + uri: String, + input: Int, + videoOutput: Int, + audioOutput: Int, + ) = coroutineScope { + async { + runCatching { withTimeout(TIMEOUT) { sendActivate(uri, input, videoOutput, audioOutput) } } + .onFailure { logger.error(it) { "$name/activate: failed to send activate" } } + .getOrDefault(Unit) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Address.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Address.kt new file mode 100644 index 0000000..eec24b7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Address.kt @@ -0,0 +1,16 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +@JvmInline +value class Address private constructor( + val byte: Byte, +) { + companion object { + fun create( + kind: AddressKind, + id: Int, + ): Address { + require(id in 0x00..0x1f) { "Address ID must be between 0x00 and 0x1f" } + return Address((kind.byte.toInt() or id).toByte()) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/AddressKind.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/AddressKind.kt new file mode 100644 index 0000000..3db60cf --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/AddressKind.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +enum class AddressKind( + val byte: Byte, +) { + All(0xc0.toByte()), + Group(0x80.toByte()), + Monitor(0x00), +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Command.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Command.kt new file mode 100644 index 0000000..dde61cb --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Command.kt @@ -0,0 +1,37 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +enum class Command( + val bytes: ByteArray, +) { + SetChannel(0x2100), + PowerOn(0x293e), + PowerOff(0x2a3e), + PressButton(0x3f44), + ; + + companion object { + fun createPacket( + destination: Address, + source: Address, + command: Command, + arg0: Int? = null, + arg1: Int? = null, + ): Packet { + require(arg0 == null || (arg0 in 0x00..0xff)) { "arg0 must be between 0x00 and 0xff" } + require(arg1 == null || (arg1 in 0x00..0xff)) { "arg1 must be between 0x00 and 0xff" } + + val bytes = byteArrayOf(destination.byte) + source.byte + command.bytes + if (arg0 == null) return Packet.create(PacketType.Command, bytes) + if (arg1 == null) return Packet.create(PacketType.Command, bytes + arg0.toByte()) + return Packet.create(PacketType.Command, bytes + arg0.toByte() + arg1.toByte()) + } + } + + constructor(code: Short) : this(toByteArray(code)) +} + +private fun toByteArray(code: Short): ByteArray = + byteArrayOf( + (code.toInt() shr 8).toByte(), + (code.toInt() and 0xff).toByte(), + ) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Packet.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Packet.kt new file mode 100644 index 0000000..0146899 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Packet.kt @@ -0,0 +1,25 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +import kotlin.experimental.inv + +@JvmInline +value class Packet private constructor( + val bytes: ByteArray, +) { + companion object { + private fun calculateChecksum(bytes: ByteArray): Byte { + val x = bytes.fold(0.toByte()) { acc, byte -> (acc + byte).toByte() }.inv() + return (x - (bytes.size - 1)).toByte() + } + + fun create( + type: PacketType, + bytes: ByteArray, + ): Packet { + if (bytes.isEmpty()) throw PacketError("Data cannot be empty") + if (bytes.size > 255) throw PacketError("Data length cannot exceed 255 bytes") + + return Packet(byteArrayOf(type.byte) + bytes.size.toByte() + bytes + calculateChecksum(bytes)) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketError.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketError.kt new file mode 100644 index 0000000..2cd8d7a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketError.kt @@ -0,0 +1,6 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +class PacketError( + message: String = "Unknown packet error", + cause: Throwable? = null, +) : SonyDriverError(message, cause) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketType.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketType.kt new file mode 100644 index 0000000..50ccd8e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketType.kt @@ -0,0 +1,7 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +enum class PacketType( + val byte: Byte, +) { + Command(0x02), +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/SonyDriverError.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/SonyDriverError.kt new file mode 100644 index 0000000..968dc9d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/SonyDriverError.kt @@ -0,0 +1,6 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support.sony + +open class SonyDriverError( + message: String = "Unknown error", + cause: Throwable? = null, +) : Error("[Sony Driver]: $message", cause) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DatabaseService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DatabaseService.kt new file mode 100644 index 0000000..ebf6640 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DatabaseService.kt @@ -0,0 +1,37 @@ +package org.sleepingcats.bridgecmdr.common.service + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import org.jetbrains.exposed.v1.jdbc.Database +import org.sleepingcats.bridgecmdr.Environment +import org.sleepingcats.bridgecmdr.common.service.migration.MigrationService +import kotlin.io.path.createDirectories +import kotlin.io.path.div + +class DatabaseService( + private val logger: KLogger, + private val migrationService: MigrationService, +) { + private val connection = MutableStateFlow(null) + + suspend fun get(): Database = connection.filterNotNull().take(1).last() + + suspend fun initializeDatabase() { + val databasePath = + Environment.directories.user.config + .createDirectories() / "store.sqlite" + logger.debug { "Database path: $databasePath" } + val db = + Database.connect( + url = "jdbc:sqlite:$databasePath", + driver = "org.sqlite.JDBC", + ) + + migrationService.runMigrations(db) + connection.update { db } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/DeviceEntity.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/DeviceEntity.kt new file mode 100644 index 0000000..67c384d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/DeviceEntity.kt @@ -0,0 +1,17 @@ +package org.sleepingcats.bridgecmdr.common.service.entity + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.UUIDEntity +import org.jetbrains.exposed.v1.dao.UUIDEntityClass +import org.sleepingcats.bridgecmdr.common.service.table.DevicesTable +import java.util.UUID + +class DeviceEntity( + id: EntityID, +) : UUIDEntity(id) { + companion object : UUIDEntityClass(DevicesTable) + + var driverId: UUID by DevicesTable.driverId + var title: String by DevicesTable.title + var path: String by DevicesTable.path +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SettingEntity.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SettingEntity.kt new file mode 100644 index 0000000..bc4d455 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SettingEntity.kt @@ -0,0 +1,14 @@ +package org.sleepingcats.bridgecmdr.common.service.entity + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.Entity +import org.jetbrains.exposed.v1.dao.EntityClass +import org.sleepingcats.bridgecmdr.common.service.table.SettingsTable + +class SettingEntity( + name: EntityID, +) : Entity(name) { + companion object : EntityClass(SettingsTable) + + var value: String by SettingsTable.value +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SourceEntity.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SourceEntity.kt new file mode 100644 index 0000000..1147427 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SourceEntity.kt @@ -0,0 +1,16 @@ +package org.sleepingcats.bridgecmdr.common.service.entity + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.UUIDEntity +import org.jetbrains.exposed.v1.dao.UUIDEntityClass +import org.sleepingcats.bridgecmdr.common.service.table.SourcesTable +import java.util.UUID + +class SourceEntity( + id: EntityID, +) : UUIDEntity(id) { + companion object : UUIDEntityClass(SourcesTable) + + var title by SourcesTable.title + var image by SourcesTable.image +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/TieEntity.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/TieEntity.kt new file mode 100644 index 0000000..9cf6a94 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/TieEntity.kt @@ -0,0 +1,19 @@ +package org.sleepingcats.bridgecmdr.common.service.entity + +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.dao.UUIDEntity +import org.jetbrains.exposed.v1.dao.UUIDEntityClass +import org.sleepingcats.bridgecmdr.common.service.table.TiesTable +import java.util.UUID + +class TieEntity( + id: EntityID, +) : UUIDEntity(id) { + companion object : UUIDEntityClass(TiesTable) + + var sourceId by TiesTable.sourceId + var deviceId by TiesTable.deviceId + var inputChannel by TiesTable.inputChannel + var outputVideoChannel by TiesTable.outputVideoChannel + var outputAudioChannel by TiesTable.outputAudioChannel +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDeviceService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDeviceService.kt new file mode 100644 index 0000000..62ed35d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDeviceService.kt @@ -0,0 +1,155 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.upsertReturning +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.entity.DeviceEntity +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.table.DevicesTable +import org.sleepingcats.bridgecmdr.common.service.table.TiesTable +import org.sleepingcats.core.ErrorHandler +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +private operator fun Device.Companion.invoke(entity: DeviceEntity): Device = + Device(entity.id.value.toKotlinUuid(), entity.driverId.toKotlinUuid(), entity.title, entity.path) + +class LocalDeviceService( + private val databaseService: DatabaseService, + private val localDriverService: LocalDriverService, +) : KoinComponent, + DeviceService { + override suspend fun all(): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + DeviceEntity.all().map { Device(it) } + } + } + + override suspend fun findById(id: Uuid): Device = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + DeviceEntity.findById(id.toJavaUuid())?.let { Device(it) } + ?: throw NotFoundException("Device with ID $id not found") + } + } + + suspend fun findManyByIds(ids: List): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + DeviceEntity + .find { DevicesTable.id inList ids.map { it.toJavaUuid() } } + .map { Device(it) } + } + } + + suspend fun verifyById( + id: Uuid, + onError: ErrorHandler = { msg -> throw BadRequestException(msg) }, + ): Unit = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + if (DeviceEntity.count(DevicesTable.id eq id.toJavaUuid()) == 0L) { + onError("Device with ID $id not found") + } + } + } + + override suspend fun insert(payload: Device.Payload): Device = + withContext(Dispatchers.IO) { + // Ensure the driver exist. + localDriverService.verifyById(payload.driverId) + transaction(databaseService.get()) { + DeviceEntity + .new { + driverId = payload.driverId.toJavaUuid() + title = payload.title + path = payload.path + }.let { Device(it) } + } + } + + override suspend fun upsert(device: Device): Device = + withContext(Dispatchers.IO) { + localDriverService.verifyById(device.driverId) + transaction(databaseService.get()) { + val rows = + DevicesTable.upsertReturning( + where = { DevicesTable.id eq device.id.toJavaUuid() }, + onUpdate = { + it[DevicesTable.driverId] = device.driverId.toJavaUuid() + it[DevicesTable.title] = device.title + it[DevicesTable.path] = device.path + }, + ) { + it[id] = device.id.toJavaUuid() + it[driverId] = device.driverId.toJavaUuid() + it[title] = device.title + it[path] = device.path + } + + rows + .map { Device(DeviceEntity.wrapRow(it)) } + .firstOrNull() ?: throw IllegalStateException("Failed to upsert Device with ID ${device.id}") + } + } + + override suspend fun updateById( + id: Uuid, + payload: Device.Payload, + ): Device = + withContext(Dispatchers.IO) { + // Ensure the driver exist. + localDriverService.verifyById(payload.driverId) + transaction(databaseService.get()) { + DeviceEntity + .findByIdAndUpdate(id.toJavaUuid()) { device -> + device.driverId = payload.driverId.toJavaUuid() + device.title = payload.title + device.path = payload.path + }?.let { Device(it) } ?: throw NotFoundException("Device with ID $id not found") + } + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Device.Update, + ): Device = + withContext(Dispatchers.IO) { + // Ensure the driver exist if updating it. + payload.driverId?.let { localDriverService.verifyById(it) } + transaction(databaseService.get()) { + DeviceEntity + .findByIdAndUpdate(id.toJavaUuid()) { device -> + payload.driverId?.let { device.driverId = it.toJavaUuid() } + payload.title?.let { device.title = it } + payload.path?.let { device.path = it } + }?.let { Device(it) } ?: throw NotFoundException("Device with ID $id not found") + } + } + + override suspend fun deleteById(id: Uuid): Device = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TiesTable.deleteWhere { TiesTable.deviceId eq id.toJavaUuid() } + DeviceEntity + .findById(id.toJavaUuid()) + ?.apply { delete() } + ?.let { Device(it) } + ?: throw NotFoundException("Device with ID $id not found") + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDriverService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDriverService.kt new file mode 100644 index 0000000..b1ec810 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDriverService.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.core.DriverModule +import org.sleepingcats.bridgecmdr.common.driver.extron.ExtronSisDriver +import org.sleepingcats.bridgecmdr.common.driver.shinybow.ShinybowVersion2Driver +import org.sleepingcats.bridgecmdr.common.driver.shinybow.ShinybowVersion3Driver +import org.sleepingcats.bridgecmdr.common.driver.sony.SonyRs485Driver +import org.sleepingcats.bridgecmdr.common.driver.teslasmart.TeslaSmartKvmDriver +import org.sleepingcats.bridgecmdr.common.driver.teslasmart.TeslaSmartMatrixDriver +import org.sleepingcats.bridgecmdr.common.driver.teslasmart.TeslaSmartSdiDriver +import org.sleepingcats.bridgecmdr.common.driver.tesmart.TeSmartSmartKvmDriver +import org.sleepingcats.bridgecmdr.common.driver.tesmart.TeSmartSmartMatrixDriver +import org.sleepingcats.bridgecmdr.common.driver.tesmart.TeSmartSmartSdiDriver +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.core.ErrorHandler +import org.sleepingcats.core.memoizeSuspend +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class LocalDriverService : + KoinComponent, + DriverService { + private val drivers = + memoizeSuspend { + listOf( + ExtronSisDriver(), + ShinybowVersion2Driver(), + ShinybowVersion3Driver(), + SonyRs485Driver(), + TeslaSmartKvmDriver(), + TeslaSmartMatrixDriver(), + TeslaSmartSdiDriver(), + TeSmartSmartKvmDriver(), + TeSmartSmartMatrixDriver(), + TeSmartSmartSdiDriver(), + ) + } + + private val index = + memoizeSuspend { + drivers().associateBy { it.id } + } + + override suspend fun all(): List = withContext(Dispatchers.IO) { drivers() } + + override suspend fun findById(id: Uuid): DriverModule = + findByIdOrNull(id) ?: throw NotFoundException("Drive with ID $id not found") + + suspend fun findByIdOrNull(id: Uuid): DriverModule? = index()[id] + + suspend fun verifyById( + id: Uuid, + onError: ErrorHandler = { msg -> throw BadRequestException(msg) }, + ) { + if (!index().containsKey(id)) onError("Driver with ID $id not found") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalLegacySettingsService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalLegacySettingsService.kt new file mode 100644 index 0000000..dc92021 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalLegacySettingsService.kt @@ -0,0 +1,32 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.LegacySettingsService +import org.sleepingcats.bridgecmdr.common.service.entity.SettingEntity +import org.sleepingcats.bridgecmdr.common.service.model.LegacySettings +import kotlin.uuid.ExperimentalUuidApi + +class LocalLegacySettingsService( + private val databaseService: DatabaseService, +) : KoinComponent, + LegacySettingsService { + override suspend fun read() = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + LegacySettings( + iconSize = SettingEntity.findById("iconSize")?.value?.let { Json.decodeFromString(it) }, + colorScheme = SettingEntity.findById("colorScheme")?.value?.let { Json.decodeFromString(it) }, + powerOffWhen = SettingEntity.findById("powerOffWhen")?.value?.let { Json.decodeFromString(it) }, + powerOnDevices = SettingEntity.findById("powerOnSwitchesAtStart")?.value?.let { Json.decodeFromString(it) }, + buttonOrder = SettingEntity.findById("buttonOrder")?.value?.let { Json.decodeFromString(it) }, + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalPowerService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalPowerService.kt new file mode 100644 index 0000000..d17b9d5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalPowerService.kt @@ -0,0 +1,40 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.PowerService +import kotlin.uuid.ExperimentalUuidApi + +class LocalPowerService( + private val localDeviceService: LocalDeviceService, + private val localDriverService: LocalDriverService, +) : KoinComponent, + PowerService { + override suspend fun powerOn(): Unit = + withContext(Dispatchers.IO) { + // Not optimal, but expected use cases shouldn't have many drivers and devices. + // But this is easier to read and understand. + val drivers = localDriverService.all() + localDeviceService + .all() + .mapNotNull { device -> drivers.find { it.id == device.driverId }?.let { Pair(device, it) } } + .map { (device, driver) -> driver.powerOn(device.path) } + .awaitAll() + } + + override suspend fun powerOff(): Unit = + withContext(Dispatchers.IO) { + // Not optimal, but expected use cases shouldn't have many drivers and devices. + // But this is easier to read and understand. + val drivers = localDriverService.all() + localDeviceService + .all() + .mapNotNull { device -> drivers.find { it.id == device.driverId }?.let { Pair(device, it) } } + .map { (device, driver) -> driver.powerOff(device.path) } + .awaitAll() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSerialPortService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSerialPortService.kt new file mode 100644 index 0000000..a4c32b7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSerialPortService.kt @@ -0,0 +1,32 @@ +package org.sleepingcats.bridgecmdr.common.service.local + +import com.fazecast.jSerialComm.SerialPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo + +class LocalSerialPortService : + KoinComponent, + SerialPortService { + override suspend fun all(): List = + withContext(Dispatchers.IO) { + SerialPort + .getCommPorts() + .filter { port -> port.vendorID != -1 && port.productID != -1 } + .map { port -> + PortInfo( + path = port.systemPortPath, + serialNumber = port.serialNumber ?: "Unknown", + manufacturer = port.manufacturer ?: "Unknown", + title = port.descriptivePortName, + name = port.systemPortName, + description = port.portDescription, + location = port.portLocation, + productId = port.productID.toShort().toHexString(), + vendorId = port.vendorID.toShort().toHexString(), + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSourceService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSourceService.kt new file mode 100644 index 0000000..9766800 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSourceService.kt @@ -0,0 +1,168 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.upsertReturning +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.entity.SourceEntity +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.service.table.SourcesTable +import org.sleepingcats.bridgecmdr.common.service.table.TiesTable +import org.sleepingcats.core.ErrorHandler +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +private operator fun Source.Companion.invoke(entity: SourceEntity): Source = + Source(entity.id.value.toKotlinUuid(), entity.title, entity.image?.toKotlinUuid()) + +class LocalSourceService( + private val databaseService: DatabaseService, + private val localUserImageService: LocalUserImageService, + private val localDriverService: LocalDriverService, +) : KoinComponent, + SourceService { + private val localDeviceService: LocalDeviceService by inject() + private val localTieService: LocalTieService by inject() + + override suspend fun all(): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + SourceEntity.all().map { Source(it) } + } + } + + override suspend fun findById(id: Uuid): Source = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + SourceEntity.findById(id.toJavaUuid())?.let { Source(it) } + ?: throw NotFoundException("Source with ID $id not found") + } + } + + suspend fun verifyById( + id: Uuid, + onError: ErrorHandler = { msg -> throw BadRequestException(msg) }, + ): Unit = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + if (SourceEntity.count(SourcesTable.id eq id.toJavaUuid()) == 0L) { + onError("Source with ID $id not found") + } + } + } + + override suspend fun insert(payload: Source.Payload): Source = + withContext(Dispatchers.IO) { + payload.image?.let { localUserImageService.verifyById(it) } + transaction(databaseService.get()) { + SourceEntity + .new { + title = payload.title + image = payload.image?.toJavaUuid() + }.let { Source(it) } + } + } + + override suspend fun upsert(source: Source): Source = + withContext(Dispatchers.IO) { + source.image?.let { localUserImageService.verifyById(it) } + transaction(databaseService.get()) { + val rows = + SourcesTable + .upsertReturning( + where = { SourcesTable.id eq source.id.toJavaUuid() }, + onUpdate = { + it[SourcesTable.title] = source.title + it[SourcesTable.image] = source.image?.toJavaUuid() + }, + ) { + it[id] = source.id.toJavaUuid() + it[title] = source.title + it[image] = source.image?.toJavaUuid() + } + + rows + .map { Source(SourceEntity.wrapRow(it)) } + .firstOrNull() ?: throw IllegalStateException("Failed to upsert Source with ID ${source.id}") + } + } + + override suspend fun updateById( + id: Uuid, + payload: Source.Payload, + ): Source = + withContext(Dispatchers.IO) { + payload.image?.let { localUserImageService.verifyById(it) } + transaction(databaseService.get()) { + SourceEntity + .findByIdAndUpdate(id.toJavaUuid()) { source -> + source.title = payload.title + source.image = payload.image?.toJavaUuid() + }?.let { Source(it) } ?: throw NotFoundException("Source with ID $id not found") + } + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Source.Update, + ): Source = + withContext(Dispatchers.IO) { + payload.image?.let { localUserImageService.verifyById(it) } + transaction(databaseService.get()) { + SourceEntity + .findByIdAndUpdate(id.toJavaUuid()) { source -> + payload.title?.let { source.title = it } + payload.image?.let { source.image = it.toJavaUuid() } + }?.let { Source(it) } ?: throw NotFoundException("Source with ID $id not found") + } + } + + override suspend fun deleteById(id: Uuid): Source = + withContext(Dispatchers.IO) { + // NOTE: We don't delete the associated image in case another source is using it. + transaction(databaseService.get()) { + TiesTable.deleteWhere { TiesTable.sourceId eq id.toJavaUuid() } + SourceEntity + .findById(id.toJavaUuid()) + ?.apply { delete() } + ?.let { Source(it) } + ?: throw NotFoundException("Source with ID $id not found") + } + } + + override suspend fun activateById(id: Uuid): Unit = + withContext(Dispatchers.IO) { + verifyById(id) { msg -> throw NotFoundException(msg) } + + // Not optimal, but expected use cases shouldn't have many drivers, devices, and ties. + // But this is easier to read and understand. + + val devices = localDeviceService.all() + val drivers = localDriverService.all() + localTieService + .findBySourceId(id) + .mapNotNull { tie -> devices.find { it.id == tie.deviceId }?.let { Pair(tie, it) } } + .mapNotNull { (tie, device) -> drivers.find { it.id == device.driverId }?.let { Triple(tie, device, it) } } + .map { (tie, device, driver) -> + driver.activate( + device.path, + tie.inputChannel, + tie.outputVideoChannel ?: tie.inputChannel, + tie.outputAudioChannel ?: tie.outputVideoChannel ?: tie.inputChannel, + ) + }.awaitAll() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalTieService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalTieService.kt new file mode 100644 index 0000000..51a251b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalTieService.kt @@ -0,0 +1,178 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import io.ktor.server.plugins.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.upsertReturning +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.entity.TieEntity +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.common.service.table.TiesTable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +private operator fun Tie.Companion.invoke(entity: TieEntity): Tie = + Tie( + entity.id.value.toKotlinUuid(), + entity.sourceId.toKotlinUuid(), + entity.deviceId.toKotlinUuid(), + entity.inputChannel, + entity.outputVideoChannel, + entity.outputAudioChannel, + ) + +class LocalTieService( + private val databaseService: DatabaseService, + private val localDeviceService: LocalDeviceService, + private val localSourceService: LocalSourceService, +) : KoinComponent, + TieService { + override suspend fun all(): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity.all().map { Tie(it) } + } + } + + override suspend fun findById(id: Uuid): Tie = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity.findById(id.toJavaUuid())?.let { Tie(it) } + ?: throw NotFoundException("Tie with ID $id not found") + } + } + + override suspend fun findBySourceId(sourceId: Uuid): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity + .find { TiesTable.sourceId eq sourceId.toJavaUuid() } + .map { Tie(it) } + } + } + + override suspend fun findByDeviceId(deviceId: Uuid): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity + .find { TiesTable.deviceId eq deviceId.toJavaUuid() } + .map { Tie(it) } + } + } + + override suspend fun findBySourceAndDeviceId( + sourceId: Uuid, + deviceId: Uuid, + ): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity + .find { (TiesTable.sourceId eq sourceId.toJavaUuid()) and (TiesTable.deviceId eq deviceId.toJavaUuid()) } + .map { Tie(it) } + } + } + + override suspend fun insert(payload: Tie.Payload): Tie = + withContext(Dispatchers.IO) { + localDeviceService.verifyById(payload.deviceId) + localSourceService.verifyById(payload.sourceId) + transaction(databaseService.get()) { + TieEntity + .new { + sourceId = payload.sourceId.toJavaUuid() + deviceId = payload.deviceId.toJavaUuid() + inputChannel = payload.inputChannel + outputVideoChannel = payload.outputVideoChannel + outputAudioChannel = payload.outputAudioChannel + }.let { Tie(it) } + } + } + + override suspend fun upsert(tie: Tie): Tie = + withContext(Dispatchers.IO) { + localDeviceService.verifyById(tie.deviceId) + localSourceService.verifyById(tie.sourceId) + transaction(databaseService.get()) { + val rows = + TiesTable.upsertReturning( + where = { TiesTable.id eq tie.id.toJavaUuid() }, + onUpdate = { + it[TiesTable.sourceId] = tie.sourceId.toJavaUuid() + it[TiesTable.deviceId] = tie.deviceId.toJavaUuid() + it[TiesTable.inputChannel] = tie.inputChannel + it[TiesTable.outputVideoChannel] = tie.outputVideoChannel + it[TiesTable.outputAudioChannel] = tie.outputAudioChannel + }, + ) { + it[id] = tie.id.toJavaUuid() + it[sourceId] = tie.sourceId.toJavaUuid() + it[deviceId] = tie.deviceId.toJavaUuid() + it[inputChannel] = tie.inputChannel + it[outputVideoChannel] = tie.outputVideoChannel + it[outputAudioChannel] = tie.outputAudioChannel + } + + rows + .map { Tie(TieEntity.wrapRow(it)) } + .firstOrNull() ?: throw IllegalStateException("Failed to upsert Tie with ID ${tie.id}") + } + } + + override suspend fun updateById( + id: Uuid, + payload: Tie.Payload, + ): Tie = + withContext(Dispatchers.IO) { + localDeviceService.verifyById(payload.deviceId) + localSourceService.verifyById(payload.sourceId) + transaction(databaseService.get()) { + TieEntity + .findByIdAndUpdate(id.toJavaUuid()) { entity -> + entity.sourceId = payload.sourceId.toJavaUuid() + entity.deviceId = payload.deviceId.toJavaUuid() + entity.inputChannel = payload.inputChannel + entity.outputVideoChannel = payload.outputVideoChannel + entity.outputAudioChannel = payload.outputAudioChannel + }?.let { Tie(it) } ?: throw NotFoundException("Tie with ID $id not found") + } + } + + override suspend fun partialUpdateById( + id: Uuid, + payload: Tie.Update, + ): Tie = + withContext(Dispatchers.IO) { + payload.deviceId?.let { localDeviceService.verifyById(it) } + payload.sourceId?.let { localSourceService.verifyById(it) } + transaction(databaseService.get()) { + TieEntity + .findByIdAndUpdate(id.toJavaUuid()) { entity -> + payload.sourceId?.let { entity.sourceId = it.toJavaUuid() } + payload.deviceId?.let { entity.deviceId = it.toJavaUuid() } + payload.inputChannel?.let { entity.inputChannel = it } + payload.outputVideoChannel?.let { entity.outputVideoChannel = it } + payload.outputAudioChannel?.let { entity.outputAudioChannel = it } + }?.let { Tie(it) } ?: throw NotFoundException("Tie with ID $id not found") + } + } + + override suspend fun deleteById(id: Uuid): Tie = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + TieEntity + .findById(id.toJavaUuid()) + ?.apply { delete() } + ?.let { Tie(it) } + ?: throw NotFoundException("Tie with ID $id not found") + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalUserImageService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalUserImageService.kt new file mode 100644 index 0000000..3ec9e60 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalUserImageService.kt @@ -0,0 +1,141 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.common.service.local + +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.NotFoundException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.count +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob +import org.jetbrains.exposed.v1.jdbc.deleteReturning +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.extension.insertOrGetId +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.model.NewUserImage +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import org.sleepingcats.bridgecmdr.common.service.table.UserImagesTable +import org.sleepingcats.core.ErrorHandler +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +class LocalUserImageService( + private val databaseService: DatabaseService, +) : KoinComponent, + UserImageService { + override suspend fun all(): List = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + UserImagesTable + .select( + UserImagesTable.id, + UserImagesTable.data, + UserImagesTable.type, + UserImagesTable.hash, + ).map { + UserImage( + it[UserImagesTable.id].value.toKotlinUuid(), + it[UserImagesTable.data].bytes, + it[UserImagesTable.type], + it[UserImagesTable.hash], + ) + } + } + } + + override suspend fun findById(id: Uuid): UserImage = + tryFindById(id) ?: throw NotFoundException("Image with ID $id not found") + + override suspend fun tryFindById(id: Uuid): UserImage? = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + UserImagesTable + .select( + UserImagesTable.id, + UserImagesTable.data, + UserImagesTable.type, + UserImagesTable.hash, + ).where(UserImagesTable.id eq id.toJavaUuid()) + .limit(1) + .firstOrNull() + ?.let { + UserImage( + it[UserImagesTable.id].value.toKotlinUuid(), + it[UserImagesTable.data].bytes, + it[UserImagesTable.type], + it[UserImagesTable.hash], + ) + } + } + } + + suspend fun verifyById( + id: Uuid, + onError: ErrorHandler = { msg -> throw BadRequestException(msg) }, + ): Unit = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + val countExpression = UserImagesTable.id.count().alias("count") + val count = + UserImagesTable + .select(countExpression) + .where(UserImagesTable.id eq id.toJavaUuid()) + .first()[countExpression] + + if (count == 0L) onError("Image with ID $id not found") + } + } + + override suspend fun upsert(image: UserImage.New): UserImage = + withContext(Dispatchers.IO) { + val newImage = NewUserImage(image.data, image.type) + val id = + transaction(databaseService.get()) { + UserImagesTable + .insertOrGetId({ hash eq newImage.hash }) { + it[data] = ExposedBlob(newImage.bytes) + it[type] = newImage.type + it[hash] = newImage.hash + } ?: throw IllegalStateException("Image with upsert failed") + } + UserImage( + id.value.toKotlinUuid(), + newImage.bytes, + newImage.type, + newImage.hash, + ) + } + + override suspend fun deleteById(id: Uuid): UserImage = + withContext(Dispatchers.IO) { + transaction(databaseService.get()) { + UserImagesTable + .deleteReturning( + returning = + listOf( + UserImagesTable.id, + UserImagesTable.data, + UserImagesTable.type, + UserImagesTable.hash, + ), + where = { UserImagesTable.id eq id.toJavaUuid() }, + ).firstOrNull() + ?.let { + UserImage( + it[UserImagesTable.id].value.toKotlinUuid(), + it[UserImagesTable.data].bytes, + it[UserImagesTable.type], + it[UserImagesTable.hash], + ) + } + ?: throw NotFoundException("Image with ID $id not found") + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/Migration.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/Migration.kt new file mode 100644 index 0000000..f654959 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/Migration.kt @@ -0,0 +1,13 @@ +package org.sleepingcats.bridgecmdr.common.service.migration + +import io.github.oshai.kotlinlogging.KLogger +import org.jetbrains.exposed.v1.jdbc.Database + +interface Migration { + val name: String + + suspend fun up( + db: Database, + logger: KLogger, + ) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/MigrationService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/MigrationService.kt new file mode 100644 index 0000000..4be2067 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/MigrationService.kt @@ -0,0 +1,79 @@ +package org.sleepingcats.bridgecmdr.common.service.migration + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.common.service.migration.script.M20260105123500CreateSqliteTables +import org.sleepingcats.bridgecmdr.common.service.migration.script.M20260105123530MigrateToSqlite +import java.time.ZoneOffset.UTC +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter.ISO_INSTANT +import kotlin.system.measureTimeMillis +import kotlin.time.Duration.Companion.seconds + +class MigrationService( + private val logger: KLogger, +) : KoinComponent { + private object MigrationTable : Table("bridgecmdr_migrations") { + val name = text("name") + override val primaryKey = PrimaryKey(name) + val date = text("date") + } + + private val migrations = + listOf( + M20260105123500CreateSqliteTables, + M20260105123530MigrateToSqlite, + ) + + suspend fun runMigrations(db: Database) { + withContext(Dispatchers.IO) { + transaction(db) { + SchemaUtils.create(MigrationTable) + } + } + + val executed = + withContext(Dispatchers.IO) { + transaction(db) { + MigrationTable.selectAll().map { it[MigrationTable.name] } + } + } + + for (migration in migrations) { + if (executed.contains(migration.name)) continue + + withContext(Dispatchers.IO) { + runCatching { + transaction(db) { + runBlocking { + logger.debug { "Running migration: ${migration.name}" } + val time = measureTimeMillis { migration.up(db, logger) } + logger.info { "Migration applied: ${migration.name}, in ${time.seconds}" } + val checked = + MigrationTable + .insert { + it[name] = migration.name + it[date] = ZonedDateTime.now(UTC).format(ISO_INSTANT) + }.insertedCount + if (checked != 1) { + throw IllegalStateException("Failed to record migration: ${migration.name}") + } + } + } + }.onFailure { + logger.error(it) { "Failed to apply migration: ${migration.name}" } + throw it + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123500CreateSqliteTables.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123500CreateSqliteTables.kt new file mode 100644 index 0000000..50003e1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123500CreateSqliteTables.kt @@ -0,0 +1,91 @@ +package org.sleepingcats.bridgecmdr.common.service.migration.script + +import io.github.oshai.kotlinlogging.KLogger +import org.jetbrains.exposed.v1.core.statements.StatementType.CREATE +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.sleepingcats.bridgecmdr.common.service.migration.Migration + +object M20260105123500CreateSqliteTables : Migration { + override val name: String = "20260105123500-create-sqlite-tables" + + override suspend fun up( + db: Database, + logger: KLogger, + ): Unit = + transaction(db) { + // TODO: Remove the settings table in v3.1 or later. + // It's only here to allow legacy settings migration + // and later removal without issue. + exec( + """ + CREATE TABLE IF NOT EXISTS settings ( + name VARCHAR(255) NOT NULL, + value TEXT, + CONSTRAINT settings_pk PRIMARY KEY (name) + ) + """.trimIndent(), + explicitStatementType = CREATE, + ) { + logger.info { it } + } + exec( + """ + CREATE TABLE IF NOT EXISTS devices ( + id BINARY(16) NOT NULL, + driver_id BINARY(16) NOT NULL, + title VARCHAR(255) NOT NULL, + path VARCHAR(255) NOT NULL, + CONSTRAINT pk_devices_id PRIMARY KEY (id) + ) + """.trimIndent(), + explicitStatementType = CREATE, + ) { TODO("testing") } + exec( + """ + CREATE TABLE IF NOT EXISTS images ( + id BINARY(16) NOT NULL, + data BLOB NOT NULL, + type VARCHAR(255) NOT NULL, + hash BINARY(32) NOT NULL, + CONSTRAINT pk_images_id PRIMARY KEY (id), + CONSTRAINT u_images_hash UNIQUE (hash) + ) + """.trimIndent(), + explicitStatementType = CREATE, + ) + exec( + """ + CREATE TABLE IF NOT EXISTS sources ( + id BINARY(16) NOT NULL, + title VARCHAR(255) NOT NULL, + image BINARY(16), + CONSTRAINT pk_sources_id PRIMARY KEY (id), + CONSTRAINT fk_sources_image__id FOREIGN KEY (image) REFERENCES images(id) ON DELETE SET NULL ON UPDATE CASCADE + ) + """.trimIndent(), + explicitStatementType = CREATE, + ) + exec( + """ + CREATE TABLE IF NOT EXISTS ties ( + id BINARY(16) NOT NULL, + source_id BINARY(16) NOT NULL, + device_id BINARY(16) NOT NULL, + input_channel INTEGER NOT NULL, + output_video_channel INTEGER, + output_audio_channel INTEGER, + CONSTRAINT pk_ties_id PRIMARY KEY (id), + CONSTRAINT fk_ties_device_id__id FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_ties_source_id__id FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT chk_ties_input_channel_signed_integer CHECK (input_channel BETWEEN -2147483648 AND 2147483647), + CONSTRAINT chk_ties_output_video_channel_signed_integer CHECK (output_video_channel BETWEEN -2147483648 AND 2147483647), + CONSTRAINT chk_ties_output_audio_channel_signed_integer CHECK (output_audio_channel BETWEEN -2147483648 AND 2147483647) + ) + """.trimIndent(), + explicitStatementType = CREATE, + ) + exec("CREATE INDEX idx_ties_device_id ON ties (device_id)") + exec("CREATE INDEX idx_ties_source_id ON ties (source_id)") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123530MigrateToSqlite.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123530MigrateToSqlite.kt new file mode 100644 index 0000000..ce0ad85 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123530MigrateToSqlite.kt @@ -0,0 +1,18 @@ +package org.sleepingcats.bridgecmdr.common.service.migration.script + +import io.github.oshai.kotlinlogging.KLogger +import org.jetbrains.exposed.v1.jdbc.Database +import org.sleepingcats.bridgecmdr.common.service.migration.Migration + +object M20260105123530MigrateToSqlite : Migration { + override val name: String = "20260105123530-migrate-to-sqlite" + + override suspend fun up( + db: Database, + logger: KLogger, + ) { + // No-op: Data migration from LevelDB was only doable in v2 since no such up-to-date library + // exists for Kotlin Multiplatform or Java/Kotlin JVM. This is here to ensure the migration + // is recorded. + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/NewUserImage.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/NewUserImage.kt new file mode 100644 index 0000000..fd3eaf7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/NewUserImage.kt @@ -0,0 +1,12 @@ +package org.sleepingcats.bridgecmdr.common.service.model + +import kotlinx.serialization.Serializable +import java.security.MessageDigest + +@Serializable +class NewUserImage( + val bytes: ByteArray, + val type: String, +) { + val hash: ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/DevicesTable.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/DevicesTable.kt new file mode 100644 index 0000000..2696713 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/DevicesTable.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.service.table + +import org.jetbrains.exposed.v1.core.dao.id.UUIDTable + +object DevicesTable : UUIDTable("devices") { + val driverId = uuid("driver_id") + val title = varchar("title", 255) + val path = varchar("path", 255) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SettingsTable.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SettingsTable.kt new file mode 100644 index 0000000..872933d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SettingsTable.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.service.table + +import org.jetbrains.exposed.v1.core.dao.id.IdTable + +object SettingsTable : IdTable("settings") { + override val id = varchar("name", 255).entityId() + override val primaryKey = PrimaryKey(id) + val value = text("value") +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SourcesTable.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SourcesTable.kt new file mode 100644 index 0000000..a4e558f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SourcesTable.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.service.table + +import org.jetbrains.exposed.v1.core.dao.id.UUIDTable +import org.jetbrains.exposed.v1.core.plus + +object SourcesTable : UUIDTable("sources") { + val title = varchar("title", 255) + val image = uuid("image").references(UserImagesTable.id).nullable() +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/TiesTable.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/TiesTable.kt new file mode 100644 index 0000000..053a71a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/TiesTable.kt @@ -0,0 +1,11 @@ +package org.sleepingcats.bridgecmdr.common.service.table + +import org.jetbrains.exposed.v1.core.dao.id.UUIDTable + +object TiesTable : UUIDTable("ties") { + val sourceId = uuid("source_id").references(SourcesTable.id) + val deviceId = uuid("device_id").references(DevicesTable.id) + val inputChannel = integer("input_channel") + val outputVideoChannel = integer("output_video_channel").nullable() + val outputAudioChannel = integer("output_audio_channel").nullable() +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/UserImagesTable.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/UserImagesTable.kt new file mode 100644 index 0000000..fb8b973 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/UserImagesTable.kt @@ -0,0 +1,9 @@ +package org.sleepingcats.bridgecmdr.common.service.table + +import org.jetbrains.exposed.v1.core.dao.id.UUIDTable + +object UserImagesTable : UUIDTable("images") { + val data = blob("data") + val type = varchar("type", 255) + val hash = binary("hash", 32).uniqueIndex() +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Application.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Application.kt new file mode 100644 index 0000000..2522f78 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Application.kt @@ -0,0 +1,59 @@ +package org.sleepingcats.bridgecmdr.server + +import com.atlassian.onetime.core.TOTP +import com.atlassian.onetime.service.TOTPVerificationResult.Success +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.bearer +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.resources.Resources +import io.ktor.server.routing.routing +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.common.security.TokenService +import org.sleepingcats.bridgecmdr.common.security.decrypt +import org.sleepingcats.bridgecmdr.server.plugin.installErrorHandling +import org.sleepingcats.bridgecmdr.server.route.devices +import org.sleepingcats.bridgecmdr.server.route.drivers +import org.sleepingcats.bridgecmdr.server.route.images +import org.sleepingcats.bridgecmdr.server.route.power +import org.sleepingcats.bridgecmdr.server.route.serialPorts +import org.sleepingcats.bridgecmdr.server.route.sources +import org.sleepingcats.bridgecmdr.server.route.ties +import org.sleepingcats.bridgecmdr.server.security.ServerContext +import org.sleepingcats.bridgecmdr.server.security.UserPrincipal +import kotlin.getValue +import kotlin.io.encoding.Base64 + +fun Application.application(context: ServerContext) { + val tokenService: TokenService by inject() + + install(ContentNegotiation) { json() } + install(Resources) + installErrorHandling() + + install(Authentication) { + bearer(Branding.qualifiedName) { + realm = Branding.name + authenticate { credentials -> + val totp = TOTP(String.decrypt(Base64.decode(credentials.token), context.privateKey)) + if (tokenService.verifyToken(totp, context.tokenSecret) is Success) UserPrincipal else null + } + } + } + + routing { + authenticate(Branding.qualifiedName) { + devices() + drivers() + images() + power() + serialPorts() + sources() + ties() + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Constants.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Constants.kt new file mode 100644 index 0000000..d35c2cd --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Constants.kt @@ -0,0 +1,4 @@ +package org.sleepingcats.bridgecmdr.server + +const val SERVER_PORT = 8080 +const val SECURE_SERVER_PORT = 8443 diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerController.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerController.kt new file mode 100644 index 0000000..58d011c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerController.kt @@ -0,0 +1,120 @@ +package org.sleepingcats.bridgecmdr.server + +import io.github.oshai.kotlinlogging.KLogger +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.connector +import io.ktor.server.engine.embeddedServer +import io.ktor.server.engine.sslConnector +import io.ktor.server.netty.Netty +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.ktor.plugin.setKoin +import org.sleepingcats.bridgecmdr.common.isDevelopment +import org.sleepingcats.bridgecmdr.server.ServerStatus.Running +import org.sleepingcats.bridgecmdr.server.ServerStatus.Starting +import org.sleepingcats.bridgecmdr.server.ServerStatus.Stopped +import org.sleepingcats.bridgecmdr.server.ServerStatus.Stopping +import org.sleepingcats.bridgecmdr.server.security.ServerContext + +class ServerController( + private val logger: KLogger, + scope: CoroutineScope, +) : KoinComponent { + private enum class Commands { + None, + Start, + Stop, + } + + private val _status = MutableStateFlow(Stopped) + + val status = _status.asStateFlow() + + private val command = MutableStateFlow(Commands.None) + + fun start() { + command.update { Commands.Start } + } + + fun stop() { + command.update { Commands.Stop } + } + + init { + scope.launch(Dispatchers.Default) { + command + .onEach { action -> + when (action) { + Commands.Start -> startServer() + Commands.Stop -> stopServer() + Commands.None -> Unit + } + }.catch { throwable -> logger.error(throwable) { "Server command failure" } } + .launchIn(this) + } + } + + private var server: EmbeddedServer<*, *>? = null + + private fun createServer(context: ServerContext): EmbeddedServer<*, *> = + embeddedServer( + Netty, + configure = { + if (isDevelopment) { + connector { + host = context.bindAddress + port = SERVER_PORT + } + } + + sslConnector( + keyStore = context.keyStore, + keyAlias = "bridgeCmdr", + keyStorePassword = { context.keyStorePassword.toCharArray() }, + privateKeyPassword = { context.privateKeyPassword.toCharArray() }, + ) { + host = context.bindAddress + port = context.port + } + }, + ) { + // Set Koin instance for Ktor, can't use the plugin + // since we want to use the instance from Compose. + setKoin(getKoin()) + application(context) + } + + private suspend fun startServer() { + if (server != null) return + logger.info { "Starting server..." } + _status.update { Starting } + + val context = ServerContext.create() + + server = + runCatching { createServer(context).start(wait = false) } + .onFailure { throwable -> logger.error(throwable) { "Failed to start server" } } + .onFailure { _status.update { Stopped } } + .onSuccess { _status.update { Running(context) } } + .getOrNull() + } + + private fun stopServer() { + val current = server ?: return + logger.info { "Stopping server..." } + _status.update { Stopping } + server = + runCatching { current.stop(1000, 5000) } + .onFailure { throwable -> logger.error(throwable) { "Failed to stop server" } } + .onSuccess { _status.update { Stopped } } + .let { null } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerStatus.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerStatus.kt new file mode 100644 index 0000000..1a28ba4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerStatus.kt @@ -0,0 +1,34 @@ +package org.sleepingcats.bridgecmdr.server + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.server_ServerStatus_Running +import bridgecmdr.composeapp.generated.resources.server_ServerStatus_Starting +import bridgecmdr.composeapp.generated.resources.server_ServerStatus_Stopped +import bridgecmdr.composeapp.generated.resources.server_ServerStatus_Stopping +import org.jetbrains.compose.resources.StringResource +import org.sleepingcats.bridgecmdr.server.security.ServerContext + +sealed interface ServerStatus { + object Stopped : ServerStatus { + override val resource = Res.string.server_ServerStatus_Stopped + } + + object Starting : ServerStatus { + override val resource = Res.string.server_ServerStatus_Starting + } + + class Running( + context: ServerContext, + ) : ServerStatus { + override val resource = Res.string.server_ServerStatus_Running + val url = "https://${context.serverAddress}:${context.port}" + val tokenSecret = context.tokenSecret + val publicKey = context.publicKey + } + + object Stopping : ServerStatus { + override val resource = Res.string.server_ServerStatus_Stopping + } + + val resource: StringResource +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/module/ServerModule.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/module/ServerModule.kt new file mode 100644 index 0000000..3c71c3d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/module/ServerModule.kt @@ -0,0 +1,14 @@ +package org.sleepingcats.bridgecmdr.server.module + +import kotlinx.coroutines.CoroutineScope +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.common.security.TokenService +import org.sleepingcats.bridgecmdr.server.ServerController + +fun serverModule(scope: CoroutineScope) = + module { + singleOf(::TokenService) + single { ServerController(get(), scope) } bind ServerController::class + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/ErrorHandling.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/ErrorHandling.kt new file mode 100644 index 0000000..677f28f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/ErrorHandling.kt @@ -0,0 +1,72 @@ +package org.sleepingcats.bridgecmdr.server.plugin + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.plugins.BadRequestException +import io.ktor.server.plugins.NotFoundException +import io.ktor.server.plugins.requestvalidation.RequestValidationException +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.plugins.statuspages.StatusPagesConfig +import io.ktor.server.response.respond +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +class HttpErrorException( + val status: HttpStatusCode, + message: String? = null, + details: List? = null, +) : Exception(status.description) { + @Serializable + data class Payload( + val status: Int, + val message: String, + val description: String? = null, + val details: List? = null, + ) + + constructor(cause: Throwable) : this(HttpStatusCode.InternalServerError, cause.message) + + constructor(status: HttpStatusCode, cause: Throwable) : this(status, cause.message) + + constructor(cause: RequestValidationException) : this( + HttpStatusCode.BadRequest, + "Validation failed for ${cause.value}", + cause.reasons, + ) + + val payload = + Payload( + status.value, + status.description, + message, + details, + ) + + val description by payload::description + val details by payload::details +} + +suspend inline fun ApplicationCall.error(httpError: HttpErrorException) { + this.respond(httpError.status, httpError.payload) +} + +inline fun StatusPagesConfig.catchAndRespond(status: HttpStatusCode): Unit = + catchAndRespond(T::class, status) + +inline fun StatusPagesConfig.catchAndRespond( + klass: KClass, + status: HttpStatusCode, +): Unit = exception(klass) { call, cause -> call.error(HttpErrorException(status, cause)) } + +fun Application.installErrorHandling() { + install(StatusPages) { + // Special cases for errors that have specific overloads. + exception { call, cause -> call.error(HttpErrorException(cause)) } + + catchAndRespond(HttpStatusCode.BadRequest) + catchAndRespond(HttpStatusCode.NotFound) + catchAndRespond(HttpStatusCode.NotFound) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/Validation.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/Validation.kt new file mode 100644 index 0000000..fd89315 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/Validation.kt @@ -0,0 +1,18 @@ +package org.sleepingcats.bridgecmdr.server.plugin + +import io.konform.validation.Validation +import io.konform.validation.ValidationBuilder +import io.ktor.server.plugins.requestvalidation.RequestValidationConfig +import io.ktor.server.plugins.requestvalidation.ValidationResult + +inline fun RequestValidationConfig.rules(noinline init: ValidationBuilder.() -> Unit) { + val validator = Validation(init) + this.validate { of -> + val result = validator(of) + if (result.isValid) { + ValidationResult.Valid + } else { + ValidationResult.Invalid(result.errors.map { it.message }) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Devices.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Devices.kt new file mode 100644 index 0000000..383d120 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Devices.kt @@ -0,0 +1,76 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.server.route + +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.minLength +import io.ktor.resources.Resource +import io.ktor.server.plugins.requestvalidation.RequestValidation +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.put +import io.ktor.server.resources.resource +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.server.plugin.rules +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Resource("/devices") +class Devices { + @Resource("/{id}") + class ById( + val id: Uuid, + ) +} + +fun Route.devices() = + resource { + install(RequestValidation) { + rules { + Device.Payload::title { + minLength(1) hint "Device name may not be empty" + maxLength(255) hint "Device name may not be longer than 255 characters" + } + + Device.Payload::path { + // TODO: Pattern based validation of the path (from JS version) + minLength(1) hint "Device path may not be empty" + maxLength(255) hint "Device path may not be longer than 255 characters" + } + } + } + + val service: DeviceService by inject() + + get { + call.respond>(service.all()) + } + + get { params -> + call.respond(service.findById(params.id)) + } + + post { + call.respond(service.insert(call.receive())) + } + + put { params -> + call.respond(service.updateById(params.id, call.receive())) + } + + patch { params -> + call.respond(service.partialUpdateById(params.id, call.receive())) + } + + delete { params -> + call.respond(service.deleteById(params.id)) + } + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Drivers.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Drivers.kt new file mode 100644 index 0000000..3616f49 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Drivers.kt @@ -0,0 +1,36 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.server.route + +import io.ktor.resources.Resource +import io.ktor.server.resources.get +import io.ktor.server.resources.resource +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Resource("/drivers") +class Drivers { + @Resource("/{id}") + class ById( + val id: Uuid, + ) +} + +fun Route.drivers() = + resource { + val service: DriverService by inject() + + get { + call.respond>(service.all()) + } + + get { params -> + call.respond(service.findById(params.id)) + } + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Images.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Images.kt new file mode 100644 index 0000000..300c27f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Images.kt @@ -0,0 +1,77 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.server.route + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.request.contentType +import io.ktor.server.request.receiveChannel +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.resource +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes +import io.ktor.server.routing.Route +import io.ktor.server.routing.contentType +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.toByteArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.local.LocalUserImageService +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Resource("/images") +class Images { + @Resource("/{id}") + class ById( + val id: Uuid, + ) +} + +fun Route.images() = + resource { + val service: LocalUserImageService by inject() + + get { + call.respond>(service.all().map { it.id }) + } + + get { params -> + val image = service.findById(params.id) + call.response.header("x-id", image.id.toHexDashString()) + call.response.header("x-hash", image.hash.toHexString()) + call.respondBytes(image.data, contentType = ContentType.parse(image.type)) + } + + contentType(ContentType.Image.Any) { + post { + val image = service.storeImage(call.receiveChannel(), call.request.contentType().toString()) + call.response.header("x-id", image.id.toHexDashString()) + call.response.header("x-hash", image.hash.toHexString()) + call.respond(HttpStatusCode.Created) + } + } + + delete { params -> + val image = service.deleteById(params.id) + call.response.header("x-id", image.id.toHexDashString()) + call.response.header("x-hash", image.hash.toHexString()) + call.respondBytes(image.data, contentType = ContentType.parse(image.type)) + } + } + +private suspend inline fun UserImageService.storeImage( + channel: ByteReadChannel, + type: String, +): UserImage = + withContext(Dispatchers.IO) { + upsert(UserImage.New(channel.toByteArray(), type)) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/LegacySettings.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/LegacySettings.kt new file mode 100644 index 0000000..a54b88d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/LegacySettings.kt @@ -0,0 +1,15 @@ +package org.sleepingcats.bridgecmdr.server.route + +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.local.LocalLegacySettingsService + +fun Route.settings() { + val legacySettings by inject() + + get("/settings") { + call.respond(legacySettings.read()) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Power.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Power.kt new file mode 100644 index 0000000..cec0dc8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Power.kt @@ -0,0 +1,24 @@ +package org.sleepingcats.bridgecmdr.server.route + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.PowerService + +fun Route.power() = + route("/power") { + val service: PowerService by inject() + + post("/off") { + service.powerOff() + call.respond(HttpStatusCode.NoContent) + } + + post("/on") { + service.powerOn() + call.respond(HttpStatusCode.NoContent) + } + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/SerialPort.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/SerialPort.kt new file mode 100644 index 0000000..2317de4 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/SerialPort.kt @@ -0,0 +1,19 @@ +package org.sleepingcats.bridgecmdr.server.route + +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.route +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo + +fun Route.serialPorts() { + route("/ports") { + val service: SerialPortService by inject() + + get { + call.respond>(service.all()) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Sources.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Sources.kt new file mode 100644 index 0000000..e289c80 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Sources.kt @@ -0,0 +1,83 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.server.route + +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.minLength +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.server.plugins.requestvalidation.RequestValidation +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.put +import io.ktor.server.resources.resource +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.SourceService +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.server.plugin.rules +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +import io.ktor.server.resources.post as action + +@OptIn(ExperimentalUuidApi::class) +@Resource("/sources") +class Sources { + @Resource("/{id}") + class ById( + val id: Uuid, + ) { + @Resource("/activate") + class Activate( + val parent: ById, + ) + } +} + +fun Route.sources() = + resource { + install(RequestValidation) { + rules { + Source.Payload::title { + minLength(1) hint "Source name may not be empty" + maxLength(255) hint "Source name may not be longer than 255 characters" + } + } + } + + val service: SourceService by inject() + + get { + call.respond>(service.all()) + } + + get { params -> + call.respond(service.findById(params.id)) + } + + post { + call.respond(service.insert(call.receive())) + } + + action { params -> + service.activateById(params.parent.id) + call.respond(HttpStatusCode.NoContent) + } + + put { params -> + call.respond(service.updateById(params.id, call.receive())) + } + + patch { params -> + call.respond(service.partialUpdateById(params.id, call.receive())) + } + + delete { params -> + call.respond(service.deleteById(params.id)) + } + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Ties.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Ties.kt new file mode 100644 index 0000000..a4ce3c9 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Ties.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.server.route + +import io.konform.validation.constraints.minimum +import io.ktor.resources.Resource +import io.ktor.server.plugins.requestvalidation.RequestValidation +import io.ktor.server.request.receive +import io.ktor.server.resources.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.patch +import io.ktor.server.resources.put +import io.ktor.server.resources.resource +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.post +import org.koin.ktor.ext.inject +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.server.plugin.rules +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Resource("/ties") +class Ties { + @Resource("/") + class Filtered( + val sourceId: Uuid? = null, + val deviceId: Uuid? = null, + ) + + @Resource("/{id}") + class ById( + val id: Uuid, + ) +} + +fun Route.ties() = + resource { + install(RequestValidation) { + rules { + Tie.Payload::inputChannel { + minimum(1) hint "Input channel must be 1 or greater" + } + + Tie.Payload::outputVideoChannel ifPresent { + minimum(1) hint "Video output channel must be 1 or greater" + } + + Tie.Payload::outputAudioChannel ifPresent { + minimum(1) hint "Audio output channel must be 1 or greater" + } + } + + rules { + Tie.Update::inputChannel ifPresent { + minimum(1) hint "Input channel must be 1 or greater" + } + + Tie.Update::outputVideoChannel ifPresent { + minimum(1) hint "Video output channel must be 1 or greater" + } + + Tie.Update::outputAudioChannel ifPresent { + minimum(1) hint "Audio output channel must be 1 or greater" + } + } + } + + val service: TieService by inject() + + get { params -> + when { + params.deviceId != null && params.sourceId != null -> { + call.respond>(service.findBySourceAndDeviceId(params.sourceId, params.deviceId)) + } + + params.sourceId != null -> { + call.respond>(service.findBySourceId(params.sourceId)) + } + + params.deviceId != null -> { + call.respond>(service.findByDeviceId(params.deviceId)) + } + + else -> { + call.respond>(service.all()) + } + } + } + + get { params -> + call.respond(service.findById(params.id)) + } + + post { + call.respond(service.insert(call.receive())) + } + + put { params -> + call.respond(service.updateById(params.id, call.receive())) + } + + patch { params -> + call.respond(service.partialUpdateById(params.id, call.receive())) + } + + delete { params -> + call.respond(service.deleteById(params.id)) + } + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/ServerContext.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/ServerContext.kt new file mode 100644 index 0000000..bf7e364 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/ServerContext.kt @@ -0,0 +1,106 @@ +package org.sleepingcats.bridgecmdr.server.security + +import com.atlassian.onetime.model.TOTPSecret +import io.github.oshai.kotlinlogging.KLogger +import io.ktor.network.tls.certificates.buildKeyStore +import io.ktor.network.tls.certificates.saveToFile +import io.ktor.network.tls.extensions.HashAlgorithm.SHA1 +import io.ktor.network.tls.extensions.SignatureAlgorithm.RSA +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.sleepingcats.bridgecmdr.Environment +import org.sleepingcats.bridgecmdr.common.isDevelopment +import org.sleepingcats.bridgecmdr.common.security.TokenService +import org.sleepingcats.bridgecmdr.server.SECURE_SERVER_PORT +import java.net.Inet4Address +import java.net.InetAddress +import java.nio.file.attribute.PosixFilePermission.OWNER_READ +import java.nio.file.attribute.PosixFilePermission.OWNER_WRITE +import java.security.Key +import java.security.KeyStore +import java.security.PublicKey +import java.security.SecureRandom +import javax.security.auth.x500.X500Principal +import kotlin.io.path.div +import kotlin.io.path.setPosixFilePermissions + +private val passwordCharSet = + ('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('!', '@', '#', '$', '%', '^', '&', '*', '(', ')') + +private fun getPasswordGenerator() = generateSequence { passwordCharSet[SecureRandom().nextInt(passwordCharSet.size)] } + +private fun generatePassword(length: Int = 64) = String(getPasswordGenerator().take(length).toList().toCharArray()) + +class ServerContext( + val bindAddress: String = "0.0.0.0", + val serverAddress: String, + val keyStore: KeyStore, + val keyStorePassword: String, + val publicKey: PublicKey, + val privateKey: Key, + val privateKeyPassword: String, + val tokenSecret: TOTPSecret, + val port: Int = SECURE_SERVER_PORT, +) { + companion object : KoinComponent { + suspend fun create( + logger: KLogger = get(), + tokenService: TokenService = get(), + ): ServerContext { + val keyStorePassword = generatePassword() + val hostAddress = + runCatching { checkNotNull(InetAddress.getLocalHost()) } + .onFailure { throwable -> logger.warn(throwable) { "Failed to get local host address" } } + .onSuccess { if (!it.isSiteLocalAddress) logger.warn { "IP Address is not site: ${it.hostAddress}" } } + .getOrNull() + val address = hostAddress?.run { this.hostAddress } ?: "127.0.0.1" + val hostNames = + hostAddress + ?.run { setOf("localhost", this.hostName, this.canonicalHostName).filterNotNull().toTypedArray() } + ?: arrayOf("localhost") + val emulatorAddress = if (isDevelopment) arrayOf("10.0.2.2") else emptyArray() + val hostIpAddresses = + hostAddress?.run { setOf("127.0.0.1", *emulatorAddress, this.hostAddress).filterNotNull().toTypedArray() } + ?: arrayOf("127.0.01", *emulatorAddress) + val privateKeyPassword = generatePassword() + val keyStoreFile = Environment.directories.user.runtime / "keystore.jks" + val keyStore = + buildKeyStore { + certificate("bridgeCmdr") { + password = privateKeyPassword + subject = X500Principal("CN=bridgeCmdr, O=Sleeping Cats, OU=FOSS, C=US") + ipAddresses = hostIpAddresses.map { Inet4Address.getByName(it) } + domains = listOf(*hostIpAddresses, *hostNames) + // Ensure the key uses an algorithm we expect. + hash = SHA1 + sign = RSA + } + }.apply { + saveToFile(keyStoreFile.toFile(), keyStorePassword) + } + + keyStoreFile.setPosixFilePermissions(setOf(OWNER_READ, OWNER_WRITE)) + + val certificate = + checkNotNull(keyStore.getCertificate("bridgeCmdr")) { "Certificate should be present in keystore" } + val publicKey = checkNotNull(certificate.publicKey) { "Public key should be present in certificate" } + logger.warn { "Public key format: ${publicKey.format}, algo: ${publicKey.algorithm}" } + val privateKey = + checkNotNull(keyStore.getKey("bridgeCmdr", privateKeyPassword.toCharArray())) { + "Private key should be present in keystore" + } + + val tokenSecret = tokenService.generateSecret() + + return ServerContext( + keyStore = keyStore, + keyStorePassword = keyStorePassword, + publicKey = publicKey, + privateKey = privateKey, + privateKeyPassword = privateKeyPassword, + serverAddress = address, + tokenSecret = tokenSecret, + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/UserPrincipal.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/UserPrincipal.kt new file mode 100644 index 0000000..9e773d5 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/UserPrincipal.kt @@ -0,0 +1,3 @@ +package org.sleepingcats.bridgecmdr.server.security + +object UserPrincipal diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/MainWindow.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/MainWindow.kt new file mode 100644 index 0000000..9cf43b8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/MainWindow.kt @@ -0,0 +1,46 @@ +package org.sleepingcats.bridgecmdr.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.rememberWindowState +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.app_icon +import bridgecmdr.composeapp.generated.resources.branding_appName +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.sleepingcats.bridgecmdr.common.isDevelopment +import org.sleepingcats.bridgecmdr.ui.service.ApplicationService + +@Composable +fun MainWindow( + applicationService: ApplicationService, + content: @Composable () -> Unit, +) { + val icon = painterResource(Res.drawable.app_icon) + val windowState = + rememberWindowState().apply { + if (isDevelopment) { + // In development mode, use a fixed window size. + size = DpSize(800.dp, 600.dp) + } else { + // In release mode, start full screen. + placement = WindowPlacement.Fullscreen + } + } + + val scope = rememberCoroutineScope() + + Window( + onCloseRequest = { scope.launch { applicationService.quit() } }, + title = stringResource(Res.string.branding_appName), + state = windowState, + icon = icon, + ) { + content() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.jvm.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.jvm.kt new file mode 100644 index 0000000..1e53507 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.jvm.kt @@ -0,0 +1,98 @@ +package org.sleepingcats.bridgecmdr.ui + +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.remember +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable +import org.sleepingcats.bridgecmdr.ui.view.BackupManagerRoute +import org.sleepingcats.bridgecmdr.ui.view.DeviceListRoute +import org.sleepingcats.bridgecmdr.ui.view.EditDeviceRoute +import org.sleepingcats.bridgecmdr.ui.view.EditSourceRoute +import org.sleepingcats.bridgecmdr.ui.view.EditTieRoute +import org.sleepingcats.bridgecmdr.ui.view.GeneralSettingsRoute +import org.sleepingcats.bridgecmdr.ui.view.ServerCodeRoute +import org.sleepingcats.bridgecmdr.ui.view.SettingsRoute +import org.sleepingcats.bridgecmdr.ui.view.SourceListRoute +import org.sleepingcats.bridgecmdr.ui.view.TieListRoute + +actual fun getStartupRoute(): Route = Dashboard + +actual fun NavGraphBuilder.platformRoutes(navController: NavController) { + navigation (startDestination = MainSettings) { + composable { SettingsRoute(navController) } + composable { ServerCodeRoute(navController) } + composable { GeneralSettingsRoute(navController) } + composable { SourceListRoute(navController) } + composable( + enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, + exitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }, + ) { EditSourceRoute(navController, it.toRoute()) } + composable { DeviceListRoute(navController) } + composable( + enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, + exitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }, + ) { EditDeviceRoute(navController, it.toRoute()) } + navigation(startDestination = TieList) { + composable { + val parent: Ties = remember(it) { navController.getBackStackEntry().toRoute() } + TieListRoute(navController, parent) + } + composable( + enterTransition = { slideInVertically(initialOffsetY = { it }) + fadeIn() }, + exitTransition = { slideOutVertically(targetOffsetY = { it }) + fadeOut() }, + ) { + val parent: Ties = remember(it) { navController.getBackStackEntry().toRoute() } + EditTieRoute(navController, parent, it.toRoute()) + } + } + composable { BackupManagerRoute(navController = navController) } + } +} + +@Serializable +object ServerCode : Route + +@Serializable +object MainSettings : Route + +@Serializable +object GeneralSettings : Route + +@Serializable +object DeviceList : Route + +@Serializable +class EditDevice( + val deviceId: String?, +) : Route + +@Serializable +object SourceList : Route + +@Serializable +class EditSource( + val sourceId: String?, +) : Route + +@Serializable +class Ties( + val sourceId: String, +) : Route + +@Serializable +object TieList : Route + +@Serializable +class EditTie( + val tieId: String?, +) : Route + +@Serializable +object BackupManager : Route diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DeviceCache.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DeviceCache.kt new file mode 100644 index 0000000..327cc32 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DeviceCache.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache + +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceCache : DataCache() { + override val key = Device::id +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DriverCache.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DriverCache.kt new file mode 100644 index 0000000..85463d8 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DriverCache.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache + +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DriverCache : DataCache() { + override val key = Driver::id +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/PortCache.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/PortCache.kt new file mode 100644 index 0000000..13497b3 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/PortCache.kt @@ -0,0 +1,8 @@ +package org.sleepingcats.bridgecmdr.ui.cache + +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache + +class PortCache : DataCache() { + override val key = PortInfo::path +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/TieCache.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/TieCache.kt new file mode 100644 index 0000000..d2b75a1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/TieCache.kt @@ -0,0 +1,12 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.cache + +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.ui.cache.core.DataCache +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class TieCache : DataCache() { + override val key = Tie::id +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/components/Pickers.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/components/Pickers.kt new file mode 100644 index 0000000..ecb1c02 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/components/Pickers.kt @@ -0,0 +1,21 @@ +package org.sleepingcats.bridgecmdr.ui.components + +import androidx.compose.runtime.Composable +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher + +@Composable +fun rememberImagePickerLauncher( + title: String? = null, + directory: PlatformFile? = null, + dialogSettings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + onResult: (PlatformFile?) -> Unit, +) = rememberFilePickerLauncher( + type = FileKitType.File("png", "jpg", "jpeg", "svg"), + title = title, + directory = directory, + dialogSettings = dialogSettings, + onResult = onResult, +) diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.jvm.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.jvm.kt new file mode 100644 index 0000000..e11cb31 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.jvm.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.bridgecmdr.ui.module + +import org.koin.dsl.bind +import org.koin.dsl.module + +val platformContextModule = + module { + // Coil mock platform context for JVM. + single { coil3.PlatformContext.INSTANCE } bind coil3.PlatformContext::class + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.jvm.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.jvm.kt new file mode 100644 index 0000000..fd83454 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.jvm.kt @@ -0,0 +1,117 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.module + +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.emptyPreferences +import okio.Path.Companion.toOkioPath +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.bind +import org.koin.dsl.module +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.Environment +import org.sleepingcats.bridgecmdr.common.backup.Exporter +import org.sleepingcats.bridgecmdr.common.backup.Importer +import org.sleepingcats.bridgecmdr.ui.cache.DeviceCache +import org.sleepingcats.bridgecmdr.ui.cache.DriverCache +import org.sleepingcats.bridgecmdr.ui.cache.PortCache +import org.sleepingcats.bridgecmdr.ui.cache.TieCache +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.DriverRepository +import org.sleepingcats.bridgecmdr.ui.repository.PortRepository +import org.sleepingcats.bridgecmdr.ui.repository.TieRepository +import org.sleepingcats.bridgecmdr.ui.service.ApplicationService +import org.sleepingcats.bridgecmdr.ui.service.SessionService +import org.sleepingcats.bridgecmdr.ui.view.model.ApplicationViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.BackupManagerViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DesktopApplicationViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DesktopDashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DeviceListViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.EditDeviceViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.EditSourceViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.EditTieViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.GeneralSettingsViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.ServerCodeViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.SettingsViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.SourceListViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.TieListViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.UserImageModel +import org.sleepingcats.core.settings.PreferencesDataStore +import kotlin.io.path.div +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +fun platformSpecificModule(exitApplication: (code: Int) -> Unit) = + module { + // + // Preferences data store + // + + single { + PreferencesDataStore( + PreferenceDataStoreFactory.createWithPath( + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, + ) { + (Environment.directories.user.config / "${Branding.qualifiedName}.preferences_pb").toOkioPath(true) + }, + ) + } + + // + // Application and session service + // + + single { ApplicationService(get(), exitApplication) } + singleOf(::SessionService) + + // + // Backup management + // + + singleOf(::Exporter) + singleOf(::Importer) + + // + // Caches + // + + singleOf(::DriverCache) + singleOf(::DeviceCache) + singleOf(::PortCache) + singleOf(::TieCache) + + // + // Repositories + // + + singleOf(::DriverRepository) + singleOf(::PortRepository) + singleOf(::DeviceRepository) + singleOf(::TieRepository) + + // + // View models + // + + singleOf(::UserImageModel) + viewModelOf(::DesktopApplicationViewModel) bind ApplicationViewModel::class + viewModelOf(::DesktopDashboardViewModel) bind DashboardViewModel::class + viewModelOf(::ServerCodeViewModel) + + viewModelOf(::SettingsViewModel) + viewModelOf(::GeneralSettingsViewModel) + + viewModelOf(::DeviceListViewModel) + factory { (deviceId: Uuid) -> EditDeviceViewModel(deviceId, get(), get(), get(), get()) } + + viewModelOf(::SourceListViewModel) + factory { (sourceId: Uuid) -> EditSourceViewModel(sourceId, get(), get(), get()) } + + factory { (sourceId: Uuid) -> TieListViewModel(sourceId, get(), get(), get(), get(), get(), get()) } + factory { (sourceId: Uuid, tieId: Uuid) -> EditTieViewModel(sourceId, tieId, get(), get(), get(), get()) } + + viewModelOf(::BackupManagerViewModel) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/DashboardActions.jvm.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/DashboardActions.jvm.kt new file mode 100644 index 0000000..0e67a2f --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/DashboardActions.jvm.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.platform + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.onClick +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.button_power +import bridgecmdr.composeapp.generated.resources.power +import bridgecmdr.composeapp.generated.resources.qrcode +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import org.sleepingcats.bridgecmdr.server.ServerController +import org.sleepingcats.bridgecmdr.server.ServerStatus.Running +import org.sleepingcats.bridgecmdr.server.ServerStatus.Stopped +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.ServerCode +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.view.model.DashboardViewModel +import org.sleepingcats.bridgecmdr.ui.view.model.DesktopDashboardViewModel + +@Composable +actual fun ColumnScope.PlatformDashboardActions( + goTo: (route: Route) -> Unit, + startOver: (route: Route) -> Unit, +) { + // HACK: To ensure only a single instance of this view model is created within the dashboard + // view, we need to use the same class type. Since multiple bindings are seen as + // different keys for this, just cast it after we get it to the expected type. + val viewModel = koinViewModel() as DesktopDashboardViewModel + val serverController: ServerController = koinInject() + + val status by serverController.status.collectAsState(Stopped) + val state by viewModel.state.collectAsState() + val scope = rememberCoroutineScope() + + val (expandPowerButton, setExpandPowerButton) = remember { mutableStateOf(false) } + + AnimatedVisibility(status is Running, enter = fadeIn(), exit = fadeOut()) { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text("Access remote server") }, + ) { + FloatingActionButton( + modifier = Modifier.padding(top = 16.dp), + onClick = { goTo(ServerCode) }, + ) { Icon(painterResource(Res.drawable.qrcode), contentDescription = "Access remote server") } + } + } + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.button_power)) }, + ) { + ExtendedFloatingActionButton( + modifier = Modifier.padding(top = 16.dp), + icon = { + Icon( + painterResource(Res.drawable.power), + contentDescription = stringResource(Res.string.button_power), + ) + }, + text = { Text(stringResource(Res.string.button_power)) }, + onClick = + when (state.powerOffTaps) { + PowerOffTaps.Single -> { + { viewModel.powerOff() } + } + + PowerOffTaps.Double -> { + { + // Rather hacky way to implement this since the FAB consumes the + // pointer input gestures. But it is more tunable this way. + if (expandPowerButton) { + viewModel.powerOff() + } else { + scope.launch { + setExpandPowerButton(true) + delay(2000) + setExpandPowerButton(false) + } + } + } + } + }, + expanded = expandPowerButton, + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DeviceRepository.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DeviceRepository.kt new file mode 100644 index 0000000..5d1750c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DeviceRepository.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.repository + +import org.sleepingcats.bridgecmdr.common.service.DeviceService +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.ui.cache.DeviceCache +import org.sleepingcats.bridgecmdr.ui.repository.core.DataRepository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DeviceRepository( + cache: DeviceCache, + private val service: DeviceService, +) : DataRepository(cache) { + override suspend fun refresh() = cache.refresh(service.all()) + + override suspend fun add(item: Device) = cache.add(service.insert(Device.Payload(item))) + + override suspend fun upsert(item: Device) = cache.upsert(service.upsert(item)) + + override suspend fun update(item: Device) = cache.update(service.updateById(item.id, Device.Payload(item))) + + override suspend fun remove(item: Device) = cache.remove(service.deleteById(item.id)) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DriverRepository.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DriverRepository.kt new file mode 100644 index 0000000..096b1df --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DriverRepository.kt @@ -0,0 +1,17 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.repository + +import org.sleepingcats.bridgecmdr.common.service.DriverService +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.ui.cache.DriverCache +import org.sleepingcats.bridgecmdr.ui.repository.core.CachingRepository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class DriverRepository( + cache: DriverCache, + private val service: DriverService, +) : CachingRepository(cache) { + override suspend fun refresh() = cache.refresh(service.all()) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/PortRepository.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/PortRepository.kt new file mode 100644 index 0000000..f09ad52 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/PortRepository.kt @@ -0,0 +1,13 @@ +package org.sleepingcats.bridgecmdr.ui.repository + +import org.sleepingcats.bridgecmdr.common.service.SerialPortService +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo +import org.sleepingcats.bridgecmdr.ui.cache.PortCache +import org.sleepingcats.bridgecmdr.ui.repository.core.CachingRepository + +class PortRepository( + cache: PortCache, + private val service: SerialPortService, +) : CachingRepository(cache) { + override suspend fun refresh() = cache.refresh(service.all()) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/TieRepository.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/TieRepository.kt new file mode 100644 index 0000000..49b97ff --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/TieRepository.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.repository + +import org.sleepingcats.bridgecmdr.common.service.TieService +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.ui.cache.TieCache +import org.sleepingcats.bridgecmdr.ui.repository.core.DataRepository +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class TieRepository( + cache: TieCache, + private val service: TieService, +) : DataRepository(cache) { + override suspend fun refresh() = cache.refresh(service.all()) + + override suspend fun add(item: Tie) = cache.add(service.insert(Tie.Payload(item))) + + override suspend fun upsert(item: Tie) = cache.upsert(service.upsert(item)) + + override suspend fun update(item: Tie) = cache.update(service.updateById(item.id, Tie.Payload(item))) + + override suspend fun remove(item: Tie) = cache.remove(service.deleteById(item.id)) + + suspend fun refreshFromSource(source: Source): List = cache.refresh(service.findBySourceId(source.id)) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/ApplicationService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/ApplicationService.kt new file mode 100644 index 0000000..ca1f6ca --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/ApplicationService.kt @@ -0,0 +1,28 @@ +package org.sleepingcats.bridgecmdr.ui.service + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.CoroutineScope +import org.sleepingcats.bridgecmdr.ui.service.xdg.AutoStartFlow + +class ApplicationService( + private val logger: KLogger, + private val exitApplication: (code: Int) -> Unit, +) { + private val exitActions = mutableListOf Unit>() + + fun onExit(action: suspend () -> Unit) { + exitActions.add(action) + } + + fun checkAutoRunIn(scope: CoroutineScope) = AutoStartFlow(logger, scope) + + suspend fun quit() { + exitActions.forEach { runCatching { it() }.onFailure { logger.error(it) { "onExit action failure" } } } + exitApplication(0) + } + + fun fatalExit(): Nothing { + exitApplication(255) + throw IllegalStateException("Unreachable code after fatalExit") + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/SessionService.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/SessionService.kt new file mode 100644 index 0000000..12721ce --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/SessionService.kt @@ -0,0 +1,51 @@ +package org.sleepingcats.bridgecmdr.ui.service + +import io.github.oshai.kotlinlogging.KLogger +import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder +import org.sleepingcats.bridgecmdr.common.isDevelopment +import org.sleepingcats.bridgecmdr.ui.service.dbus.SessionManager +import org.sleepingcats.core.Platform + +class SessionService( + private val logger: KLogger, +) { + private val useDbus: Boolean by lazy { + DBusConnectionBuilder.forSystemBus().build().use { connection -> + runCatching { + connection + .getRemoteObject( + "org.freedesktop.login1", + "/org/freedesktop/login1", + SessionManager::class.java, + ).canPowerOff() == "yes" + }.onFailure { throwable -> logger.error(throwable) { "D-Bus is not available for shutdown operations." } } + .getOrElse { false } + } + } + + fun shutdownSystem() { + // Only support power-off on POSIX systems. + if (!Platform.isPosix) return + + // Don't power-off during development mode. + if (isDevelopment) return + + if (useDbus) { + DBusConnectionBuilder.forSystemBus().build().use { connection -> + val sessionManager = + connection + .getRemoteObject( + "org.freedesktop.login1", + "/org/freedesktop/login1", + SessionManager::class.java, + ) + + logger.info { "Shutting down via D-Bus..." } + sessionManager.powerOff(false) + } + } else { + logger.info { "Shutting down via command line..." } + Runtime.getRuntime().exec(arrayOf("shutdown", "-h", "now")) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/dbus/SessionManager.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/dbus/SessionManager.kt new file mode 100644 index 0000000..974b6f1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/dbus/SessionManager.kt @@ -0,0 +1,14 @@ +package org.sleepingcats.bridgecmdr.ui.service.dbus + +import org.freedesktop.dbus.annotations.DBusInterfaceName +import org.freedesktop.dbus.annotations.DBusMemberName +import org.freedesktop.dbus.interfaces.DBusInterface + +@DBusInterfaceName("org.freedesktop.login1.Manager") +internal interface SessionManager : DBusInterface { + @DBusMemberName("CanPowerOff") + fun canPowerOff(): String + + @DBusMemberName("PowerOff") + fun powerOff(interactive: Boolean) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/AutoStartFlow.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/AutoStartFlow.kt new file mode 100644 index 0000000..9891053 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/AutoStartFlow.kt @@ -0,0 +1,102 @@ +@file:OptIn(ExperimentalForInheritanceCoroutinesApi::class) + +package org.sleepingcats.bridgecmdr.ui.service.xdg + +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.branding_appName +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.ui.service.xdg.entry.AsciiString +import org.sleepingcats.bridgecmdr.ui.service.xdg.entry.DesktopEntryFile +import org.sleepingcats.bridgecmdr.ui.service.xdg.entry.IconString +import org.sleepingcats.bridgecmdr.ui.service.xdg.entry.ListOfAsciiStrings +import org.sleepingcats.core.BaseDirectories +import kotlin.io.path.createParentDirectories +import kotlin.io.path.div +import kotlin.io.path.exists +import kotlin.io.path.writeLines + +class AutoStartFlow private constructor( + private val logger: KLogger, + private val flow: MutableStateFlow, +) : Flow by flow { + companion object { + operator fun invoke( + logger: KLogger, + scope: CoroutineScope, + ): AutoStartFlow = + AutoStartFlow(logger, MutableStateFlow(false)).apply { + scope.launch(Dispatchers.IO) { + if (!autoStartFile.exists()) return@launch + runCatching { + flow.update { true } + val desktopFile = DesktopEntryFile.read(autoStartFile) + + // Ensure the path is correct. + val exec = desktopFile.get("Desktop Entry", "Exec", AsciiString) + // TODO: Exec path must be determined properly. + if (exec != null && exec == "ExecPathPlaceholder") return@runCatching + logger.info { "Correcting autostart exec path: $exec" } + + // Fix the path. + desktopFile.set( + "Desktop Entry", + "Exec", + "ExecPathPlaceholder", + AsciiString, + ) + + autoStartFile.writeLines(desktopFile) + }.onFailure { flow.update { false } } + .onFailure { throwable -> logger.error(throwable) { "Failed to correct auto-start entry" } } + } + } + } + + private val autoStartFile by lazy { + (BaseDirectories.user.config / "autostart" / "${Branding.applicationId}.desktop").also { path -> + logger.debug { "Auto-start file path: $path" } + } + } + + suspend fun enable() = + withContext(Dispatchers.IO) { + val appName = getString(Res.string.branding_appName) + runCatching { + autoStartFile.createParentDirectories() + val desktopFile = + DesktopEntryFile.create { + section("Desktop Entry") { + set("Type", "Application", AsciiString) + set("Version", "1.0", AsciiString) + set("Name", appName) + set("Comment", "Launch $appName at login") + set("Exec", "ExecPathPlaceholder", AsciiString) // TODO: Set correct exec path + set("Icon", Branding.applicationId, IconString) + set("Terminal", false) + set("Categories", listOf("Utility"), ListOfAsciiStrings) + } + } + autoStartFile.writeLines(desktopFile) + }.onSuccess { flow.update { true } } + .onFailure { throwable -> logger.error(throwable) { "Failed to enable auto-start entry" } } + .getOrElse { } + } + + suspend fun disable() = + withContext(Dispatchers.IO) { + runCatching { if (autoStartFile.exists()) autoStartFile.toFile().delete() } + .onSuccess { flow.update { false } } + .onFailure { throwable -> logger.error(throwable) { "Failed to disable auto-start entry" } } + .getOrElse { } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Builder.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Builder.kt new file mode 100644 index 0000000..d7a951c --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Builder.kt @@ -0,0 +1,88 @@ +package org.sleepingcats.bridgecmdr.ui.service.xdg.entry + +class Builder internal constructor() { + private var file = DesktopEntryFile() + + fun comment(content: String) { + file.lines.add(Comment(content)) + } + + fun section( + name: String, + block: SectionBuilder.() -> Unit, + ) { + file.lines.add(SectionBuilder(name).apply(block).build()) + } + + fun build(): DesktopEntryFile = file +} + +class SectionBuilder internal constructor( + name: String, +) { + private val section = Section(name) + + fun comment(content: String) { + section.lines.add(Comment(content)) + } + + fun set( + name: String, + value: String, + encoder: Value, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, encoder.encode(value))) + } + + fun set( + name: String, + value: String, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, LocaleString.encode(value))) + } + + fun set( + name: String, + value: List, + encoder: Value>, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, encoder.encode(value))) + } + + fun set( + name: String, + value: List, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, ListOfLocaleStrings.encode(value))) + } + + fun set( + name: String, + value: Int, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, NumericValue.encode(value.toFloat()))) + } + + fun set( + name: String, + value: Float, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, NumericValue.encode(value))) + } + + fun set( + name: String, + value: Boolean, + ) { + require(section.lines.find { it is Entry && it.name == name } == null) { "Entry for \"$this\" already exists" } + section.lines.add(Entry(name, BooleanValue.encode(value))) + } + + fun build(): Section = section +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/DesktopEntryFile.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/DesktopEntryFile.kt new file mode 100644 index 0000000..11c02d3 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/DesktopEntryFile.kt @@ -0,0 +1,189 @@ +package org.sleepingcats.bridgecmdr.ui.service.xdg.entry + +import java.nio.file.Path + +class DesktopEntryFile( + internal val lines: MutableList = mutableListOf(), +) : Iterable { + companion object { + fun create(block: Builder.() -> Unit): DesktopEntryFile = Builder().apply(block).build() + + fun read(path: Path): DesktopEntryFile = parse(path.toFile().readText()) + + fun parse(text: String): DesktopEntryFile { + val lineEnding = if (text.contains("\r\n")) "\r\n" else "\n" + return parse(text, lineEnding) + } + + fun parse( + text: String, + lineEnding: String = "\n", + ): DesktopEntryFile { + val rawLines = text.split(lineEnding) + return parse(rawLines) + } + + fun parse(rawLines: Iterable): DesktopEntryFile { + val lines = mutableListOf() + var section: Section? = null + + for ((index, rawLine) in rawLines.withIndex()) { + val openBracketIndex = rawLine.indexOf('[') + val closeBracketIndex = rawLine.indexOf(']') + val commentHashIndex = rawLine.indexOf('#') + val equalsIndexIndex = rawLine.indexOf('=') + + when { + openBracketIndex == 0 && closeBracketIndex > 0 -> { + val groupName = rawLine.substring(1, closeBracketIndex) + check(groupName.isValidGroupName()) { "$index: error: Invalid group name \"$groupName\"" } + if (section != null) { + lines.add(section) + } + + section = Section(groupName) + } + + commentHashIndex == 0 -> { + if (section != null) { + section.lines.add(Comment(rawLine.substring(1))) + } else { + lines.add(Comment(rawLine.substring(1))) + } + } + + equalsIndexIndex > 0 -> { + check(section != null) { "$index: error: Entry found outside of any section" } + + val name = rawLine.substring(0, equalsIndexIndex).trimEnd() + val value = rawLine.substring(equalsIndexIndex + 1).trimStart() + check(name.isValidKey()) { "$index: error: Invalid key \"$name\"" } + + section.lines.add(Entry(name, value)) + } + + rawLine.isBlank() -> { + if (section != null) { + section.lines.add(BlankLine(rawLine)) + } else { + lines.add(BlankLine(rawLine)) + } + + continue + } + + else -> { + throw IllegalStateException("$index: error: Unknown data at \"$rawLine\"") + } + } + } + + if (section != null) { + lines.add(section) + } + + check(lines.find { it is Section && it.name == "Desktop Entry" } != null) { + "No \"Desktop Entry\" section found" + } + + return DesktopEntryFile(lines) + } + } + + override operator fun iterator() = + iterator { + for (line in lines) { + when (line) { + is Section -> { + yield(line.rawText) + for (sectionLine in line.lines) { + yield(sectionLine.rawText) + } + } + + else -> { + yield(line.rawText) + } + } + } + } + + private fun getRawValue( + section: String, + name: String, + ): String? { + require(section.isValidGroupName()) { "Invalid section name: \"$section\"" } + require(name.isValidKey()) { "Invalid key name: \"$name\"" } + val sec = lines.find { it is Section && it.name == section }?.let { it as? Section } ?: return null + val entry = sec.lines.find { it is Entry && it.name == name }?.let { it as? Entry } ?: return null + return entry.value + } + + fun > get( + section: String, + name: String, + decodeAs: T, + ): V? = getRawValue(section, name)?.let { decodeAs.decode(it) } + + private fun setRawValue( + section: String, + name: String, + value: String, + ) { + require(section.isValidGroupName()) { "Invalid section name: \"$section\"" } + require(name.isValidKey()) { "Invalid key name: \"$name\"" } + + val sec = + lines.find { it is Section && it.name == section }?.let { it as? Section } + ?: Section(section).also { lines.add(it) } + + val entry = sec.lines.find { it is Entry && it.name == name }?.let { it as? Entry } + if (entry != null) { + entry.value = value + return + } + + sec.lines.add(Entry(name, value)) + } + + fun > set( + section: String, + name: String, + value: String, + encodeAs: T, + ) { + setRawValue(section, name, encodeAs.encode(value)) + } + + fun set( + section: String, + name: String, + value: String, + ) { + setRawValue(section, name, LocaleString.encode(value)) + } + + fun set( + section: String, + name: String, + value: Int, + ) { + setRawValue(section, name, NumericValue.encode(value.toFloat())) + } + + fun set( + section: String, + name: String, + value: Float, + ) { + setRawValue(section, name, NumericValue.encode(value)) + } + + fun set( + section: String, + name: String, + value: Boolean, + ) { + setRawValue(section, name, BooleanValue.encode(value)) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Lines.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Lines.kt new file mode 100644 index 0000000..caabe64 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Lines.kt @@ -0,0 +1,39 @@ +package org.sleepingcats.bridgecmdr.ui.service.xdg.entry + +sealed interface Line { + val rawText: String +} + +sealed interface SectionLine { + val rawText: String +} + +@ConsistentCopyVisibility +data class Comment internal constructor( + val content: String, +) : Line, + SectionLine { + override val rawText: String get() = "#$content" +} + +@ConsistentCopyVisibility +data class BlankLine internal constructor( + override val rawText: String = "", +) : Line, + SectionLine + +@ConsistentCopyVisibility +data class Section internal constructor( + val name: String, + val lines: MutableList = mutableListOf(), +) : Line { + override val rawText: String get() = "[$name]" +} + +@ConsistentCopyVisibility +data class Entry internal constructor( + val name: String, + var value: String, +) : SectionLine { + override val rawText: String get() = "$name=$value" +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Utilities.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Utilities.kt new file mode 100644 index 0000000..756d290 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Utilities.kt @@ -0,0 +1,130 @@ +package org.sleepingcats.bridgecmdr.ui.service.xdg.entry + +import kotlin.text.iterator + +internal val ASCII = 0x20.toChar()..0x7e.toChar() + +internal val UPPER = 'A'..'Z' + +internal val LOWER = 'a'..'z' + +internal val DIGIT = '0'..'9' + +internal fun String.isValidGroupName(): Boolean = + this.isNotEmpty() && this.all { char -> (char in ASCII) && (char != '[') && (char != ']') } + +internal fun String.isValidKey(): Boolean = + this.isNotEmpty() && + this.all { char -> + (char in UPPER) || + (char in LOWER) || + (char in DIGIT) || + (char == '@') || + (char == '_') || + (char == '-') || + (char == '[') || + (char == ']') + } + +internal fun String.isValidAsciiString(): Boolean = this.all { char -> char in ASCII } + +private fun String.unescape( + builder: StringBuilder, + atIndex: Int, +): Int { + val index = atIndex + 1 + check(index < this.length) { "Invalid escape sequence at end of string" } + when (this[index]) { + 's' -> builder.append(' ') + 'n' -> builder.append('\n') + 't' -> builder.append('\t') + 'r' -> builder.append('\r') + '\\' -> builder.append('\\') + else -> throw IllegalStateException("Invalid escape sequence: \\${this[index]}") + } + + return index +} + +internal fun String.unescape(): String = + StringBuilder() + .apply { + var index = 0 + + while (index < this@unescape.length) { + when (val char = this@unescape[index]) { + '\\' -> index = unescape(this, index) + else -> append(char) + } + + index += 1 + } + }.toString() + +internal fun String.escape(): String = + StringBuilder() + .apply { + for (char in this@escape) { + when (char) { + '\n' -> append("\\n") + '\t' -> append("\\t") + '\r' -> append("\\r") + '\\' -> append("\\\\") + else -> append(char) + } + } + }.toString() + +private fun CharIterator.unescapeInList(builder: StringBuilder) { + check(this.hasNext()) { "Invalid escape sequence at end of string" } + when (val char = this.next()) { + // Leave these alone for the parser to handle + 's', 'n', 't', 'r', '\\' -> builder.append("'\\$char") + + // Only unescape \; + ';' -> builder.append(';') + + // Anything else is invalid + else -> throw IllegalStateException("Invalid escape sequence: \\$char") + } +} + +private fun MutableList.addString(builder: StringBuilder) { + this.add(builder.toString()) + builder.clear() +} + +// Expected: results +// "" -> [] +// ";" -> [] +// "a" -> ["a"] +// "a;" -> ["a"] +// "a;b;c" -> ["a", "b", "c"] +// "a\;b;c" -> ["a;b", "c"] +// "a\\;b;c" -> ["a\\","b","c"] +// "a;b;c;" -> ["a", "b", "c"] +// "a\;b;c;" -> ["a;b", "c"] +// "a;;c" -> ["a","","c"] +// "a;;" -> ["a",""] +// "a;; " -> ["a","", " "] +// "a\\;b;c\;;" -> ["a\\","b","c;"] + +internal fun String.splitList(): List = + mutableListOf().also list@{ list -> + // Simplify empty item handling by treating these two cases + // as an empty list. Now any trailing semicolon indicates + // an empty item. + if (this == "" || this == ";") return@list + + val iterator = this.iterator() + val builder = StringBuilder() + while (iterator.hasNext()) { + when (val char = iterator.next()) { + '\\' -> iterator.unescapeInList(builder) + ';' -> list.addString(builder) + else -> builder.append(char) + } + } + + if (builder.isNotEmpty()) list.add(builder.toString()) + } diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Value.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Value.kt new file mode 100644 index 0000000..bffa348 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Value.kt @@ -0,0 +1,90 @@ +package org.sleepingcats.bridgecmdr.ui.service.xdg.entry + +sealed interface Value { + fun decode(raw: String): T + + fun encode(value: T): String +} + +sealed interface AsciiString : Value { + companion object : AsciiString + + override fun decode(raw: String): String { + check(raw.isValidAsciiString()) { "Invalid ASCII string: \"${raw}\"" } + return raw.unescape() + } + + override fun encode(value: String): String { + check(value.isValidAsciiString()) { "Invalid ASCII string: \"${value.escape()}\"" } + return value.escape() + } +} + +sealed interface LocaleString : Value { + companion object : LocaleString + + override fun decode(raw: String): String = raw.unescape() + + override fun encode(value: String): String = value.escape() +} + +sealed interface IconString : Value { + companion object : IconString + + override fun decode(raw: String): String = raw.unescape() + + override fun encode(value: String): String = value.escape() +} + +sealed interface BooleanValue : Value { + companion object : BooleanValue + + override fun decode(raw: String): Boolean = + when (raw) { + "true" -> true + "false" -> false + else -> throw IllegalStateException("Invalid boolean value: \"$raw\"") + } + + override fun encode(value: Boolean): String = if (value) "true" else "false" +} + +sealed interface NumericValue : Value { + companion object : NumericValue + + override fun decode(raw: String): Float = checkNotNull(raw.toFloatOrNull()) { "Invalid numeric value: \"$raw\"" } + + override fun encode(value: Float): String = value.toString() +} + +sealed interface ListOf> : Value> { + val entryOf: T + + override fun decode(raw: String): List = raw.splitList().map { entryOf.decode(it) } + + override fun encode(value: List): String = + value.joinToString(";", postfix = ";") { + entryOf.encode(it).replace(";", "\\;") + } +} + +sealed interface ListOfAsciiStrings : ListOf { + companion object : ListOfAsciiStrings + + override val entryOf: AsciiString + get() = AsciiString +} + +sealed interface ListOfLocaleStrings : ListOf { + companion object : ListOfLocaleStrings + + override val entryOf: LocaleString + get() = LocaleString +} + +sealed interface ListOfIconStrings : ListOf { + companion object : ListOfIconStrings + + override val entryOf: IconString + get() = IconString +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/BackupManagerView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/BackupManagerView.kt new file mode 100644 index 0000000..06827eb --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/BackupManagerView.kt @@ -0,0 +1,120 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.backup_export +import bridgecmdr.composeapp.generated.resources.backup_export_supporting +import bridgecmdr.composeapp.generated.resources.backup_import +import bridgecmdr.composeapp.generated.resources.backup_import_supporting +import bridgecmdr.composeapp.generated.resources.backup_load +import bridgecmdr.composeapp.generated.resources.backup_suggestion +import bridgecmdr.composeapp.generated.resources.branding_appName +import bridgecmdr.composeapp.generated.resources.export +import bridgecmdr.composeapp.generated.resources.import +import bridgecmdr.composeapp.generated.resources.settings_backups +import bridgecmdr.composeapp.generated.resources.unknown_error +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.extension.invoke +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.view.model.BackupManagerViewModel + +@Composable +fun BackupManagerRoute(navController: NavController) { + BackupManagerView( + goBack = { navController.popBackStack() }, + ) +} + +@Composable +fun BackupManagerView( + goBack: () -> Unit, + viewModel: BackupManagerViewModel = koinViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + val scope = rememberCoroutineScope() + + val exportPickerLauncher = rememberFileSaverLauncher { file -> file?.let { scope.launch { viewModel.export(file) } } } + + val importPickerLauncher = + rememberFilePickerLauncher( + type = FileKitType.File("zip"), + title = stringResource(Res.string.backup_load), + ) { file -> file?.let { scope.launch { viewModel.import(file) } } } + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + val appName = stringResource(Res.string.branding_appName) + val backupSuggestion = stringResource(Res.string.backup_suggestion) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings_backups)) }, + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) { + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.export), null) }, + headlineContent = { Text(stringResource(Res.string.backup_export)) }, + supportingContent = { Text(stringResource(Res.string.backup_export_supporting, appName)) }, + modifier = + Modifier.clickable { + exportPickerLauncher.launch( + suggestedName = backupSuggestion, + extension = "zip", + ) + }, + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.import), null) }, + headlineContent = { Text(stringResource(Res.string.backup_import)) }, + supportingContent = { Text(stringResource(Res.string.backup_import_supporting, appName)) }, + modifier = Modifier.clickable { importPickerLauncher() }, + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DeviceListView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DeviceListView.kt new file mode 100644 index 0000000..aa5386b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DeviceListView.kt @@ -0,0 +1,182 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_delete +import bridgecmdr.composeapp.generated.resources.action_keep +import bridgecmdr.composeapp.generated.resources.delete +import bridgecmdr.composeapp.generated.resources.devices_addDevice +import bridgecmdr.composeapp.generated.resources.devices_deleteMonitor +import bridgecmdr.composeapp.generated.resources.devices_deleteSwitch +import bridgecmdr.composeapp.generated.resources.devices_deleteWarning +import bridgecmdr.composeapp.generated.resources.devices_doYouWantToDeleteDevice +import bridgecmdr.composeapp.generated.resources.devices_doYouWantToDeleteMonitor +import bridgecmdr.composeapp.generated.resources.devices_doYouWantToDeleteSwitch +import bridgecmdr.composeapp.generated.resources.monitor +import bridgecmdr.composeapp.generated.resources.plus +import bridgecmdr.composeapp.generated.resources.settings_devices +import bridgecmdr.composeapp.generated.resources.unknown_driver +import bridgecmdr.composeapp.generated.resources.unknown_error +import bridgecmdr.composeapp.generated.resources.video_switch +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.common.service.model.DriverKind +import org.sleepingcats.bridgecmdr.ui.EditDevice +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.ConfirmModal +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.component.hideOnScroll +import org.sleepingcats.bridgecmdr.ui.view.model.DeviceListViewModel +import kotlin.uuid.ExperimentalUuidApi + +@Composable +fun DeviceListRoute(navController: NavController) = + DeviceListView( + goBack = { navController.popBackStack() }, + goTo = { r -> navController.navigate(r) }, + ) + +@Composable +private fun DeviceListView( + goBack: () -> Unit, + goTo: (Route) -> Unit, + viewModel: DeviceListViewModel = koinViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + val scope = rememberCoroutineScope() + + fun addDevice() = goTo(EditDevice(null)) + + fun editDevice(device: Device) = goTo(EditDevice(device.id.toHexDashString())) + + fun deleteQuestion(driver: Driver?) = + when (driver?.kind) { + DriverKind.Monitor -> Res.string.devices_doYouWantToDeleteMonitor + DriverKind.Switch -> Res.string.devices_doYouWantToDeleteSwitch + else -> Res.string.devices_doYouWantToDeleteDevice + } + + val deleteDevice = + ConfirmModal>( + title = { (_, driver) -> Text(stringResource(deleteQuestion(driver))) }, + text = { Text(stringResource(Res.string.devices_deleteWarning)) }, + confirmButton = { confirm -> TextButton(onClick = confirm) { Text(stringResource(Res.string.action_delete)) } }, + cancelButton = { cancel -> TextButton(onClick = cancel) { Text(stringResource(Res.string.action_keep)) } }, + ) { (device), confirm -> if (confirm == true) scope.launch { viewModel.delete(device) { viewModel.refresh() } } } + + val showActions = rememberSaveable { mutableStateOf(true) } + val connection = hideOnScroll(showActions) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + // TODO: Support pull to refresh on errors. + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings_devices)) }, + ) + }, + floatingActionButton = { + SimpleTooltip( + tooltip = { Text(stringResource(Res.string.devices_addDevice)) }, + position = TooltipAnchorPosition.Start, + ) { + AnimatedVisibility(visible = showActions.value, enter = fadeIn(), exit = fadeOut()) { + FloatingActionButton(onClick = { addDevice() }) { + Icon(painterResource(Res.drawable.plus), contentDescription = stringResource(Res.string.devices_addDevice)) + } + } + } + }, + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues).fillMaxSize().nestedScroll(connection)) { + items(state.devices, key = { it.id }) { device -> + val driver = state.drivers.find { device.driverId == it.id } + val icon = if (driver?.kind == DriverKind.Monitor) Res.drawable.monitor else Res.drawable.video_switch + ListItem( + modifier = Modifier.clickable(enabled = !isLoading) { editDevice(device) }, + leadingContent = { Icon(painterResource(icon), contentDescription = null) }, + headlineContent = { Text(device.title) }, + supportingContent = { Text(driver?.title ?: stringResource(Res.string.unknown_driver)) }, + trailingContent = { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { + Text( + when (driver?.kind) { + DriverKind.Monitor -> stringResource(Res.string.devices_deleteMonitor) + DriverKind.Switch -> stringResource(Res.string.devices_deleteSwitch) + else -> stringResource(Res.string.devices_deleteSwitch) + }, + ) + }, + ) { + IconButton(onClick = { deleteDevice(Pair(device, driver)) }) { + Icon( + painterResource(Res.drawable.delete), + contentDescription = + when (driver?.kind) { + DriverKind.Monitor -> stringResource(Res.string.devices_deleteMonitor) + DriverKind.Switch -> stringResource(Res.string.devices_deleteSwitch) + else -> stringResource(Res.string.devices_deleteSwitch) + }, + ) + } + } + }, + ) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditDeviceView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditDeviceView.kt new file mode 100644 index 0000000..80751e1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditDeviceView.kt @@ -0,0 +1,250 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_add +import bridgecmdr.composeapp.generated.resources.action_close +import bridgecmdr.composeapp.generated.resources.action_save +import bridgecmdr.composeapp.generated.resources.close +import bridgecmdr.composeapp.generated.resources.device_addDevice +import bridgecmdr.composeapp.generated.resources.device_driver +import bridgecmdr.composeapp.generated.resources.device_driver_notNil +import bridgecmdr.composeapp.generated.resources.device_editDevice +import bridgecmdr.composeapp.generated.resources.device_experimentalDriver +import bridgecmdr.composeapp.generated.resources.device_hostname +import bridgecmdr.composeapp.generated.resources.device_pathType +import bridgecmdr.composeapp.generated.resources.device_path_noHostname +import bridgecmdr.composeapp.generated.resources.device_path_noPort +import bridgecmdr.composeapp.generated.resources.device_path_noType +import bridgecmdr.composeapp.generated.resources.device_port +import bridgecmdr.composeapp.generated.resources.device_title +import bridgecmdr.composeapp.generated.resources.device_title_maxLength +import bridgecmdr.composeapp.generated.resources.device_title_notBlank +import bridgecmdr.composeapp.generated.resources.flask +import bridgecmdr.composeapp.generated.resources.unknown_error +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.sleepingcats.bridgecmdr.common.service.model.PathType +import org.sleepingcats.bridgecmdr.ui.EditDevice +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.OutlinedSelectField +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.component.errorIconOf +import org.sleepingcats.bridgecmdr.ui.component.from +import org.sleepingcats.bridgecmdr.ui.component.hintsOf +import org.sleepingcats.bridgecmdr.ui.component.isErrored +import org.sleepingcats.bridgecmdr.ui.component.minHeighBugFixForOutlinedTextField +import org.sleepingcats.bridgecmdr.ui.scaffold.FullscreenModalScaffold +import org.sleepingcats.bridgecmdr.ui.view.model.EditDeviceViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +operator fun MutableStateFlow.component1(): StateFlow = this.asStateFlow() + +operator fun MutableStateFlow.component2(): (function: (T) -> T) -> Unit = { update(it) } + +@Composable +fun EditDeviceRoute( + navController: NavController, + route: EditDevice, +) { + EditDeviceView( + deviceId = route.deviceId?.let { Uuid.parseHexDash(it) } ?: Uuid.NIL, + goBack = { navController.popBackStack() }, + ) +} + +@Composable +fun EditDeviceView( + deviceId: Uuid, + goBack: () -> Unit, + viewModel: EditDeviceViewModel = koinViewModel { parametersOf(deviceId) }, +) { + val isLoading by viewModel.isLoading.collectAsState() + val drivers by viewModel.drivers.collectAsState() + val ports by viewModel.ports.collectAsState() + + val state by viewModel.state.collectAsState() + val title by viewModel.title.collectAsState() + val driver by viewModel.driver.collectAsState() + val pathType by viewModel.pathType.collectAsState() + val hostname by viewModel.hostname.collectAsState() + val port by viewModel.port.collectAsState() + + val modalTitle = + when (viewModel.id) { + Uuid.NIL -> Res.string.device_addDevice + else -> Res.string.device_editDevice + } + + val modalConfirmLabel = + when (viewModel.id) { + Uuid.NIL -> Res.string.action_add + else -> Res.string.action_save + } + + val hints = + hintsOf( + Res.string.device_title_notBlank, + Res.string.device_title_maxLength, + Res.string.device_driver_notNil, + Res.string.device_path_noHostname, + Res.string.device_path_noPort, + Res.string.device_path_noType, + ) + + // Bugfix for layout issue with OutlinedTextField and minLines/maxLines/singleLine + val minHeight = minHeighBugFixForOutlinedTextField(withSupportText = true) + + LoadingOverlay(isLoading) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + FullscreenModalScaffold( + titleContent = { Text(stringResource(modalTitle)) }, + navigationIcon = { + IconButton(onClick = { goBack() }) { + Icon(painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.action_close)) + } + }, + actions = { + TextButton(onClick = { viewModel.save { goBack() } }) { + Text(stringResource(modalConfirmLabel)) + } + }, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().height(minHeight), + label = { Text(stringResource(Res.string.device_title)) }, + singleLine = true, + value = title, + isError = isErrored(state.titleError), + onValueChange = { viewModel.setTitle(it.take(255)) }, + supportingText = { Text((state.titleError from hints) ?: "") }, + trailingIcon = errorIconOf(state.titleError), + ) + OutlinedSelectField( + modifier = Modifier.fillMaxWidth(), + value = driver, + onValueChanged = { viewModel.setDriver(it) }, + label = { Text(stringResource(Res.string.device_driver)) }, + isError = isErrored(state.driverError), + supportingText = { Text((state.driverError from hints) ?: "") }, + trailingIcon = errorIconOf(state.driverError), + valueText = { it.title }, + options = drivers, + optionKey = { it.id }, + optionContent = { + Row { + Text( + modifier = Modifier.weight(1f, fill = true), + text = it.title, + ) + if (it.experimental) { + SimpleTooltip( + tooltip = { Text(stringResource(Res.string.device_experimentalDriver)) }, + position = TooltipAnchorPosition.Start, + ) { + Icon( + painterResource(Res.drawable.flask), + contentDescription = stringResource(Res.string.device_experimentalDriver), + modifier = Modifier.size(16.dp), + ) + } + } + } + }, + ) + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedSelectField( + modifier = Modifier.fillMaxWidth(0.3f).padding(top = 8.dp, end = 16.dp), + value = pathType, + onValueChanged = { viewModel.setPathType(it) }, + placeholder = { Text(stringResource(Res.string.device_pathType)) }, + isError = isErrored(state.pathTypeError), + supportingText = { Text((state.pathTypeError from hints) ?: "") }, + trailingIcon = errorIconOf(state.pathTypeError), + valueText = { stringResource(it.label) }, + options = PathType.entries, + ) + when (pathType) { + PathType.Port -> { + OutlinedSelectField( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.device_port)) }, + value = port, + onValueChanged = { viewModel.setPort(it) }, + isError = isErrored(state.pathError), + supportingText = { Text((state.pathError from hints) ?: "") }, + trailingIcon = errorIconOf(state.pathError), + valueText = { "${it.manufacturer} ${it.title} ${it.serialNumber}" }, + options = ports, + ) + } + + PathType.Remote -> { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().height(minHeight), + label = { Text(stringResource(Res.string.device_hostname)) }, + singleLine = true, + value = hostname, + onValueChange = { viewModel.setHostname(it.take(255 - PathType.Remote.prefix.length)) }, + isError = isErrored(state.pathError), + supportingText = { Text((state.pathError from hints) ?: "") }, + trailingIcon = errorIconOf(state.pathError), + ) + } + + null -> { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(minHeight - 8.dp), + enabled = false, + readOnly = true, + singleLine = true, + value = "", + onValueChange = { }, + isError = isErrored(state.pathError), + supportingText = { Text((state.pathError from hints) ?: "") }, + trailingIcon = errorIconOf(state.pathError), + ) + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditSourceView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditSourceView.kt new file mode 100644 index 0000000..ad190a0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditSourceView.kt @@ -0,0 +1,163 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_add +import bridgecmdr.composeapp.generated.resources.action_close +import bridgecmdr.composeapp.generated.resources.action_save +import bridgecmdr.composeapp.generated.resources.close +import bridgecmdr.composeapp.generated.resources.source_addSource +import bridgecmdr.composeapp.generated.resources.source_editSource +import bridgecmdr.composeapp.generated.resources.source_title +import bridgecmdr.composeapp.generated.resources.source_title_maxLength +import bridgecmdr.composeapp.generated.resources.source_title_notBlank +import bridgecmdr.composeapp.generated.resources.unknown_error +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.sleepingcats.bridgecmdr.common.extension.invoke +import org.sleepingcats.bridgecmdr.ui.EditSource +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.Ties +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.ImagePreview +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.errorIconOf +import org.sleepingcats.bridgecmdr.ui.component.from +import org.sleepingcats.bridgecmdr.ui.component.hintsOf +import org.sleepingcats.bridgecmdr.ui.component.isErrored +import org.sleepingcats.bridgecmdr.ui.component.minHeighBugFixForOutlinedTextField +import org.sleepingcats.bridgecmdr.ui.components.rememberImagePickerLauncher +import org.sleepingcats.bridgecmdr.ui.scaffold.FullscreenModalScaffold +import org.sleepingcats.bridgecmdr.ui.view.model.EditSourceViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Composable +fun EditSourceRoute( + navController: NavController, + route: EditSource, +) { + EditSourceView( + sourceId = route.sourceId?.let { Uuid.parseHexDash(it) } ?: Uuid.NIL, + goBack = { navController.popBackStack() }, + goTo = { route -> + // Navigate to the new route, popping the current one to avoid jumping back to this view. + navController.navigate(route) { + popUpTo(navController.currentBackStackEntry?.destination?.route ?: return@navigate) { + inclusive = true + } + } + }, + ) +} + +@Composable +fun EditSourceView( + sourceId: Uuid, + goBack: () -> Unit, + goTo: (Route) -> Unit, + viewModel: EditSourceViewModel = koinViewModel { parametersOf(sourceId) }, +) { + val isLoading by viewModel.isLoading.collectAsState() + + val state by viewModel.state.collectAsState() + val title by viewModel.title.collectAsState() + val image by viewModel.image.collectAsState() + + val modalTitle = + when (viewModel.id) { + Uuid.NIL -> Res.string.source_addSource + else -> Res.string.source_editSource + } + + val modalConfirmLabel = + when (viewModel.id) { + Uuid.NIL -> Res.string.action_add + else -> Res.string.action_save + } + + val hints = + hintsOf( + Res.string.source_title_notBlank, + Res.string.source_title_maxLength, + ) + + // Bugfix for layout issue with OutlinedTextField and minLines/maxLines/singleLine + val minHeight = minHeighBugFixForOutlinedTextField(withSupportText = true) + + LoadingOverlay(isLoading) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + val scope = rememberCoroutineScope() + val pickImage = + rememberImagePickerLauncher { file -> + if (file != null) { + scope.launch { + val imageId = viewModel.uploadImage(file) ?: return@launch + viewModel.setImage(imageId) + } + } + } + + FullscreenModalScaffold( + titleContent = { Text(stringResource(modalTitle)) }, + navigationIcon = { + IconButton(onClick = { goBack() }) { + Icon(painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.action_close)) + } + }, + actions = { + TextButton(onClick = { viewModel.save { goTo(Ties(it.id.toHexDashString())) } }) { + Text(stringResource(modalConfirmLabel)) + } + }, + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth().height(minHeight), + label = { Text(stringResource(Res.string.source_title)) }, + singleLine = true, + value = title, + isError = isErrored(state.titleError), + onValueChange = { viewModel.setTitle(it.take(255)) }, + supportingText = { Text((state.titleError from hints) ?: "") }, + trailingIcon = errorIconOf(state.titleError), + ) + ImagePreview( + model = image, + onClick = { pickImage() }, + modifier = Modifier.align(Alignment.CenterHorizontally), + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + hoverContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditTieView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditTieView.kt new file mode 100644 index 0000000..9b56b26 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditTieView.kt @@ -0,0 +1,215 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_add +import bridgecmdr.composeapp.generated.resources.action_close +import bridgecmdr.composeapp.generated.resources.action_save +import bridgecmdr.composeapp.generated.resources.close +import bridgecmdr.composeapp.generated.resources.tie_addTie +import bridgecmdr.composeapp.generated.resources.tie_audioChannel_minimum +import bridgecmdr.composeapp.generated.resources.tie_deviceId_notNil +import bridgecmdr.composeapp.generated.resources.tie_editTie +import bridgecmdr.composeapp.generated.resources.tie_inputChannel_minimum +import bridgecmdr.composeapp.generated.resources.tie_videoChannel_minimum +import bridgecmdr.composeapp.generated.resources.unknown_error +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.ui.EditTie +import org.sleepingcats.bridgecmdr.ui.Ties +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.OutlinedNumericField +import org.sleepingcats.bridgecmdr.ui.component.OutlinedSelectField +import org.sleepingcats.bridgecmdr.ui.component.errorIconOf +import org.sleepingcats.bridgecmdr.ui.component.from +import org.sleepingcats.bridgecmdr.ui.component.hintsOf +import org.sleepingcats.bridgecmdr.ui.component.isErrored +import org.sleepingcats.bridgecmdr.ui.component.minHeighBugFixForOutlinedTextField +import org.sleepingcats.bridgecmdr.ui.scaffold.FullscreenModalScaffold +import org.sleepingcats.bridgecmdr.ui.view.model.EditTieViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Composable +fun EditTieRoute( + navController: NavController, + parent: Ties, + route: EditTie, +) { + EditTieView( + sourceId = Uuid.parseHexDash(parent.sourceId), + tieId = route.tieId?.let { Uuid.parseHexDash(it) } ?: Uuid.NIL, + goBack = { navController.popBackStack() }, + ) +} + +@Composable +fun EditTieView( + sourceId: Uuid, + tieId: Uuid, + goBack: () -> Unit, + viewModel: EditTieViewModel = koinViewModel { parametersOf(sourceId, tieId) }, +) { + val isLoading by viewModel.isLoading.collectAsState() + val devices by viewModel.devices.collectAsState() + + val state by viewModel.state.collectAsState() + val device by viewModel.device.collectAsState() + val driver by viewModel.driver.collectAsState() + val inputChannel by viewModel.inputChannel.collectAsState() + val outputVideoChannel by viewModel.outputVideoChannel.collectAsState() + val outputAudioChannel by viewModel.outputAudioChannel.collectAsState() + + val modalTitle = + when (viewModel.id) { + Uuid.NIL -> Res.string.tie_addTie + else -> Res.string.tie_editTie + } + + val modalConfirmLabel = + when (viewModel.id) { + Uuid.NIL -> Res.string.action_add + else -> Res.string.action_save + } + + val hints = + hintsOf( + Res.string.tie_deviceId_notNil, + Res.string.tie_inputChannel_minimum, + Res.string.tie_videoChannel_minimum, + Res.string.tie_audioChannel_minimum, + ) + + // Bugfix for layout issue with OutlinedTextField and minLines/maxLines/singleLine + minHeighBugFixForOutlinedTextField(withSupportText = true) + + LoadingOverlay(isLoading) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + FullscreenModalScaffold( + titleContent = { Text(stringResource(modalTitle)) }, + navigationIcon = { + IconButton(onClick = { goBack() }) { + Icon(painterResource(Res.drawable.close), contentDescription = stringResource(Res.string.action_close)) + } + }, + actions = { + TextButton(onClick = { viewModel.save { goBack() } }) { + Text(stringResource(modalConfirmLabel)) + } + }, + ) { + Row { + OutlinedSelectField( + modifier = Modifier.fillMaxWidth(), + value = device, + onValueChanged = { viewModel.setDevice(it) }, + label = { Text("Device") }, + isError = isErrored(state.deviceError), + supportingText = { Text((state.deviceError from hints) ?: "") }, + trailingIcon = errorIconOf(state.deviceError), + valueText = { it.title }, + options = devices, + ) + } + driver?.let { driver -> + Row { + OutlinedNumericField( + value = inputChannel, + onValueChanged = { viewModel.setInputChannel(it ?: 1) }, + modifier = Modifier.defaultMinSize(minWidth = 100.dp), + label = { Text("Input Channel") }, + isError = isErrored(state.inputChannelError), + supportingText = { Text((state.inputChannelError from hints) ?: "") }, + trailingIcon = errorIconOf(state.inputChannelError), + minValue = 1, + ) + if (driver.capabilities and Capabilities.MULTIPLE_OUTPUTS != 0) { + OutlinedNumericField( + value = outputVideoChannel, + onValueChanged = { viewModel.setOutputVideoChannel(it) }, + modifier = Modifier.defaultMinSize(minWidth = 100.dp).padding(start = 12.dp), + label = { Text("Video Channel") }, + isError = isErrored(state.outputVideoChannelError), + supportingText = { Text((state.outputVideoChannelError from hints) ?: "") }, + trailingIcon = errorIconOf(state.outputVideoChannelError), + minValue = 1, + ) + } + } + if (driver.capabilities and Capabilities.AUDIO_INDEPENDENT != 0) { + Row(verticalAlignment = CenterVertically) { + val interactionSource = remember { MutableInteractionSource() } + Switch( + checked = outputAudioChannel == null, + onCheckedChange = { viewModel.setOutputAudioChannel(if (it) null else outputVideoChannel) }, + interactionSource = interactionSource, + ) + Text( + "Synchronize audio and video channels", + modifier = + Modifier + .padding(start = 12.dp) + .clickable( + onClick = { + viewModel.setOutputAudioChannel(if (outputAudioChannel != null) null else outputVideoChannel) + }, + interactionSource = interactionSource, + indication = null, + role = Role.Switch, + ), + ) + } + Row { + OutlinedNumericField( + value = outputAudioChannel, + onValueChanged = { viewModel.setOutputAudioChannel(it) }, + modifier = Modifier.defaultMinSize(minWidth = 100.dp), + label = { Text("Audio Channel") }, + isError = isErrored(state.outputAudioChannelError), + supportingText = { Text((state.outputAudioChannelError from hints) ?: "") }, + trailingIcon = errorIconOf(state.outputAudioChannelError), + enabled = outputAudioChannel != null, + minValue = 1, + ) + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/GeneralSettingsView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/GeneralSettingsView.kt new file mode 100644 index 0000000..91da26d --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/GeneralSettingsView.kt @@ -0,0 +1,225 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.branding_appName +import bridgecmdr.composeapp.generated.resources.exit_run +import bridgecmdr.composeapp.generated.resources.power +import bridgecmdr.composeapp.generated.resources.power_socket +import bridgecmdr.composeapp.generated.resources.refresh_auto +import bridgecmdr.composeapp.generated.resources.server_network +import bridgecmdr.composeapp.generated.resources.server_outline +import bridgecmdr.composeapp.generated.resources.setting_AppTheme +import bridgecmdr.composeapp.generated.resources.setting_IconSize +import bridgecmdr.composeapp.generated.resources.setting_IconSize_Size +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps +import bridgecmdr.composeapp.generated.resources.setting_PowerOffTaps_supporting +import bridgecmdr.composeapp.generated.resources.setting_autoStart +import bridgecmdr.composeapp.generated.resources.setting_autoStart_false +import bridgecmdr.composeapp.generated.resources.setting_autoStart_true +import bridgecmdr.composeapp.generated.resources.setting_close_application +import bridgecmdr.composeapp.generated.resources.setting_close_application_support +import bridgecmdr.composeapp.generated.resources.setting_powerOnDevices +import bridgecmdr.composeapp.generated.resources.setting_powerOnDevices_false +import bridgecmdr.composeapp.generated.resources.setting_powerOnDevices_true +import bridgecmdr.composeapp.generated.resources.setting_server +import bridgecmdr.composeapp.generated.resources.setting_server_status +import bridgecmdr.composeapp.generated.resources.setting_server_support +import bridgecmdr.composeapp.generated.resources.settings_general +import bridgecmdr.composeapp.generated.resources.theme_light_dark +import bridgecmdr.composeapp.generated.resources.view_dashboard +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import org.sleepingcats.bridgecmdr.server.ServerStatus +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.SelectModal +import org.sleepingcats.bridgecmdr.ui.view.model.GeneralSettingsViewModel + +@Composable +fun GeneralSettingsRoute(navController: NavController) = GeneralSettingsView(goBack = { navController.popBackStack() }) + +@Composable +private fun GeneralSettingsView( + goBack: () -> Unit, + viewModel: GeneralSettingsViewModel = koinViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + val serverIcon = + when (state.serverStatus) { + is ServerStatus.Stopped -> Res.drawable.server_outline + is ServerStatus.Starting -> Res.drawable.server_outline + is ServerStatus.Running -> Res.drawable.server_network + is ServerStatus.Stopping -> Res.drawable.server_network + } + + val serverRowEnabled = + when (state.serverStatus) { + is ServerStatus.Stopped -> true + is ServerStatus.Starting -> false + is ServerStatus.Running -> true + is ServerStatus.Stopping -> false + } + + val scope = rememberCoroutineScope() + + val selectIconSize = + SelectModal( + itemContent = { stringResource(Res.string.setting_IconSize_Size, it.size) }, + items = IconSize.entries, + ) { value -> if (value != null) scope.launch { viewModel.setIconSize(value) } } + + val selectColorTheme = + SelectModal( + itemContent = { stringResource(it.option) }, + items = AppTheme.entries, + ) { value -> if (value != null) scope.launch { viewModel.setAppTheme(value) } } + + val selectPowerOffTaps = + SelectModal( + supportContent = { Text(stringResource(Res.string.setting_PowerOffTaps_supporting)) }, + itemContent = { stringResource(it.option) }, + items = PowerOffTaps.entries, + ) { value -> if (value != null) scope.launch { viewModel.setPowerOffTaps(value) } } + + fun toggleAutoRun() = + scope.launch { + if (state.autoRun) { + viewModel.disableAutoRun() + } else { + viewModel.enableAutoRun() + } + } + + fun togglePowerOnDevicesAtStart() = + scope.launch { + viewModel.setPowerOnDevicesAtStart(!state.powerOnDevicesAtStart) + } + + fun toggleServer() = + scope.launch { + viewModel.setRunServer(!state.runServer) + } + + val appName = stringResource(Res.string.branding_appName) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings_general)) }, + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) { + ListItem( // TODO: Need to support auto start on JVM only platforms + modifier = Modifier.clickable { toggleAutoRun() }, + leadingContent = { Icon(painterResource(Res.drawable.refresh_auto), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_autoStart)) }, + supportingContent = { + Text( + when (state.autoRun) { + true -> stringResource(Res.string.setting_autoStart_true, appName) + false -> stringResource(Res.string.setting_autoStart_false, appName) + }, + ) + }, + trailingContent = { Switch(checked = state.autoRun, onCheckedChange = { toggleAutoRun() }) }, + ) + ListItem( + modifier = Modifier.clickable { selectIconSize(state.iconSize) }, + leadingContent = { Icon(painterResource(Res.drawable.view_dashboard), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_IconSize)) }, + supportingContent = { Text(stringResource(Res.string.setting_IconSize_Size, state.iconSize.size)) }, + ) + ListItem( + modifier = Modifier.clickable { selectColorTheme(state.appTheme) }, + leadingContent = { Icon(painterResource(Res.drawable.theme_light_dark), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_AppTheme)) }, + supportingContent = { Text(stringResource(state.appTheme.description)) }, + ) + ListItem( + modifier = Modifier.clickable { selectPowerOffTaps(state.powerOffTaps) }, + leadingContent = { Icon(painterResource(Res.drawable.power), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_PowerOffTaps)) }, + supportingContent = { Text(stringResource(state.powerOffTaps.description)) }, + ) + ListItem( + modifier = Modifier.clickable { togglePowerOnDevicesAtStart() }, + leadingContent = { Icon(painterResource(Res.drawable.power_socket), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_powerOnDevices)) }, + supportingContent = { + Text( + stringResource( + if (state.powerOnDevicesAtStart) { + Res.string.setting_powerOnDevices_true + } else { + Res.string.setting_powerOnDevices_false + }, + appName, + ), + ) + }, + trailingContent = { + Switch(checked = state.powerOnDevicesAtStart, onCheckedChange = { togglePowerOnDevicesAtStart() }) + }, + ) + + ListItem( + modifier = Modifier.clickable(enabled = serverRowEnabled) { toggleServer() }, + leadingContent = { Icon(painterResource(serverIcon), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_server)) }, + supportingContent = { + Column { + Text(stringResource(Res.string.setting_server_support)) + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Text( + stringResource(Res.string.setting_server_status, stringResource(state.serverStatus.resource)), + ) + } + } + }, + trailingContent = { Switch(checked = state.runServer, onCheckedChange = { toggleServer() }) }, + ) + ListItem( + modifier = Modifier.clickable { viewModel.exitApplication() }, + leadingContent = { Icon(painterResource(Res.drawable.exit_run), contentDescription = null) }, + headlineContent = { Text(stringResource(Res.string.setting_close_application, appName)) }, + supportingContent = { Text(stringResource(Res.string.setting_close_application_support, appName)) }, + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ServerCodeView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ServerCodeView.kt new file mode 100644 index 0000000..5089193 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ServerCodeView.kt @@ -0,0 +1,64 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.branding_appName +import bridgecmdr.composeapp.generated.resources.server_remoteCode +import bridgecmdr.composeapp.generated.resources.server_remoteCode_instructions +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.view.model.ServerCodeViewModel + +@Composable +fun ServerCodeRoute(navController: NavController) { + ServerCodeView(goBack = { navController.popBackStack() }) +} + +@Composable +private fun ServerCodeView( + goBack: () -> Unit, + viewModel: ServerCodeViewModel = koinInject(), +) { + val state by viewModel.state.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.server_remoteCode)) }, + ) + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues).fillMaxSize(), contentAlignment = Center) { + Column(horizontalAlignment = CenterHorizontally) { + state.qrCode?.let { bitmap -> Image(bitmap, contentDescription = null, contentScale = ContentScale.None) } + Text( + stringResource(Res.string.server_remoteCode_instructions, stringResource(Res.string.branding_appName)), + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + ) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SettingsView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SettingsView.kt new file mode 100644 index 0000000..636842e --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SettingsView.kt @@ -0,0 +1,123 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.branding_appName +import bridgecmdr.composeapp.generated.resources.cogs +import bridgecmdr.composeapp.generated.resources.controller_classic +import bridgecmdr.composeapp.generated.resources.information +import bridgecmdr.composeapp.generated.resources.message_loading +import bridgecmdr.composeapp.generated.resources.settings +import bridgecmdr.composeapp.generated.resources.settings_about +import bridgecmdr.composeapp.generated.resources.settings_about_support +import bridgecmdr.composeapp.generated.resources.settings_backups +import bridgecmdr.composeapp.generated.resources.settings_backups_support +import bridgecmdr.composeapp.generated.resources.settings_devices +import bridgecmdr.composeapp.generated.resources.settings_devices_support +import bridgecmdr.composeapp.generated.resources.settings_general +import bridgecmdr.composeapp.generated.resources.settings_general_support +import bridgecmdr.composeapp.generated.resources.settings_sources +import bridgecmdr.composeapp.generated.resources.settings_sources_support +import bridgecmdr.composeapp.generated.resources.swap_vertical +import bridgecmdr.composeapp.generated.resources.video_switch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.Branding +import org.sleepingcats.bridgecmdr.ui.BackupManager +import org.sleepingcats.bridgecmdr.ui.DeviceList +import org.sleepingcats.bridgecmdr.ui.GeneralSettings +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.SourceList +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.view.model.SettingsViewModel + +@Composable +fun SettingsRoute(navController: NavController) = + SettingsView( + goBack = { navController.popBackStack() }, + goTo = { r -> navController.navigate(r) }, + ) + +@Composable +private fun SettingsView( + goBack: () -> Unit, + goTo: (Route) -> Unit, + viewModel: SettingsViewModel = koinViewModel(), +) { + val sourceCount by viewModel.sourceCount.collectAsState() + val deviceCount by viewModel.deviceCount.collectAsState() + + val appName = stringResource(Res.string.branding_appName) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings)) }, + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState())) { + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.cogs), null) }, + headlineContent = { Text(stringResource(Res.string.settings_general)) }, + supportingContent = { Text(stringResource(Res.string.settings_general_support, appName)) }, + modifier = Modifier.clickable { goTo(GeneralSettings) }, + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.controller_classic), null) }, + headlineContent = { Text(stringResource(Res.string.settings_sources)) }, + supportingContent = { + when (val quantity = sourceCount) { + null -> Text(stringResource(Res.string.message_loading)) + else -> Text(pluralStringResource(Res.plurals.settings_sources_support, quantity, quantity)) + } + }, + modifier = Modifier.clickable { goTo(SourceList) }, + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.video_switch), null) }, + headlineContent = { Text(stringResource(Res.string.settings_devices)) }, + supportingContent = { + when (val quantity = deviceCount) { + null -> Text(stringResource(Res.string.message_loading)) + else -> Text(pluralStringResource(Res.plurals.settings_devices_support, quantity, quantity)) + } + }, + modifier = Modifier.clickable { goTo(DeviceList) }, + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.swap_vertical), contentDescription = "Backup") }, + headlineContent = { Text(stringResource(Res.string.settings_backups)) }, + supportingContent = { Text(stringResource(Res.string.settings_backups_support)) }, + modifier = Modifier.clickable(onClick = { goTo(BackupManager) }), + ) + ListItem( + leadingContent = { Icon(painterResource(Res.drawable.information), null) }, + headlineContent = { Text(stringResource(Res.string.settings_about, appName)) }, + supportingContent = { Text(stringResource(Res.string.settings_about_support, Branding.version)) }, + ) + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SourceListView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SourceListView.kt new file mode 100644 index 0000000..31291ef --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SourceListView.kt @@ -0,0 +1,156 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_delete +import bridgecmdr.composeapp.generated.resources.action_keep +import bridgecmdr.composeapp.generated.resources.delete +import bridgecmdr.composeapp.generated.resources.devices_doYouWantToDeleteSource +import bridgecmdr.composeapp.generated.resources.plus +import bridgecmdr.composeapp.generated.resources.settings_sources +import bridgecmdr.composeapp.generated.resources.sources_addSource +import bridgecmdr.composeapp.generated.resources.sources_deleteSource +import bridgecmdr.composeapp.generated.resources.unknown_error +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.EditSource +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.Ties +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.ConfirmModal +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.MediaImage +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.component.hideOnScroll +import org.sleepingcats.bridgecmdr.ui.view.model.SourceListViewModel +import kotlin.uuid.ExperimentalUuidApi + +@Composable +fun SourceListRoute(navController: NavController) { + SourceListView( + goBack = { navController.popBackStack() }, + goTo = { r -> navController.navigate(r) }, + ) +} + +@Composable +internal fun SourceListView( + goBack: () -> Unit, + goTo: (Route) -> Unit, + viewModel: SourceListViewModel = koinViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + fun listTies(source: Source) { + goTo(Ties(source.id.toHexDashString())) + } + + val scope = rememberCoroutineScope() + + fun addSource() { + goTo(EditSource(null)) + } + + val deleteSource = + ConfirmModal( + title = { Text(stringResource(Res.string.devices_doYouWantToDeleteSource)) }, + confirmButton = { confirm -> TextButton(onClick = confirm) { Text(stringResource(Res.string.action_delete)) } }, + cancelButton = { cancel -> TextButton(onClick = cancel) { Text(stringResource(Res.string.action_keep)) } }, + ) { source, confirm -> if (confirm == true) scope.launch { viewModel.delete(source) } } + + val showActions = rememberSaveable { mutableStateOf(true) } + val connection = hideOnScroll(showActions) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + // TODO: Support pull to refresh on errors. + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { BackButton(goBack) }, + title = { Text(stringResource(Res.string.settings_sources)) }, + ) + }, + floatingActionButton = { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.sources_addSource)) }, + ) { + AnimatedVisibility(visible = showActions.value, enter = fadeIn(), exit = fadeOut()) { + FloatingActionButton(onClick = { addSource() }) { + Icon(painterResource(Res.drawable.plus), contentDescription = stringResource(Res.string.sources_addSource)) + } + } + } + }, + ) { paddingValues -> + LazyColumn(modifier = Modifier.padding(paddingValues).fillMaxSize().nestedScroll(connection)) { + items(state.sources, key = { it.id }) { source -> + ListItem( + modifier = Modifier.clickable(enabled = !isLoading) { listTies(source) }, + leadingContent = { MediaImage(source.image, contentDescription = null) }, + headlineContent = { Text(source.title) }, + trailingContent = { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.sources_deleteSource)) }, + ) { + IconButton(onClick = { deleteSource(source) }) { + Icon( + painterResource(Res.drawable.delete), + contentDescription = stringResource(Res.string.sources_deleteSource), + ) + } + } + }, + ) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/TieListView.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/TieListView.kt new file mode 100644 index 0000000..f98ae55 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/TieListView.kt @@ -0,0 +1,316 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3Api::class) + +package org.sleepingcats.bridgecmdr.ui.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment.Companion.BottomEnd +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.navigation.NavController +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.action_delete +import bridgecmdr.composeapp.generated.resources.action_keep +import bridgecmdr.composeapp.generated.resources.delete +import bridgecmdr.composeapp.generated.resources.export +import bridgecmdr.composeapp.generated.resources.import +import bridgecmdr.composeapp.generated.resources.plus +import bridgecmdr.composeapp.generated.resources.source_title +import bridgecmdr.composeapp.generated.resources.ties_addTie +import bridgecmdr.composeapp.generated.resources.ties_audio +import bridgecmdr.composeapp.generated.resources.ties_deleteTie +import bridgecmdr.composeapp.generated.resources.ties_doYouWantToDeleteTie +import bridgecmdr.composeapp.generated.resources.ties_input +import bridgecmdr.composeapp.generated.resources.ties_noTies +import bridgecmdr.composeapp.generated.resources.ties_source +import bridgecmdr.composeapp.generated.resources.ties_ties +import bridgecmdr.composeapp.generated.resources.ties_video +import bridgecmdr.composeapp.generated.resources.unknown_device +import bridgecmdr.composeapp.generated.resources.unknown_error +import bridgecmdr.composeapp.generated.resources.volume_medium +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf +import org.sleepingcats.bridgecmdr.common.extension.invoke +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.ui.EditTie +import org.sleepingcats.bridgecmdr.ui.Route +import org.sleepingcats.bridgecmdr.ui.Ties +import org.sleepingcats.bridgecmdr.ui.component.AlertModal +import org.sleepingcats.bridgecmdr.ui.component.BackButton +import org.sleepingcats.bridgecmdr.ui.component.ConfirmModal +import org.sleepingcats.bridgecmdr.ui.component.ImagePreview +import org.sleepingcats.bridgecmdr.ui.component.InputModal +import org.sleepingcats.bridgecmdr.ui.component.LoadingOverlay +import org.sleepingcats.bridgecmdr.ui.component.SimpleTooltip +import org.sleepingcats.bridgecmdr.ui.component.hideOnScroll +import org.sleepingcats.bridgecmdr.ui.components.rememberImagePickerLauncher +import org.sleepingcats.bridgecmdr.ui.view.model.TieListViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Composable +fun TieListRoute( + navController: NavController, + parent: Ties, +) { + TieListView( + sourceId = Uuid.parseHexDash(parent.sourceId), + goBack = { navController.popBackStack() }, + goTo = { r -> navController.navigate(r) }, + ) +} + +@Composable +fun TieListView( + sourceId: Uuid, + goBack: () -> Unit, + goTo: (Route) -> Unit, + viewModel: TieListViewModel = koinViewModel { parametersOf(sourceId) }, +) { + val isLoading by viewModel.isLoading.collectAsState() + val state by viewModel.state.collectAsState() + + LoadingOverlay(isLoading) + + val scope = rememberCoroutineScope() + val pickImage = + rememberImagePickerLauncher { file -> + if (file != null) { + scope.launch { + val imageId = viewModel.uploadImage(file) ?: return@launch + viewModel.partialUpdateSource(sourceId, Source.Update(image = imageId)) { viewModel.refresh() } + } + } + } + + val editTitle = + InputModal(label = { Text(stringResource(Res.string.source_title)) }) { value -> + if (value != null) { + scope.launch { + viewModel.partialUpdateSource(sourceId, Source.Update(title = value)) { viewModel.refresh() } + } + } + } + + fun addTie() = goTo(EditTie(null)) + + fun editTie(tie: Tie) = goTo(EditTie(tie.id.toHexDashString())) + + val deleteTie = + ConfirmModal( + title = { Text(stringResource(Res.string.ties_doYouWantToDeleteTie)) }, + confirmButton = { confirm -> TextButton(onClick = confirm) { Text(stringResource(Res.string.action_delete)) } }, + cancelButton = { cancel -> TextButton(onClick = cancel) { Text(stringResource(Res.string.action_keep)) } }, + ) { tie, confirm -> if (confirm == true) scope.launch { viewModel.deleteTie(tie) { viewModel.refresh() } } } + + val showActions = rememberSaveable { mutableStateOf(true) } + val connection = hideOnScroll(showActions) + + val tieListColor = ListItemDefaults.colors().copy(containerColor = MaterialTheme.colorScheme.surfaceContainer) + + AlertModal( + visible = state.error != null, + onClose = { viewModel.dismissError() }, + title = { Text(stringResource(state.error?.resource ?: Res.string.unknown_error)) }, + ) + + // TODO: Support pull to refresh on errors. + AlertModal( + visible = state.fatalError != null, + onClose = { goBack() }, + title = { Text(stringResource(state.fatalError?.resource ?: Res.string.unknown_error)) }, + ) + + Surface { + Box(modifier = Modifier.fillMaxSize()) { + BackButton(goBack, modifier = Modifier.absoluteOffset(4.dp, 8.dp).zIndex(1f)) + Box(modifier = Modifier.align(BottomEnd).absoluteOffset((-16).dp, (-16).dp).zIndex(1f)) { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.ties_addTie)) }, + ) { + AnimatedVisibility(visible = showActions.value, enter = fadeIn(), exit = fadeOut()) { + FloatingActionButton(onClick = { addTie() }) { + Icon(painterResource(Res.drawable.plus), stringResource(Res.string.ties_addTie)) + } + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .nestedScroll(connection) + .verticalScroll(rememberScrollState()), + ) { + ImagePreview( + model = state.source.image, + onClick = { pickImage() }, + modifier = + Modifier + .align(CenterHorizontally) + .padding(12.dp), + ) + ListItem( + modifier = Modifier.clickable { editTitle(state.source.title) }, + headlineContent = { Text(state.source.title) }, + supportingContent = { Text(stringResource(Res.string.ties_source)) }, + ) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .padding(bottom = 32.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(top = 16.dp), + ) { + Text( + stringResource(Res.string.ties_ties), + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) + + if (state.ties.isEmpty()) { + return@Column ListItem( + headlineContent = { + Text( + text = stringResource(Res.string.ties_noTies), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + }, + colors = tieListColor, + ) + } + for (tie in state.ties) { + val device = state.devices.find { it.id == tie.deviceId } + val driver = device?.let { state.drivers.find { it.id == device.driverId } } + ListItem( + modifier = Modifier.clickable { editTie(tie) }, + headlineContent = { Text(device?.title ?: stringResource(Res.string.unknown_device)) }, + supportingContent = { + Column { + Text(driver?.title ?: "Unknown driver") + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) { + Row(modifier = Modifier.padding(top = 1.dp)) { + Row( + modifier = + Modifier + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Icon( + painterResource(Res.drawable.import), + contentDescription = stringResource(Res.string.ties_input), + modifier = Modifier.size(16.dp).padding(end = 2.dp), + ) + Text("${tie.inputChannel}") + } + if (driver?.capabilities?.and(Capabilities.MULTIPLE_OUTPUTS) == Capabilities.MULTIPLE_OUTPUTS) { + Row( + modifier = + Modifier + .padding(start = 4.dp) + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Icon( + painterResource(Res.drawable.export), + contentDescription = stringResource(Res.string.ties_video), + modifier = Modifier.size(16.dp).padding(end = 2.dp), + ) + Text("${tie.outputVideoChannel ?: tie.inputChannel}") + } + + if (driver.capabilities and Capabilities.AUDIO_INDEPENDENT == Capabilities.AUDIO_INDEPENDENT) { + Row( + modifier = + Modifier + .padding(start = 4.dp) + .clip(MaterialTheme.shapes.extraSmall) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Icon( + painterResource(Res.drawable.volume_medium), + contentDescription = stringResource(Res.string.ties_audio), + modifier = Modifier.size(16.dp).padding(end = 2.dp), + ) + Text("${tie.outputAudioChannel ?: tie.outputVideoChannel ?: tie.inputChannel}") + } + } + } + } + } + } + }, + trailingContent = { + SimpleTooltip( + position = TooltipAnchorPosition.Start, + tooltip = { Text(stringResource(Res.string.ties_deleteTie)) }, + ) { + IconButton(onClick = { deleteTie(tie) }) { + Icon( + painterResource(Res.drawable.delete), + contentDescription = stringResource(Res.string.ties_deleteTie), + ) + } + } + }, + colors = tieListColor, + ) + } + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/BackupManagerViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/BackupManagerViewModel.kt new file mode 100644 index 0000000..bb0ea44 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/BackupManagerViewModel.kt @@ -0,0 +1,48 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.backup_export_failed +import bridgecmdr.composeapp.generated.resources.backup_import_failed +import io.github.oshai.kotlinlogging.KLogger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.toKotlinxIoPath +import io.github.vinceglb.filekit.utils.toFile +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import org.sleepingcats.bridgecmdr.common.backup.Exporter +import org.sleepingcats.bridgecmdr.common.backup.Importer +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel + +class BackupManagerViewModel( + logger: KLogger, + private val _import: Importer, + private val _export: Exporter, +) : TrackingViewModel(logger) { + class State( + val error: ViewError? = null, + val fatalError: ViewError? = null, + ) + + val state = + combine( + error, + fatalError, + ::State, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + suspend fun import(file: PlatformFile) = + loadingWhile { + // Really!?!? + runCatching { _import(file.toKotlinxIoPath().toFile().toPath()) } + .onFailure { throwable -> pushError(throwable) { Res.string.backup_import_failed } } + } + + suspend fun export(file: PlatformFile) = + loadingWhile { + // Really!?!? + runCatching { _export(file.toKotlinxIoPath().toFile().toPath()) } + .onFailure { throwable -> pushError(throwable) { Res.string.backup_export_failed } } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopApplicationViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopApplicationViewModel.kt new file mode 100644 index 0000000..6e6a8a0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopApplicationViewModel.kt @@ -0,0 +1,68 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.fatal_database +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.service.DatabaseService +import org.sleepingcats.bridgecmdr.common.service.PowerService +import org.sleepingcats.bridgecmdr.server.ServerController +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.service.ApplicationService + +class DesktopApplicationViewModel( + logger: KLogger, + databaseService: DatabaseService, + serverController: ServerController, + applicationService: ApplicationService, + settingsRepository: SettingsRepository, + powerService: PowerService, +) : ApplicationViewModel(logger, settingsRepository) { + init { + viewModelScope.launch(Dispatchers.Default) { + // Initialize the database first. + runCatching { databaseService.initializeDatabase() } + .onFailure { throwable -> + logger.error(throwable) { "Failed to initialize database" } + fatalError.update { + logger.error(throwable) { "Failed to initialize database" } + ViewError(Res.string.fatal_database, throwable) { + applicationService.fatalExit() + } + } + } + + // Load legacy settings. + settingsRepository.loadLegacySettings() + + // Now it will be possible to initialize the server. + settingsRepository.data + .map { it.runServer } + .onEach { if (it) serverController.start() else serverController.stop() } + .catch { throwable -> logger.error(throwable) { "Server action failed" } } + .launchIn(this) + applicationService.onExit { serverController.stop() } + + // If power-on at start-up is enabled, power on all devices + val powerOnDevices = + settingsRepository.data + .map { it.powerOnDevicesAtStart } + .take(1) + .last() + if (powerOnDevices) { + powerService.powerOn() + } + + applicationService.onExit { powerService.powerOff() } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopDashboardViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopDashboardViewModel.kt new file mode 100644 index 0000000..e1d0603 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopDashboardViewModel.kt @@ -0,0 +1,24 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.service.ApplicationService +import org.sleepingcats.bridgecmdr.ui.service.SessionService + +class DesktopDashboardViewModel( + logger: KLogger, + settingsRepository: SettingsRepository, + repository: SourceRepository, + private val applicationService: ApplicationService, + private val sessionService: SessionService, +) : DashboardViewModel(logger, settingsRepository, repository) { + fun powerOff() { + viewModelScope.launch { + applicationService.quit() + sessionService.shutdownSystem() + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DeviceListViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DeviceListViewModel.kt new file mode 100644 index 0000000..6ab7ef0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DeviceListViewModel.kt @@ -0,0 +1,67 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.device_failedToGet +import bridgecmdr.composeapp.generated.resources.devices_failedToDelete +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.DriverRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi + +class DeviceListViewModel( + logger: KLogger, + private val driverRepository: DriverRepository, + private val repository: DeviceRepository, +) : TrackingViewModel(logger) { + data class State( + val drivers: List = emptyList(), + val devices: List = emptyList(), + val error: ViewError? = null, + val fatalError: ViewError? = null, + ) + + val state = + combine( + driverRepository.items, + repository.items, + error, + fatalError, + ::State, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + init { + viewModelScope.launch { refresh() } + } + + suspend fun refresh() { + loadingCoroutineScope { + runCatching { + listOf( + async { driverRepository.refresh() }, + async { repository.refresh() }, + ).awaitAll() + }.onFailure { throwable -> pushFatalError(throwable) { Res.string.device_failedToGet } } + } + } + + suspend fun delete( + device: Device, + onFailure: suspend () -> Unit, + ) = loadingWhile { + runCatching { repository.remove(device) } + .onFailure { throwable -> pushError(throwable) { Res.string.devices_failedToDelete }.also { onFailure() } } + .getOrNull() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditDeviceViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditDeviceViewModel.kt new file mode 100644 index 0000000..617f945 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditDeviceViewModel.kt @@ -0,0 +1,185 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.device_driver_notNil +import bridgecmdr.composeapp.generated.resources.device_failedToAdd +import bridgecmdr.composeapp.generated.resources.device_failedToGet +import bridgecmdr.composeapp.generated.resources.device_failedToUpdate +import bridgecmdr.composeapp.generated.resources.device_path_noHostname +import bridgecmdr.composeapp.generated.resources.device_path_noPort +import bridgecmdr.composeapp.generated.resources.device_path_noType +import bridgecmdr.composeapp.generated.resources.device_title_maxLength +import bridgecmdr.composeapp.generated.resources.device_title_notBlank +import io.github.oshai.kotlinlogging.KLogger +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.notBlank +import io.konform.validation.constraints.pattern +import io.konform.validation.path.ValidationPath +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.common.service.model.PathType +import org.sleepingcats.bridgecmdr.common.service.model.PortInfo +import org.sleepingcats.bridgecmdr.ui.component.firstErrorOf +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.DriverRepository +import org.sleepingcats.bridgecmdr.ui.repository.PortRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class EditDeviceViewModel( + val id: Uuid, + logger: KLogger, + private val repository: DeviceRepository, + driverRepository: DriverRepository, + portRepository: PortRepository, +) : TrackingViewModel(logger) { + data class FormState( + val validation: ValidationResult? = null, + val fatalError: ViewError? = null, + val error: ViewError? = null, + ) { + val titleError = firstErrorOf(validation?.errors, Device::title) + val driverError = firstErrorOf(validation?.errors, Device::driverId) + val pathTypeError = firstErrorOf(validation?.errors, ValidationPath.of(Device::path, "pathType")) + val pathError = firstErrorOf(validation?.errors, Device::path) + } + + private val validation = MutableStateFlow?>(null) + + val drivers = driverRepository.items + + val ports = portRepository.items + + val state = + combine(validation, fatalError, error, ::FormState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), FormState()) + + private val validate = + Validation { + Device::title { + notBlank() hint Res.string.device_title_notBlank.key + maxLength(255) hint Res.string.device_title_maxLength.key + } + + Device::driverId { + constrain(Res.string.device_driver_notNil.key) { it != Uuid.NIL } + } + + Device::path dynamic { device -> + when { + device.path.startsWith(PathType.Remote.prefix) -> { + pattern("^ip:.+") hint Res.string.device_path_noHostname.key + } + + device.path.startsWith(PathType.Port.prefix) -> { + pattern("^port:.+") hint Res.string.device_path_noPort.key + } + + else -> { + validate("pathType", { it }) { constrain(Res.string.device_path_noType.key) { false } } + } + } + } + } + + val title = MutableStateFlow(Device.Blank.title) + val driver = MutableStateFlow(null) + val pathType = MutableStateFlow(null) + val port = MutableStateFlow(null) + val hostname = MutableStateFlow("") + + private val device: Device get() = + Device( + id = id, + driverId = driver.value?.id ?: Uuid.NIL, + title = title.value, + path = + when (pathType.value) { + PathType.Port -> "${PathType.Port.prefix}${port.value?.path ?: ""}" + PathType.Remote -> "${PathType.Remote.prefix}${hostname.value}" + null -> "" + }, + ) + + init { + launchLoading { + runCatching { + // Ports may not always be loaded. + if (portRepository.latest().isEmpty()) portRepository.refresh() + + val foundDevice = id.takeIf { it != Uuid.NIL }?.let { repository.findLatest(it) } ?: Device.Blank + title.update { foundDevice.title } + driver.update { driverRepository.findLatest(foundDevice.driverId) } + when { + foundDevice.path.startsWith(PathType.Port.prefix) -> { + val portPath = foundDevice.path.substring(PathType.Port.startIndex) + pathType.update { PathType.Port } + port.update { portRepository.findLatest(portPath) } + } + + foundDevice.path.startsWith(PathType.Remote.prefix) -> { + pathType.update { PathType.Remote } + hostname.update { foundDevice.path.substring(PathType.Remote.startIndex) } + } + + else -> { + pathType.update { null } + } + } + }.onFailure { throwable -> pushFatalError(throwable) { Res.string.device_failedToGet } } + } + } + + private fun pushError( + throwable: Throwable, + device: Device, + ) { + pushError(throwable) { + if (device.id == Uuid.NIL) { + Res.string.device_failedToAdd + } else { + Res.string.device_failedToUpdate + } + } + } + + private inline fun ifValid(onSuccess: (Device) -> Unit): Boolean { + val result = validate(device) + if (result.isValid) return onSuccess(device).let { true } + return this.validation.update { result }.let { false } + } + + fun save(onSuccess: (Device) -> Unit) = + ifValid { device -> + launchLoading { + runCatching { if (device.id == Uuid.NIL) repository.add(device) else repository.update(device) } + .onFailure { throwable -> pushError(throwable, device) } + .onSuccess { value -> onSuccess(value) } + } + } + + fun setTitle(newTitle: String) = title.update { newTitle } + + fun setDriver(newDriver: Driver?) = driver.update { newDriver } + + fun setPathType(newPathType: PathType?) { + pathType.update { newPathType } + hostname.update { "" } + port.update { null } + } + + fun setPort(newPort: PortInfo?) = port.update { newPort } + + fun setHostname(newHostname: String) = hostname.update { newHostname } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditSourceViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditSourceViewModel.kt new file mode 100644 index 0000000..04d4985 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditSourceViewModel.kt @@ -0,0 +1,114 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.source_failedToAdd +import bridgecmdr.composeapp.generated.resources.source_failedToGet +import bridgecmdr.composeapp.generated.resources.source_failedToUpdate +import bridgecmdr.composeapp.generated.resources.source_failedToUploadImage +import bridgecmdr.composeapp.generated.resources.source_title_maxLength +import bridgecmdr.composeapp.generated.resources.source_title_notBlank +import io.github.oshai.kotlinlogging.KLogger +import io.github.vinceglb.filekit.PlatformFile +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.notBlank +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.component.firstErrorOf +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class EditSourceViewModel( + val id: Uuid, + logger: KLogger, + private val userImageModel: UserImageModel, + private val repository: SourceRepository, +) : TrackingViewModel(logger) { + data class FormState( + val validation: ValidationResult? = null, + val fatalError: ViewError? = null, + val error: ViewError? = null, + ) { + val titleError = firstErrorOf(validation?.errors, Source::title) + } + + private val validation = MutableStateFlow?>(null) + + val state = + combine(validation, fatalError, error, ::FormState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), FormState()) + + private val validate = + Validation { + Source::title { + notBlank() hint Res.string.source_title_notBlank.key + maxLength(255) hint Res.string.source_title_maxLength.key + } + } + + val title = MutableStateFlow(Source.Blank.title) + + val image = MutableStateFlow(Source.Blank.image) + + private val source: Source get() = + Source( + id = id, + title = title.value, + image = image.value, + ) + + init { + launchLoading { + runCatching { + val foundSource = id.takeIf { it != Uuid.NIL }?.let { repository.findLatest(it) } ?: Source.Blank + title.update { foundSource.title } + image.update { foundSource.image } + }.onFailure { throwable -> pushFatalError(throwable) { Res.string.source_failedToGet } } + } + } + + private fun pushError( + throwable: Throwable, + source: Source, + ) { + pushError(throwable) { + if (source.id == Uuid.NIL) { + Res.string.source_failedToAdd + } else { + Res.string.source_failedToUpdate + } + } + } + + private inline fun ifValid(onSuccess: (Source) -> Unit): Boolean { + val result = validate(source) + if (result.isValid) return onSuccess(source).let { true } + return this.validation.update { result }.let { false } + } + + fun save(onSuccess: (Source) -> Unit) = + ifValid { source -> + launchLoading { + runCatching { if (source.id == Uuid.NIL) repository.add(source) else repository.update(source) } + .onFailure { throwable -> pushError(throwable, source) } + .onSuccess { value -> onSuccess(value) } + } + } + + fun setTitle(newTitle: String) = title.update { newTitle } + + fun setImage(newImage: Uuid?) = image.update { newImage } + + suspend fun uploadImage(file: PlatformFile): Uuid? = + loadingWhile { userImageModel.uploadImage(file) { pushError(it) { Res.string.source_failedToUploadImage } } } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditTieViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditTieViewModel.kt new file mode 100644 index 0000000..5bbc8ad --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditTieViewModel.kt @@ -0,0 +1,193 @@ +@file:OptIn(ExperimentalUuidApi::class, ExperimentalCoroutinesApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.tie_audioChannel_minimum +import bridgecmdr.composeapp.generated.resources.tie_deviceId_notNil +import bridgecmdr.composeapp.generated.resources.tie_failedToAdd +import bridgecmdr.composeapp.generated.resources.tie_failedToGet +import bridgecmdr.composeapp.generated.resources.tie_failedToUpdate +import bridgecmdr.composeapp.generated.resources.tie_inputChannel_minimum +import bridgecmdr.composeapp.generated.resources.tie_videoChannel_minimum +import io.github.oshai.kotlinlogging.KLogger +import io.konform.validation.Validation +import io.konform.validation.ValidationResult +import io.konform.validation.constraints.minimum +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.sleepingcats.bridgecmdr.common.service.model.Capabilities +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.ui.component.firstErrorOf +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.DriverRepository +import org.sleepingcats.bridgecmdr.ui.repository.TieRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class EditTieViewModel( + val sourceId: Uuid, + val id: Uuid, + logger: KLogger, + private val driverRepository: DriverRepository, + private val deviceRepository: DeviceRepository, + private val repository: TieRepository, +) : TrackingViewModel(logger) { + data class FormState( + val validation: ValidationResult? = null, + val fatalError: ViewError? = null, + val error: ViewError? = null, + ) { + val deviceError = firstErrorOf(validation?.errors, Tie::deviceId) + val inputChannelError = firstErrorOf(validation?.errors, Tie::inputChannel) + val outputVideoChannelError = firstErrorOf(validation?.errors, Tie::outputVideoChannel) + val outputAudioChannelError = firstErrorOf(validation?.errors, Tie::outputAudioChannel) + } + + private val validation = MutableStateFlow?>(null) + + val drivers = driverRepository.items + + val devices = + deviceRepository.items + .map { devices -> + val drivers = driverRepository.latest() + devices.filter { device -> drivers.find { it.id == device.driverId } != null } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + + val state = + combine(validation, fatalError, error, ::FormState) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), FormState()) + + private val validate = + Validation { + Tie::deviceId { + constrain(Res.string.tie_deviceId_notNil.key) { it != Uuid.NIL } + } + + Tie::inputChannel { + minimum(1) hint Res.string.tie_inputChannel_minimum.key + } + + dynamic { + val driver = driver.value + if (driver != null && driver.capabilities and Capabilities.MULTIPLE_OUTPUTS != 0) { + Tie::outputVideoChannel required { + hint = Res.string.tie_videoChannel_minimum.key + minimum(1) hint Res.string.tie_videoChannel_minimum.key + } + } + } + + Tie::outputAudioChannel ifPresent { + minimum(1) hint Res.string.tie_audioChannel_minimum.key + } + } + + val device = MutableStateFlow(null) + + val driver = + device + .flatMapLatest { + drivers + .filter { drivers -> drivers.isNotEmpty() } + .map { drivers -> drivers.find { it.id == device.value?.driverId } } + }.onEach { driver -> + // With each driver change ensure the channels make sense. + if (driver == null) { + outputVideoChannel.update { Tie.Blank.outputVideoChannel } + outputAudioChannel.update { Tie.Blank.outputAudioChannel } + return@onEach + } + + if ((driver.capabilities and Capabilities.MULTIPLE_OUTPUTS) != 0) { + outputVideoChannel.update { current -> current ?: 1 } + } else { + outputVideoChannel.update { null } + } + + if ((driver.capabilities and Capabilities.AUDIO_INDEPENDENT) == 0) { + outputAudioChannel.update { null } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + val inputChannel = MutableStateFlow(Tie.Blank.inputChannel) + + val outputVideoChannel = MutableStateFlow(Tie.Blank.outputVideoChannel) + + val outputAudioChannel = MutableStateFlow(Tie.Blank.outputAudioChannel) + + private val tie: Tie get() = + Tie( + id = id, + sourceId = sourceId, + deviceId = device.value?.id ?: Uuid.NIL, + inputChannel = inputChannel.value, + outputVideoChannel = outputVideoChannel.value, + outputAudioChannel = outputAudioChannel.value, + ) + + init { + launchLoading { + runCatching { + val foundTie = + id.takeIf { it != Uuid.NIL }?.let { repository.findLatest(it) } + ?: Tie.Blank.copy(sourceId = sourceId) + + check(foundTie.sourceId == sourceId) { "Tie sourceId mismatch" } + + device.update { deviceRepository.findLatest(foundTie.deviceId) } + inputChannel.update { foundTie.inputChannel } + outputVideoChannel.update { foundTie.outputVideoChannel } + outputAudioChannel.update { foundTie.outputAudioChannel } + }.onFailure { throwable -> pushFatalError(throwable) { Res.string.tie_failedToGet } } + } + } + + private fun pushError( + throwable: Throwable, + tie: Tie, + ) { + pushError(throwable) { + if (tie.id == Uuid.NIL) { + Res.string.tie_failedToAdd + } else { + Res.string.tie_failedToUpdate + } + } + } + + private inline fun ifValid(onSuccess: (Tie) -> Unit): Boolean { + val result = validate(tie) + if (result.isValid) return onSuccess(tie).let { true } + return this.validation.update { result }.let { false } + } + + fun save(onSuccess: (Tie) -> Unit) = + ifValid { tie -> + launchLoading { + runCatching { if (tie.id == Uuid.NIL) repository.add(tie) else repository.update(tie) } + .onFailure { throwable -> pushError(throwable, tie) } + .onSuccess { value -> onSuccess(value) } + } + } + + fun setDevice(newDevice: Device?) = device.update { newDevice } + + fun setInputChannel(newInputChannel: Int) = inputChannel.update { newInputChannel } + + fun setOutputVideoChannel(newOutputVideoChannel: Int?) = outputVideoChannel.update { newOutputVideoChannel } + + fun setOutputAudioChannel(newOutputAudioChannel: Int?) = outputAudioChannel.update { newOutputAudioChannel } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/GeneralSettingsViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/GeneralSettingsViewModel.kt new file mode 100644 index 0000000..67694e0 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/GeneralSettingsViewModel.kt @@ -0,0 +1,74 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.setting.AppTheme +import org.sleepingcats.bridgecmdr.common.setting.IconSize +import org.sleepingcats.bridgecmdr.common.setting.PowerOffTaps +import org.sleepingcats.bridgecmdr.server.ServerController +import org.sleepingcats.bridgecmdr.server.ServerStatus +import org.sleepingcats.bridgecmdr.ui.repository.SettingsRepository +import org.sleepingcats.bridgecmdr.ui.service.ApplicationService +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel + +class GeneralSettingsViewModel( + logger: KLogger, + private val settings: SettingsRepository, + private val applicationService: ApplicationService, + controller: ServerController, +) : TrackingViewModel(logger) { + data class State( + val isLoading: Boolean = false, + val autoRun: Boolean = false, + val appTheme: AppTheme = AppTheme.System, + val iconSize: IconSize = IconSize.Normal, + val powerOnDevicesAtStart: Boolean = false, + val powerOffTaps: PowerOffTaps = PowerOffTaps.Single, + val runServer: Boolean = false, + val serverStatus: ServerStatus = ServerStatus.Stopped, + ) + + val autoRun = applicationService.checkAutoRunIn(viewModelScope) + + val state = + combine( + autoRun, + settings.data, + controller.status, + ) { autoRun, settingsData, serverStatus -> + State( + autoRun = autoRun, + appTheme = settingsData.appTheme, + iconSize = settingsData.iconSize, + powerOnDevicesAtStart = settingsData.powerOnDevicesAtStart, + powerOffTaps = settingsData.powerOffTaps, + runServer = settingsData.runServer, + serverStatus = serverStatus, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + suspend fun enableAutoRun() = loadingWhile { autoRun.enable() } + + suspend fun disableAutoRun() = loadingWhile { autoRun.disable() } + + suspend fun setAppTheme(newAppTheme: AppTheme) = loadingWhile { settings.setAppTheme(newAppTheme) } + + suspend fun setIconSize(newIconSize: IconSize) = loadingWhile { settings.setIconSize(newIconSize) } + + suspend fun setPowerOnDevicesAtStart(powerOn: Boolean) = loadingWhile { settings.setPowerOnDevicesAtStart(powerOn) } + + suspend fun setPowerOffTaps(taps: PowerOffTaps) = loadingWhile { settings.setPowerOffTaps(taps) } + + suspend fun setRunServer(run: Boolean) = loadingWhile { settings.setRunServer(run) } + + fun exitApplication() { + viewModelScope.launch { applicationService.quit() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ServerCodeViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ServerCodeViewModel.kt new file mode 100644 index 0000000..3ae13b1 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ServerCodeViewModel.kt @@ -0,0 +1,38 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.viewModelScope +import com.google.zxing.BarcodeFormat.QR_CODE +import com.google.zxing.EncodeHintType.MARGIN +import com.google.zxing.qrcode.QRCodeWriter +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.sleepingcats.bridgecmdr.common.ServerCode +import org.sleepingcats.bridgecmdr.common.extension.toImageBitmap +import org.sleepingcats.bridgecmdr.server.ServerController +import org.sleepingcats.bridgecmdr.server.ServerStatus +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel + +class ServerCodeViewModel( + logger: KLogger, + serverController: ServerController, +) : TrackingViewModel(logger) { + data class State( + val qrCode: ImageBitmap? = null, + ) + + private var qrCode = + serverController.status + .filterIsInstance() + .map { status -> ServerCode(status.url, status.tokenSecret, status.publicKey) } + .map { code -> + QRCodeWriter() + .encode(code.qrCodeData, QR_CODE, 256, 256, mapOf(MARGIN to 1)) + .toImageBitmap() + } + + val state = qrCode.map(::State).stateIn(viewModelScope, SharingStarted.Eagerly, State()) +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SettingsViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SettingsViewModel.kt new file mode 100644 index 0000000..82bfebe --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SettingsViewModel.kt @@ -0,0 +1,40 @@ +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository + +class SettingsViewModel( + private val sourceRepository: SourceRepository, + private val deviceRepository: DeviceRepository, +) : ViewModel(), + KoinComponent { + val sourceCount = + sourceRepository.items + .map { it.size } + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + val deviceCount = + deviceRepository.items + .map { it.size } + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + init { + viewModelScope.launch { + refresh() + } + } + + suspend fun refresh() = + coroutineScope { + launch { sourceRepository.refresh() } + launch { deviceRepository.refresh() } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SourceListViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SourceListViewModel.kt new file mode 100644 index 0000000..a5d1f11 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SourceListViewModel.kt @@ -0,0 +1,54 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.sources_failedToDelete +import bridgecmdr.composeapp.generated.resources.sources_failedToRefresh +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import kotlin.uuid.ExperimentalUuidApi + +class SourceListViewModel( + logger: KLogger, + private val repository: SourceRepository, +) : TrackingViewModel(logger) { + data class State( + val sources: List = emptyList(), + val error: ViewError? = null, + val fatalError: ViewError? = null, + ) + + val state = + combine( + repository.items, + error, + fatalError, + ::State, + ).stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + init { + viewModelScope.launch { refresh() } + } + + suspend fun refresh() { + loadingWhile { + runCatching { repository.refresh() } + .onFailure { throwable -> pushFatalError(throwable) { Res.string.sources_failedToRefresh } } + } + } + + suspend fun delete(item: Source) = + loadingWhile { + runCatching { repository.remove(item) } + .onFailure { throwable -> pushError(throwable) { Res.string.sources_failedToDelete } } + .getOrNull() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/TieListViewModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/TieListViewModel.kt new file mode 100644 index 0000000..33ee0ab --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/TieListViewModel.kt @@ -0,0 +1,116 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import androidx.lifecycle.viewModelScope +import bridgecmdr.composeapp.generated.resources.Res +import bridgecmdr.composeapp.generated.resources.ties_failedToDelete +import bridgecmdr.composeapp.generated.resources.ties_failedToRefresh +import bridgecmdr.composeapp.generated.resources.ties_failedToUpdateImage +import bridgecmdr.composeapp.generated.resources.ties_failedToUpdateTitle +import bridgecmdr.composeapp.generated.resources.ties_failedToUploadImage +import io.github.oshai.kotlinlogging.KLogger +import io.github.vinceglb.filekit.PlatformFile +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.sleepingcats.bridgecmdr.common.service.model.Device +import org.sleepingcats.bridgecmdr.common.service.model.Driver +import org.sleepingcats.bridgecmdr.common.service.model.Source +import org.sleepingcats.bridgecmdr.common.service.model.Tie +import org.sleepingcats.bridgecmdr.ui.repository.DeviceRepository +import org.sleepingcats.bridgecmdr.ui.repository.DriverRepository +import org.sleepingcats.bridgecmdr.ui.repository.SourceRepository +import org.sleepingcats.bridgecmdr.ui.repository.TieRepository +import org.sleepingcats.bridgecmdr.ui.view.model.core.TrackingViewModel +import org.sleepingcats.core.extensions.combine +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class TieListViewModel( + private val sourceId: Uuid, + logger: KLogger, + private val userImageModel: UserImageModel, + private val driverRepository: DriverRepository, + private val deviceRepository: DeviceRepository, + private val sourceRepository: SourceRepository, + private val repository: TieRepository, +) : TrackingViewModel(logger) { + data class State( + val drivers: List = emptyList(), + val devices: List = emptyList(), + val source: Source = Source.Blank, + val ties: List = emptyList(), + val error: ViewError? = null, + val fatalError: ViewError? = null, + ) + + val state = + combine( + driverRepository.items, + deviceRepository.items, + sourceRepository.find(sourceId), + repository.items, + error, + fatalError, + ) { drivers, devices, source, ties, error, fatalError -> + State( + drivers = drivers, + devices = devices, + source = checkNotNull(source) { "No source selected" }, + ties = ties, + error = error, + fatalError = fatalError, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), State()) + + init { + viewModelScope.launch { refresh() } + } + + suspend fun refresh() { + loadingCoroutineScope { + runCatching { + listOf( + async { driverRepository.refresh() }, + async { deviceRepository.refresh() }, + ).awaitAll() + + val source = checkNotNull(sourceRepository.findLatest(sourceId)) { "Source not found" } + repository.refreshFromSource(source) + }.onFailure { throwable -> pushFatalError(throwable) { Res.string.ties_failedToRefresh } } + } + } + + suspend fun deleteTie( + tie: Tie, + onFailure: suspend () -> Unit, + ) = loadingWhile { + runCatching { repository.remove(tie) } + .onFailure { throwable -> pushError(throwable) { Res.string.ties_failedToDelete }.also { onFailure() } } + .getOrNull() + } + + suspend fun partialUpdateSource( + id: Uuid, + updates: Source.Update, + onFailure: suspend () -> Unit, + ): Source? = + loadingWhile { + runCatching { sourceRepository.partialUpdate(id, updates) } + .onFailure { throwable -> + pushError(throwable) { + if (updates.title != null) { + Res.string.ties_failedToUpdateTitle + } else { + Res.string.ties_failedToUpdateImage + } + }.also { onFailure() } + }.getOrNull() + } + + suspend fun uploadImage(file: PlatformFile): Uuid? = + loadingWhile { userImageModel.uploadImage(file) { pushError(it) { Res.string.ties_failedToUploadImage } } } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/UserImageModel.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/UserImageModel.kt new file mode 100644 index 0000000..8ec9828 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/UserImageModel.kt @@ -0,0 +1,28 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package org.sleepingcats.bridgecmdr.ui.view.model + +import io.github.oshai.kotlinlogging.KLogger +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.readBytes +import org.sleepingcats.bridgecmdr.common.service.UserImageService +import org.sleepingcats.bridgecmdr.common.service.model.UserImage +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class UserImageModel( + val imageService: UserImageService, + val logger: KLogger, +) { + suspend fun uploadImage( + file: PlatformFile, + onError: suspend (Throwable) -> Unit, + ): Uuid? = + requireNotNull(file.mimeType()?.toString()) { "File has no MIME type" } + .let { type -> + runCatching { imageService.upsert(UserImage.New(file.readBytes(), type)).id } + .onFailure { throwable -> onError(throwable) } + .getOrNull() + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/ApplicationEnvironment.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/ApplicationEnvironment.kt new file mode 100644 index 0000000..5ff9f86 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/ApplicationEnvironment.kt @@ -0,0 +1,33 @@ +package org.sleepingcats.core + +import kotlin.io.path.Path + +class SystemDirectories( + branding: ApplicationBranding, +) { + val config by lazy { BaseDirectories.system.configFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } + val data by lazy { BaseDirectories.system.dataFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } +} + +class UserDirectories( + branding: ApplicationBranding, +) { + val config by lazy { BaseDirectories.user.configFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } + val data by lazy { BaseDirectories.user.dataFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } + val state by lazy { BaseDirectories.user.stateFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } + val cache by lazy { BaseDirectories.user.cacheFor(*branding.qualifiedPath.map { Path(it) }.toTypedArray()) } + val runtime by lazy { BaseDirectories.user.runtime } +} + +class ApplicationDirectories( + branding: ApplicationBranding, +) { + val system by lazy { SystemDirectories(branding) } + val user by lazy { UserDirectories(branding) } +} + +open class ApplicationEnvironment( + branding: ApplicationBranding, +) { + val directories by lazy { ApplicationDirectories(branding) } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/BaseDirectories.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/BaseDirectories.kt new file mode 100644 index 0000000..1fd9571 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/BaseDirectories.kt @@ -0,0 +1,162 @@ +package org.sleepingcats.core + +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.div + +private val homePath by lazy { checkNotNull(Paths.get(System.getProperty("user.home"))) } + +private fun root() = Paths.get("/") + +private fun cDrive() = Paths.get("C:\\") + +private operator fun Path.div(paths: Array) = paths.fold(this) { current, next -> current / next } + +private fun findDirectoryList( + from: String, + defaults: List, +) = System.getenv(from)?.split(':')?.map { Paths.get(it) } ?: defaults + +private fun findDirectory( + from: String, + default: Path, +) = System.getenv(from)?.let { Paths.get(it) } ?: default + +private fun expandPath(string: String) = + checkNotNull( + Path.of( + System.getenv().toList().fold(string) { string, (key, value) -> + string.replace($$"$$${key}", value) + }, + ), + ) + +interface BaseSystemDirectories { + val config: List + val data: List + + fun configFor(vararg qualifiers: Path): List + + fun dataFor(vararg qualifiers: Path): List +} + +class XdgSystemDirectories : BaseSystemDirectories { + override val config by lazy { findDirectoryList("XDG_CONFIG_DIRS", listOf(root() / "etc")) } + override val data by lazy { + findDirectoryList( + "XDG_DATA_DIRS", + listOf( + root() / "usr" / "share", + root() / "usr" / "local" / "share", + ), + ) + } + + override fun configFor(vararg qualifiers: Path) = config.map { it / qualifiers } + + override fun dataFor(vararg qualifiers: Path) = data.map { it / qualifiers } +} + +class WindowsSystemDirectories : BaseSystemDirectories { + override val config by lazy { findDirectoryList("ALLUSERSPROFILE", listOf(cDrive() / "ProgramData")) } + override val data by lazy { findDirectoryList("ALLUSERSPROFILE", listOf(cDrive() / "ProgramData")) } + + override fun configFor(vararg qualifiers: Path) = config.map { it / qualifiers / "Settings" } + + override fun dataFor(vararg qualifiers: Path) = data.map { it / qualifiers / "Data" } +} + +class MacOsSystemDirectories : BaseSystemDirectories { + override val config by lazy { listOf(root() / "Library" / "Preferences") } + override val data by lazy { listOf(root() / "Library" / "Application Support") } + + override fun configFor(vararg qualifiers: Path) = config.map { it / qualifiers } + + override fun dataFor(vararg qualifiers: Path) = data.map { it / qualifiers } +} + +interface BaseUserDirectories { + val config: Path + val data: Path + val state: Path + val cache: Path + + val runtime: Path + + fun configFor(vararg qualifiers: Path): Path + + fun dataFor(vararg qualifiers: Path): Path + + fun stateFor(vararg qualifiers: Path): Path + + fun cacheFor(vararg qualifiers: Path): Path +} + +class XdgUserDirectories : BaseUserDirectories { + override val config by lazy { findDirectory("XDG_CONFIG_HOME", homePath / ".config") } + override val data by lazy { findDirectory("XDG_DATA_HOME", homePath / ".local" / "share") } + override val state by lazy { findDirectory("XDG_STATE_HOME", homePath / ".local" / "state") } + override val cache by lazy { findDirectory("XDG_CACHE_HOME", homePath / ".cache") } + + override val runtime by lazy { findDirectory("XDG_RUNTIME_DIR", root() / "tmp") } + + override fun configFor(vararg qualifiers: Path) = config / qualifiers + + override fun dataFor(vararg qualifiers: Path) = data / qualifiers + + override fun stateFor(vararg qualifiers: Path) = state / qualifiers + + override fun cacheFor(vararg qualifiers: Path) = cache / qualifiers +} + +class WindowsUserDirectories : BaseUserDirectories { + override val config by lazy { findDirectory("APPDATA", homePath / "AppData" / "Roaming") } + override val data by lazy { findDirectory("APPDATA", homePath / "AppData" / "Roaming") } + override val state by lazy { findDirectory("LOCALAPPDATA", homePath / "AppData" / "Local") } + override val cache by lazy { findDirectory("LOCALAPPDATA", homePath / "AppData" / "Local") } + + override val runtime: Path get() = TODO("Implement Windows runtime directory") + + override fun configFor(vararg qualifiers: Path) = config / qualifiers / "Settings" + + override fun dataFor(vararg qualifiers: Path) = data / qualifiers / "Data" + + override fun stateFor(vararg qualifiers: Path) = state / qualifiers / "State" + + override fun cacheFor(vararg qualifiers: Path) = cache / qualifiers / "Cache" +} + +class MacOsUserDirectories : BaseUserDirectories { + override val config by lazy { homePath / "Library" / "Preferences" } + override val data by lazy { homePath / "Library" / "Application Support" } + override val state by lazy { homePath / "Library" / "Application Support" } + override val cache by lazy { homePath / "Library" / "Caches" } + + override val runtime by lazy { expandPath($$"$TMPDIR") / expandPath($$"runtime-$UID") } + + override fun configFor(vararg qualifiers: Path) = config / qualifiers + + override fun dataFor(vararg qualifiers: Path) = data / qualifiers / "Data" + + override fun stateFor(vararg qualifiers: Path) = data / qualifiers / "State" + + override fun cacheFor(vararg qualifiers: Path) = cache / qualifiers +} + +object BaseDirectories { + val home by lazy { homePath } + val user: BaseUserDirectories by lazy { + when { + Platform.isWindows -> WindowsUserDirectories() + Platform.isMac -> MacOsUserDirectories() + else -> XdgUserDirectories() + } + } + val system: BaseSystemDirectories by lazy { + when { + Platform.isWindows -> WindowsSystemDirectories() + Platform.isMac -> MacOsSystemDirectories() + else -> XdgSystemDirectories() + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/Platform.jvm.kt b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/Platform.jvm.kt new file mode 100644 index 0000000..f6af29b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/core/Platform.jvm.kt @@ -0,0 +1,10 @@ +package org.sleepingcats.core + +class JvmPlatform : Platform { + override val name = checkNotNull(System.getProperty("os.name")) + override val version = checkNotNull(System.getProperty("os.version")) + override val arch = checkNotNull(System.getProperty("os.arch")) + override val type = PlatformType.Jvm +} + +actual fun getPlatform(): Platform = JvmPlatform() diff --git a/composeApp/src/jvmMain/resources/logback.xml b/composeApp/src/jvmMain/resources/logback.xml new file mode 100644 index 0000000..6ef148d --- /dev/null +++ b/composeApp/src/jvmMain/resources/logback.xml @@ -0,0 +1,16 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/.empty b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/.empty new file mode 100644 index 0000000..e69de29 diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/AbstractProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/AbstractProtocolTest.kt new file mode 100644 index 0000000..222577b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/AbstractProtocolTest.kt @@ -0,0 +1,110 @@ +package org.sleepingcats.bridgecmdr.test.driver + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.jupiter.api.assertDoesNotThrow +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.ExtronSisProtocol +import org.sleepingcats.bridgecmdr.common.protocol.SonyProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test + +class AbstractProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `no-op power on`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + val protocol = ExtronSisProtocol() + + assertDoesNotThrow { + protocol.sendPowerOn("ip:test") + } + + coVerify(exactly = 0) { stream.sendCommand(any(), any()) } + coVerify(exactly = 0) { stream.sendCommand(any(), any()) } + } + + @Test + fun `no-op power off`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + val protocol = ExtronSisProtocol() + + assertDoesNotThrow { + protocol.sendPowerOff("ip:test") + } + + coVerify(exactly = 0) { stream.sendCommand(any(), any()) } + coVerify(exactly = 0) { stream.sendCommand(any(), any()) } + } + + @Test + fun `power on`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + val protocol = SonyProtocol() + + assertDoesNotThrow { + protocol.powerOn("ip:test").await() + } + } + + @Test + fun `power off`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + val protocol = SonyProtocol() + + assertDoesNotThrow { + protocol.powerOff("ip:test").await() + } + } + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand(any(), any()) } throws Exception("Simulated send failure") + val protocol = SonyProtocol() + + assertDoesNotThrow { + protocol.activate("ip:test", 1).await() + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ExtronSisProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ExtronSisProtocolTest.kt new file mode 100644 index 0000000..31ab5f2 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ExtronSisProtocolTest.kt @@ -0,0 +1,58 @@ +package org.sleepingcats.bridgecmdr.test.driver + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.ExtronSisProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test + +class ExtronSisProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val input = 1 + val video = 2 + val audio = 3 + + val command = "$input*$video%\r\n$input*$audio$\r\n" + val protocol = ExtronSisProtocol() + + protocol.activate("ip:test", input, video, audio).await() + + coVerify { stream.sendCommand("ip:test", command) } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ShinybowProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ShinybowProtocolTest.kt new file mode 100644 index 0000000..56c18b3 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ShinybowProtocolTest.kt @@ -0,0 +1,187 @@ +package org.sleepingcats.bridgecmdr.test.driver + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.ShinybowProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ShinybowProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `protocol version`(): Unit = + runBlocking(Dispatchers.Unconfined) { + assertFailsWith { + val protocol = ShinybowProtocol(1) + protocol.sendPowerOn("ip:test") + } + assertFailsWith { + val protocol = ShinybowProtocol(1) + protocol.sendActivate("ip:test", 1, 1, 1) + } + } + + @Test + fun `power on - v2`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val command = "POWER 01;\r\n" + val protocol = ShinybowProtocol(2) + + protocol.powerOn("ip:test").await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `power on - v3`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val command = "POWER 001;\r\n" + val protocol = ShinybowProtocol(3) + + protocol.powerOn("ip:test").await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `power off - v2`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val command = "POWER 00;\r\n" + val protocol = ShinybowProtocol(2) + + protocol.powerOff("ip:test").await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `power off - v3`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val command = "POWER 000;\r\n" + val protocol = ShinybowProtocol(3) + + protocol.powerOff("ip:test").await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `activate tie - v2`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val input = 1 + val output = 20 + val command = "OUTPUT20 01;\r\n" + + val protocol = ShinybowProtocol(2) + + protocol.activate("ip:test", input, output).await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `activate tie - v3`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val input = 1 + val output = 202 + val command = "OUTPUT202 001;\r\n" + + val protocol = ShinybowProtocol(3) + + protocol.activate("ip:test", input, output).await() + + coVerify { stream.sendCommand("ip:test", command) } + } + + @Test + fun `activate tie - v2 - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val protocol = ShinybowProtocol(2) + + assertFailsWith { + protocol.sendActivate("ip:test", 0, 1, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 100, 1, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 1, 0, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 1, 100, 1) + } + } + + @Test + fun `activate tie - v3 - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + + val protocol = ShinybowProtocol(3) + assertFailsWith { + protocol.sendActivate("ip:test", 0, 1, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 1000, 1, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 1, 0, 1) + } + assertFailsWith { + protocol.sendActivate("ip:test", 1, 1000, 1) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/InternalSonyProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/InternalSonyProtocolTest.kt new file mode 100644 index 0000000..8feae9f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/InternalSonyProtocolTest.kt @@ -0,0 +1,101 @@ +package org.sleepingcats.bridgecmdr.test.driver.sony + +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.Address +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.AddressKind.All +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.AddressKind.Group +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.AddressKind.Monitor +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.Packet +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.PacketError +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.PacketType.Command +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.sleepingcats.bridgecmdr.common.protocol.support.sony.Command as CommandPacket + +class InternalSonyProtocolTest { + @Test + fun `good packet`() { + assertContentEquals( + Packet + .create(Command, ByteArray(5).apply { fill(0) }) + .bytes, + "02050000000000fb".hexToByteArray(), + ) + } + + @Test + fun `empty packet`() { + assertFailsWith { + Packet + .create(Command, ByteArray(0)) + .bytes + } + } + + @Test + fun `oversized packet`() { + assertFailsWith { + Packet + .create(Command, ByteArray(256)) + .bytes + } + } + + @Test + fun `addresses values`() { + assertEquals(Address.create(All, 5).byte, "c5".hexToByte()) + assertEquals(Address.create(Group, 5).byte, "85".hexToByte()) + assertEquals(Address.create(Monitor, 5).byte, 5) + } + + @Test + fun `addresses values - input range`() { + assertFailsWith { Address.create(All, 128) } + assertFailsWith { Address.create(Group, 128) } + assertFailsWith { Address.create(Monitor, 128) } + assertFailsWith { Address.create(All, -128) } + assertFailsWith { Address.create(Group, -128) } + assertFailsWith { Address.create(Monitor, -128) } + } + + @Test + fun `command packets`() { + val address = Address.create(All, 0xf) + assertContentEquals( + CommandPacket + .createPacket(address, address, CommandPacket.PowerOn) + .bytes, + "0204cfcf293ef7".hexToByteArray(), + ) + assertContentEquals( + CommandPacket + .createPacket(address, address, CommandPacket.PowerOn, 4) + .bytes, + "0205cfcf293e04f2".hexToByteArray(), + ) + assertContentEquals( + CommandPacket + .createPacket(address, address, CommandPacket.PowerOn, 3, 2) + .bytes, + "0206cfcf293e0302f0".hexToByteArray(), + ) + } + + @Test + fun `command packets - input range`() { + val address = Address.create(All, 0xf) + assertFailsWith { + CommandPacket.createPacket(address, address, CommandPacket.PowerOn, -1) + } + assertFailsWith { + CommandPacket.createPacket(address, address, CommandPacket.PowerOn, 256) + } + assertFailsWith { + CommandPacket.createPacket(address, address, CommandPacket.PowerOn, 0, -1) + } + assertFailsWith { + CommandPacket.createPacket(address, address, CommandPacket.PowerOn, 0, 256) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/SonyProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/SonyProtocolTest.kt new file mode 100644 index 0000000..d283fa0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/SonyProtocolTest.kt @@ -0,0 +1,116 @@ +package org.sleepingcats.bridgecmdr.test.driver.sony + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.SonyProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class SonyProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `power on`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "0204c0c0293e15".hexToByteArray() + val protocol = SonyProtocol() + + protocol.powerOn("ip:test").await() + + // Packet type: Command == 0x02 + // Packet contents length: 4 + // Command packet: + // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Command: PowerOn == 0x293e (in BE) + // Checksum: 0x15 + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `power off`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "0204c0c02a3e14".hexToByteArray() + val protocol = SonyProtocol() + + protocol.powerOff("ip:test").await() + + // Packet type: Command == 0x02 + // Packet contents length: 4 + // Command packet: + // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Command: PowerOff == 0x2a3e (in BE) + // Checksum: 0x14 + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "0206c0c02100010157".hexToByteArray() + val protocol = SonyProtocol() + + protocol.activate("ip:test", 1).await() + + // Packet type: Command == 0x02 + // Packet contents length: 6 + // Command packet: + // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 + // Command: Set Channel == 0x2100 (in BE) + // Channel: 1 + // Unknown: Always 0x01 + // Checksum: 0x57 + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `activate tie - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + assertFailsWith { + val protocol = SonyProtocol() + protocol.sendActivate("ip:test", 0, 0, 0) + } + assertFailsWith { + val protocol = SonyProtocol() + protocol.sendActivate("ip:test", 256, 0, 0) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecKvmProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecKvmProtocolTest.kt new file mode 100644 index 0000000..de3492b --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecKvmProtocolTest.kt @@ -0,0 +1,67 @@ +package org.sleepingcats.bridgecmdr.test.driver.teslaelect + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecKvmProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class TeslaElecKvmProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "aabb030101ee".hexToByteArray() + val protocol = TeslaElecKvmProtocol() + + protocol.activate("ip:test", 1).await() + + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `activate tie - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + assertFailsWith { + val protocol = TeslaElecKvmProtocol() + protocol.sendActivate("ip:test", 0, 0, 0) + } + assertFailsWith { + val protocol = TeslaElecKvmProtocol() + protocol.sendActivate("ip:test", 256, 0, 0) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecMatrixProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecMatrixProtocolTest.kt new file mode 100644 index 0000000..c59854a --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecMatrixProtocolTest.kt @@ -0,0 +1,75 @@ +package org.sleepingcats.bridgecmdr.test.driver.teslaelect + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecMatrixProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class TeslaElecMatrixProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "MT00SW0102NT\r\n" + val protocol = TeslaElecMatrixProtocol() + + protocol.activate("ip:test", 1, 2).await() + + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `activate tie - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + assertFailsWith { + val protocol = TeslaElecMatrixProtocol() + protocol.sendActivate("ip:test", 0, 1, 0) + } + assertFailsWith { + val protocol = TeslaElecMatrixProtocol() + protocol.sendActivate("ip:test", 100, 1, 0) + } + assertFailsWith { + val protocol = TeslaElecMatrixProtocol() + protocol.sendActivate("ip:test", 1, 0, 0) + } + assertFailsWith { + val protocol = TeslaElecMatrixProtocol() + protocol.sendActivate("ip:test", 1, 100, 0) + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecSdiProtocolTest.kt b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecSdiProtocolTest.kt new file mode 100644 index 0000000..2689c56 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecSdiProtocolTest.kt @@ -0,0 +1,67 @@ +package org.sleepingcats.bridgecmdr.test.driver.teslaelect + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.koin.dsl.module +import org.koin.test.KoinTestRule +import org.sleepingcats.bridgecmdr.common.protocol.TeslaElecSdiProtocol +import org.sleepingcats.bridgecmdr.common.protocol.stream.ProtocolStream +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class TeslaElecSdiProtocolTest { + @get:Rule + val mockRule = MockKRule(this) + + @get:Rule + val koinTestRule = + KoinTestRule.create { + printLogger() + modules( + module { + single { KotlinLogging.logger {} } + }, + ) + } + + init { + mockkObject(ProtocolStream.Companion) + } + + @MockK + lateinit var stream: ProtocolStream + + @Test + fun `activate tie`() = + runBlocking(Dispatchers.Unconfined) { + every { ProtocolStream.create(any(), any(), any()) } returns stream + coEvery { stream.sendCommand("ip:test", any()) } returns Unit + val commandBytes = "aacc0101".hexToByteArray() + val protocol = TeslaElecSdiProtocol() + + protocol.activate("ip:test", 1).await() + + coVerify { stream.sendCommand("ip:test", commandBytes) } + } + + @Test + fun `activate tie - input range`(): Unit = + runBlocking(Dispatchers.Unconfined) { + assertFailsWith { + val protocol = TeslaElecSdiProtocol() + protocol.sendActivate("ip:test", 0, 0, 0) + } + assertFailsWith { + val protocol = TeslaElecSdiProtocol() + protocol.sendActivate("ip:test", 256, 0, 0) + } + } +} diff --git a/electron-builder.yml b/electron-builder.yml deleted file mode 100644 index 79455d2..0000000 --- a/electron-builder.yml +++ /dev/null @@ -1,43 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json -appId: org.sleepingcats.${name} -productName: BridgeCmdr -copyright: 'Copyright ©2019-2024 ${author}' -artifactName: ${name}-${version}-${arch}.${ext} -directories: - buildResources: resources -files: - - '!.husky${/*}' - - '!**/.vscode${/*}' - - '!**/.idea${/*}' - - '!.yarn${/*}' - - '!coverage${/*}' - - '!docker${/*}' - - '!logs${/*}' - - '!src${/*}' - - '!.git${/*}' - - '!{.dockerignore|Dockerfile|dev-app-update.yml}' - - '!{.env|.env.*|.editorconfig}' - - '!{.eslintignore|.eslintrc.cjs}' - - '!.git*' - - '!{.ncurc.yaml|.nvmrc|.yarnrc.yml}' - - '!{electron-builder.yml}' - - '!electron.vite.config.{js|ts|mjs|cjs}' - - '!{.prettierignore|.prettierrc.json|prettier.config.cjs}' - - '!{CHANGELOG.md|README.md}' - - '!{tsconfig.json|tsconfig.*.json}' - - '!vite.config.{js|ts|mjs|cjs}' - - '!{yarn.lock|.npmrc|pnpm-lock.yaml}' -asarUnpack: - - node_modules/electron-log/**/* - - resources/** -linux: - compression: store - target: - - AppImage - maintainer: Matthew Holder - category: Utility -buildDependenciesFromSource: true -npmRebuild: true -publish: - provider: github - protocol: https diff --git a/electron.vite.config.ts b/electron.vite.config.ts deleted file mode 100644 index 5122332..0000000 --- a/electron.vite.config.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { fileURLToPath, URL } from 'node:url' -import vueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' -import vue from '@vitejs/plugin-vue' -import vueJsx from '@vitejs/plugin-vue-jsx' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import vueDevTools from 'vite-plugin-vue-devtools' -import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' -import tsconfigPaths from 'vite-tsconfig-paths' -import main from './vite.config' - -export default defineConfig({ - main, - preload: { - plugins: [ - externalizeDepsPlugin(), - tsconfigPaths({ - projects: [fileURLToPath(new URL('./tsconfig.node.json', import.meta.url))], - loose: true - }) - ], - esbuild: { - target: ['chrome126'] - }, - resolve: { - alias: { - buffer: fileURLToPath(new URL('./node_modules/buffer', import.meta.url)), - stream: fileURLToPath(new URL('./node_modules/stream-browserify', import.meta.url)), - util: fileURLToPath(new URL('./node_modules/util', import.meta.url)) - } - } - }, - renderer: { - plugins: [ - tsconfigPaths({ - projects: [fileURLToPath(new URL('./tsconfig.web.json', import.meta.url))], - loose: true - }), - vue({ template: { transformAssetUrls } }), - vueJsx(), - vuetify(), - vueDevTools(), - vueI18nPlugin({ - runtimeOnly: false, - include: [ - 'src/renderer/locales/**/*.json', - 'src/renderer/locales/**/*.json5', - 'src/renderer/locales/**/*.yaml', - 'src/renderer/locales/**/*.yml' - ] - }) as never - ], - esbuild: { - target: ['chrome126'] - }, - resolve: { - alias: { - buffer: fileURLToPath(new URL('./node_modules/buffer', import.meta.url)), - stream: fileURLToPath(new URL('./node_modules/stream-browserify', import.meta.url)), - util: fileURLToPath(new URL('./node_modules/util', import.meta.url)) - } - }, - css: { - preprocessorOptions: { - sass: { api: 'modern-compiler' }, - scss: { api: 'modern-compiler' } - } - } - } -}) diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7d732cf --- /dev/null +++ b/gradle.properties @@ -0,0 +1,10 @@ +#Kotlin +kotlin.code.style=official +kotlin.daemon.jvmargs=-Xmx3072M +#Gradle +org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true +org.gradle.caching=true +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4187750 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,108 @@ +[versions] +agp = "8.11.2" +android-compileSdk = "36" +android-minSdk = "26" +android-targetSdk = "36" +androidx-activity = "1.12.2" +androidx-datastore = "1.2.0" +androidx-lifecycle = "2.9.6" +androidx-navigation = "2.9.1" +composeHotReload = "1.0.0" +composeMultiplatform = "1.9.3" +kotlin = "2.2.21" +kotlin-serializaiton = "1.9.0" +junit = "4.13.2" +kover = "0.9.4" +kotlinx-coroutines = "1.10.2" +ktor = "3.3.3" +koin = "4.1.1" +logback = "1.5.24" +exposed = "1.0.0-rc-4" +sqlite = "3.49.1.0" +konform = "0.11.1" +atlassian-onetime = "2.2.0" +jserialcomm = "2.11.4" +oshai-logging = "7.0.14" +dbus-java = "5.1.1" +reorderable = "3.0.0" +zxing = "3.5.4" +filekit = "0.12.0" +coil = "3.3.0" +slf4j = "2.0.17" +uk-slf4j = "2.0.17-0" +code-scanner = "2.3.2" +mockk = "1.14.7" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref="kotlin-serializaiton" } +junit = { module = "junit:junit", version.ref = "junit" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } +koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } +koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4", version.ref = "koin" } +koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } +koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } +koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } +koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" } +koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +ktor-network = { module = "io.ktor:ktor-network", version.ref="ktor" } +ktor-network-tlsCertificates = { module = "io.ktor:ktor-network-tls-certificates", version.ref="ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } +ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } +ktor-server-testHost = { module = "io.ktor:ktor-server-test-host-jvm", version.ref = "ktor" } +ktor-server-di = { module = "io.ktor:ktor-server-di", version.ref = "ktor" } +ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" } +ktor-server-contentNegotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } +ktor-server-requestValidation = { module = "io.ktor:ktor-server-request-validation", version.ref = "ktor" } +ktor-server-resources = { module = "io.ktor:ktor-server-resources", version.ref = "ktor" } +ktor-server-statusPages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktor" } +ktor-serializationJson = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +konform = { module = "io.konform:konform", version.ref="konform" } +atlassian-onetime = { module = "com.atlassian:onetime", version.ref = "atlassian-onetime" } +jserialcomm = { module = "com.fazecast:jSerialComm", version.ref = "jserialcomm" } +exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" } +exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } +exposed-migration-core = { module = "org.jetbrains.exposed:exposed-migration-core", version.ref = "exposed" } +exposed-migration-jdbc = { module = "org.jetbrains.exposed:exposed-migration-jdbc", version.ref = "exposed" } +oshai-logging = { module = "io.github.oshai:kotlin-logging", version.ref="oshai-logging" } +dbus-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = "dbus-java" } +dbus-transport-nativeUnixsocket = { module = "com.github.hypfvieh:dbus-java-transport-native-unixsocket", version.ref = "dbus-java" } +reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } +zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } +sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } +slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +uk-slf4j = { module = "uk.uuid.slf4j:slf4j-android", version.ref = "uk-slf4j" } +codeScanner = { module = "com.github.yuriy-budiyev:code-scanner", version.ref = "code-scanner" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } + +[plugins] +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktor = { id = "io.ktor.plugin", version.ref = "ktor" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/package.json b/package.json deleted file mode 100644 index 4745f8a..0000000 --- a/package.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "name": "bridgecmdr", - "productName": "BridgeCmdr", - "version": "2.2.2", - "description": "Controller for professional A/V monitors and switches", - "packageManager": "yarn@1.22.22", - "type": "module", - "private": true, - "engines": { - "node": ">= 20", - "electron": ">= 31" - }, - "main": "./out/main/index.js", - "scripts": { - "docker:build": "docker build --platform arm -t bridgecmdr .", - "docker:run": "docker run --platform=arm -u $UID:$GID -v .:/app -w /app --rm -it bridgecmdr /bin/bash", - "docker:sh": "npm run docker:build && npm run docker:run", - "postinstall": "electron-builder install-app-deps", - "check:prettier": "prettier -cu .", - "check:lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore --ignore-path .eslintignore", - "check": "run-p -l typecheck 'check:*'", - "fix:prettier": "prettier -cu --write .", - "fix:lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore --ignore-path .eslintignore", - "fix": "run-s -l typecheck 'fix:*'", - "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", - "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck:test": "vue-tsc --noEmit -p tsconfig.test.json --composite false", - "typecheck:config": "vue-tsc --noEmit -p tsconfig.config.json --composite false", - "typecheck": "run-p -l 'typecheck:*'", - "start": "electron-vite preview", - "watch": "vitest", - "test": "vitest run", - "coverage": "vitest run --coverage", - "dev": "electron-vite dev", - "build": "run-p -l typecheck build-only", - "build-only": "electron-vite build", - "clean": "del dist out", - "package:dir": "electron-builder --dir --config", - "package": "run-s -l clean build package:dir", - "make:current": "electron-builder --config", - "make": "run-s -l clean build make:current", - "ncu:update": "ncu -u", - "ncu:check": "ncu", - "ncu": "ncu", - "prepare": "husky" - }, - "repository": "github:6XGate/bridgecmdr", - "keywords": [ - "linux", - "raspberry-pi", - "retrogaming", - "audio-video", - "sony-bmv", - "sony-pvm", - "extron" - ], - "author": "Matthew Holder", - "license": "GPL-3.0-or-later", - "bugs": { - "url": "https://github.com/6XGate/bridgecmdr/issues" - }, - "homepage": "https://github.com/6XGate/bridgecmdr#readme", - "devDependencies": { - "@intlify/core-base": "^10.0.6", - "@intlify/unplugin-vue-i18n": "^5.3.1", - "@mdi/js": "^7.4.47", - "@mdi/svg": "^7.4.47", - "@sindresorhus/is": "^7.0.1", - "@sixxgate/lint": "^4.0.0", - "@trpc/client": "^10.45.2", - "@trpc/server": "^10.45.2", - "@tsconfig/node20": "^20.1.5", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint": "^8.56.12", - "@types/ini": "^4.1.1", - "@types/leveldown": "^4.0.6", - "@types/levelup": "^5.1.5", - "@types/node": "^20.17.30", - "@types/pouchdb-core": "^7.0.15", - "@types/pouchdb-find": "^7.3.3", - "@types/ws": "^8.5.14", - "@typescript-eslint/eslint-plugin": "^8.22.0", - "@typescript-eslint/parser": "^8.22.0", - "@vitejs/plugin-vue": "^5.2.3", - "@vitejs/plugin-vue-jsx": "^4.1.2", - "@vitest/coverage-v8": "^2.1.9", - "@vue/eslint-config-prettier": "^9.0.0", - "@vue/eslint-config-typescript": "^13.0.0", - "@vue/tsconfig": "^0.7.0", - "@vuelidate/core": "^2.0.3", - "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^11.3.0", - "@vueuse/shared": "^11.3.0", - "@zip.js/zip.js": "^2.7.60", - "assert": "^2.1.0", - "auto-bind": "^5.0.1", - "bufferutil": "^4.0.9", - "del-cli": "^6.0.0", - "electron": "^31.7.7", - "electron-builder": "^24.13.3", - "electron-unhandled": "^5.0.0", - "electron-updater": "^6.4.1", - "electron-vite": "^2.3.0", - "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-import-resolver-typescript": "^3.7.0", - "eslint-plugin-import": "^2.31.0", - "eslint-plugin-jsdoc": "^50.6.9", - "eslint-plugin-n": "^17.15.1", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-promise": "^7.2.1", - "eslint-plugin-tsdoc": "^0.4.0", - "eslint-plugin-vue": "^9.32.0", - "execa": "^9.5.2", - "husky": "^9.1.7", - "ini": "^5.0.0", - "levelup": "^5.1.1", - "mime": "^4.0.6", - "npm-check-updates": "^17.1.16", - "npm-run-all2": "^7.0.2", - "pinia": "^2.3.1", - "pouchdb-adapter-leveldb-core": "^9.0.0", - "pouchdb-core": "^9.0.0", - "pouchdb-find": "^9.0.0", - "prettier": "^3.4.2", - "radash": "^12.1.0", - "sass-embedded": "^1.83.4", - "superjson": "^2.2.2", - "tslib": "^2.8.1", - "type-fest": "^4.33.0", - "typescript": "5.7.3", - "typescript-eslint-parser-for-extra-files": "^0.7.0", - "utf-8-validate": "^6.0.5", - "vite": "^5.4.16", - "vite-plugin-vue-devtools": "^7.7.1", - "vite-plugin-vuetify": "^2.0.4", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^2.1.9", - "vue": "^3.5.13", - "vue-eslint-parser": "^9.4.3", - "vue-i18n": "^10.0.6", - "vue-router": "^4.5.0", - "vue-tsc": "2.2.0", - "vuetify": "^3.7.19", - "ws": "^8.18.1", - "xdg-basedir": "^5.1.0", - "zod": "^3.24.1" - }, - "dependencies": { - "@electron-toolkit/utils": "^3.0.0", - "@types/pouchdb-mapreduce": "^6.1.10", - "electron-log": "^5.3.0", - "leveldown": "^6.1.1", - "pouchdb-mapreduce": "^9.0.0", - "serialport": "^12.0.0" - } -} diff --git a/prettier.config.cjs b/prettier.config.cjs deleted file mode 100644 index 4502e62..0000000 --- a/prettier.config.cjs +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-env node */ -'use strict' -module.exports = require('@sixxgate/lint/prettier.config.cjs') diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d073cbe --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,38 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + maven { + url = uri("https://jitpack.io") + } + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "BridgeCmdr" +include(":composeApp") diff --git a/src/core/attachments.ts b/src/core/attachments.ts deleted file mode 100644 index 72f68ca..0000000 --- a/src/core/attachments.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class Attachment extends Uint8Array { - readonly name - readonly type - - static async fromFile(file: File) { - return new Attachment(file.name, file.type, await file.arrayBuffer()) - } - - static async fromPouchAttachment(name: string, attachment: PouchDB.Core.FullAttachment) { - if (Buffer.isBuffer(attachment.data)) { - return new Attachment(name, attachment.content_type, attachment.data) - } - - if (attachment.data instanceof Blob) { - return new Attachment(name, attachment.content_type, await attachment.data.arrayBuffer()) - } - - const textEncoder = new TextEncoder() - return new Attachment(name, attachment.content_type, textEncoder.encode(attachment.data)) - } - - constructor(name: string, type: string, data: ArrayBufferLike) { - super(data) - this.name = name - this.type = type - } -} diff --git a/src/core/base64.ts b/src/core/base64.ts deleted file mode 100644 index 72cdd86..0000000 --- a/src/core/base64.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** Converts a byte array into a Base64 string. */ -export function toBase64(data: Uint8Array) { - return btoa(String.fromCodePoint(...data)) -} - -function* codePointsOf(data: string) { - for (let i = 0, cp = data.codePointAt(i); cp != null; ++i, cp = data.codePointAt(i)) { - yield cp - } -} - -/** Coverts a Base64 string into a byte array. */ -export function fromBase64(data: string) { - return new Uint8Array([...codePointsOf(atob(data))]) -} diff --git a/src/core/basics.ts b/src/core/basics.ts deleted file mode 100644 index 6175f91..0000000 --- a/src/core/basics.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Provides a means to define a tuple via satisfies. */ -export type Fixed = [T, ...T[]] - -/** Could be a promise. */ -export type MaybePromise = Promise | T - -/** Guards and filter to not nullish */ -export const isNotNullish = (value: T | null | undefined): value is T => value != null - -/** Wraps a non-array value in an array, or simply passes an array through. */ -export function toArray(value: T): T extends unknown[] ? T : T[] { - if (value == null) { - return [] as never - } - - return (Array.isArray(value) ? value : [value]) as never -} - -/** - * Converts and array of path segments to an object path - * @param path - Path segments. - * @returns Object path akin to radash `get`. - */ -export const toObjectPath = (path: (number | string)[]) => - path - .map((segment) => (typeof segment === 'number' ? `[${segment}]` : segment)) - .reduce((p, c) => (c.startsWith('[') ? `${p}${c}` : `${p}.${c}`)) - -/** - * Creates a new promise with externally accessible fulfillment operations. - * - * This is a polyfill for - * [Promise.withResolver](https://developer.mozilla.org/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers). - * - * @returns An object with a Promise and its fulfillment operations. - */ -export function withResolvers() { - /* v8 ignore next 2 */ // Won't be used. - let resolve: (value: T | PromiseLike) => void = () => undefined - let reject: (reason?: unknown) => void = () => undefined - - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject - }) - - return { resolve, reject, promise } -} - -/** - * Run an operation asynchronously as a microtask. - * @param op - The operation to run as a micro-task. - * @returns The result of the operation. - */ -export async function asMicrotask(op: () => MaybePromise) { - return await new Promise((resolve, reject) => { - queueMicrotask(() => { - try { - Promise.resolve(op()).then(resolve).catch(reject) - } catch (cause) { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Proxied - reject(cause) - } - }) - }) -} diff --git a/src/core/delegates.ts b/src/core/delegates.ts deleted file mode 100644 index a82de3b..0000000 --- a/src/core/delegates.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** The type of functions. */ -export type Func = (...args: Args) => Result - -/** The type of action functions. */ -export type Action = (...args: Args) => void diff --git a/src/core/error-handling.ts b/src/core/error-handling.ts deleted file mode 100644 index f33a837..0000000 --- a/src/core/error-handling.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { toObjectPath } from './basics' -import type { IsAny } from 'type-fest' -import type { z } from 'zod' - -export function getZodMessage(e: z.ZodError) { - const flattened = e.flatten((issue) => - issue.path.length > 0 ? `${toObjectPath(issue.path)}: ${issue.message}` : issue.message - ) - - return ( - flattened.formErrors[0] ?? - // Map all fields to their first error, and find the first that has an error. - Object.values(flattened.fieldErrors) - .map((errors) => errors?.[0]) - .find((error) => error != null) - ) -} - -export function getMessage(cause: unknown) { - if (cause instanceof Error) return cause.message - if (cause == null) return `BadError: ${cause}` - if (typeof cause === 'string') return cause - if (typeof cause !== 'object') return String(cause as never) - if (!('message' in cause)) return `BadError: ${Object.prototype.toString.call(cause)}` - if (typeof cause.message !== 'string') return String(cause.message) - return cause.message -} - -export type AsError = IsAny extends true ? Error : T extends Error ? T : Error - -export function toError(cause: Cause): AsError -export function toError(cause: unknown) { - if (cause instanceof Error) return cause - return new Error(getMessage(cause)) -} - -export function isNodeError(value: unknown): value is NodeJS.ErrnoException -export function isNodeError Error>( - value: unknown, - type: E -): value is InstanceType & NodeJS.ErrnoException -export function isNodeError(value: unknown, type: new (...args: any[]) => Error = Error) { - return value instanceof type && (value as NodeJS.ErrnoException).code != null -} - -export function raiseError(factory: () => Error): never { - throw factory() -} diff --git a/src/core/keys.ts b/src/core/keys.ts deleted file mode 100644 index 8bbf81b..0000000 --- a/src/core/keys.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Tagged } from 'type-fest' - -declare const KeyType: unique symbol - -export type BrandedKey = Tagged< - Key, - Kind, - { - [KeyType]: T - } -> - -export type SymbolKey = BrandedKey - -export type NumericKey = BrandedKey - -export type StringKey = BrandedKey diff --git a/src/core/location.ts b/src/core/location.ts deleted file mode 100644 index 7e69d21..0000000 --- a/src/core/location.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { z } from 'zod' -import type { Fixed } from './basics' -import type { PortInfo } from '../main/services/ports' - -export type LocationType = 'port' | 'ip' - -/** Determines if a value is a serial port or simplify has the `ip:` prefix. */ -export function isIpOrValidPort(value: string, ports: readonly PortInfo[]) { - switch (true) { - case value.startsWith('port:'): - return ports.find((port) => port.path === value.substring(5)) != null - case value.startsWith('ip:') && value[3] !== '/' && !value.slice(3).startsWith('file:') && value.length > 3: - return true - default: - return false - } -} - -/** Determines if a value is a serial port or IP address format. */ -export function isValidLocation(value: string, ports: readonly PortInfo[]) { - switch (true) { - case value.startsWith('port:'): - return ports.find((port) => port.path === value.substring(5)) != null - case value.startsWith('ip:'): - return isHostWithOptionalPort(value.substring(3)) - default: - return false - } -} - -// #region Host and Port - -export const hostWithOptionalPortPattern = /^((?:\[[A-Fa-f0-9.:]+\])|(?:[\p{N}\p{L}.-]+))(?::([1-9][0-9]*))?$/u - -function zodParseHostWithOptionalPort(value: string) { - const match = hostWithOptionalPortPattern.exec(value) - - if (match == null) { - return undefined - } - - if (match[1]?.startsWith('[') === true) { - match[1] = match[1].slice(1, -1) - } - - const host = hostSchema.safeParse(match[1]) - const port = z.coerce.number().positive().int().optional().safeParse(match[2]) - - return [host, port] satisfies Fixed -} - -export function isHostWithOptionalPort(value: string) { - const result = zodParseHostWithOptionalPort(value) - if (result == null) return false - - return result[0].success && result[1].success -} - -// #region Host name - -export function isHost(value: string) { - return isHostName(value) || isIpV4Address(value) || isIpV6Address(value) -} - -export const hostSchema = z.string().refine(isHost) - -// #endregion - -/** - * Is it a valid hostname? - * - * From RFC-952 (with relaxation stated in RFC-1123 2.1) - * - * ``` - * Hostname is - * = *["."] - * = [*[ = /[\p{N}\p{L}](?:[\p{N}\p{L}-]*[\p{N}\p{L}])?/gu - * /^(?:\.)*$/gu - * ``` - * - * Fully rendered in hostNamePattern, with non-capture groups - * to capturing converted for better readability. - */ -const hostNamePattern = /^[\p{N}\p{L}]([\p{N}\p{L}-]*[\p{N}\p{L}])?(\.[\p{N}\p{L}]([\p{N}\p{L}-]*[\p{N}\p{L}])?)*$/u -/** Determines whether a string is a hostname. */ -export const isHostName = (value: string) => hostNamePattern.test(value) -export const hostNameSchema = z.string().regex(hostNamePattern) - -// #region IPv4 - -/** - * Zod's IP pattern allows some invalid address strings, such as double-zero, `00`. - * These days IPv4 is generally always in decimal, not octal. It seems Zod was - * aiming for this. With this in mind, the definition is as follows. - * - * ``` - *
= 3 * ("." ) - * = /(25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9]/ # 0 - 255 - * ``` - */ -const ipV4Pattern = - /^((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9])(\.((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9])){3}$/u -/** Determines whether a string is an IPv4 address */ -export const isIpV4Address = (value: string): value is `${number}.${number}.${number}.${number}` => - ipV4Pattern.test(value) -export const ipV4AddressSchema = z.string().regex(ipV4Pattern) - -// #endregion - -// #region IPv6 - -/** - * Zod's IPv6 pattern allows a lot of invalid and misses some valid addresses. - * See {@link https://github.com/colinhacks/zod/issues/2339}. - * The RFCs seems indicate the following pattern. - * - * IPv6 - * - * ``` - * | - * - * = | - * = | - * - * = (7 * (":" )) - * = "::" # Zero address - * | ":" 7 * (":" ) - * | ":" 1-6 * (":" ) - * | 1-2 * ( ":") 1-5 * (":" ) - * | 1-3 * ( ":") 1-4 * (":" ) - * | 1-4 * ( ":") 1-3 * (":" ) - * | 1-5 * ( ":") 1-2 * (":" ) - * | 1-6 * ( ":") ":" - * | 7 * ( ":") ":" - * - * = /[0-9A-Fa-f]{1,3}/ | /[0-9A-F]{1,3}/i | /[0-9a-f]{1,3}/i - * - * = (5 * (":" )) - * - * = "::" # Zero prefix - * | ":" 5 * (":" ) ":" - * | ":" 1-4 * (":" ) ":" - * | 1-2 * ( ":") 1-3 * (":" ) ":" - * | 1-3 * ( ":") 1-2 * (":" ) ":" - * | 1-4 * ( ":") ":" ":" - * | 5 * ( ":") ":" - * - * = (3 * ("." )) - * - * = /(25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([1-9][0-9])|[0-9]/ # 0 - 255 - * ``` - */ -const ipPairPattern = /^[0-9A-Fa-f]{1,4}$/u - -function parsePossibleIpString(value: string) { - // Split on the ':', results in some empty strings with compact. - const parts = value.split(':') - - // Trim if leading empty string, leading zeros in compact. - if (parts[0] === '') { - parts.shift() - } - - // Trim if trailing empty string, trailing zeros in compact. - if (parts[parts.length - 1] === '') { - parts.pop() - } - - // Split if compact. Will only produce one or two results. - const sep = parts.indexOf('') - if (sep >= 0) { - // We add a zero to the second array to simply compact form logic. - // This is because the extra zero can stand for the at least one - // missing pair in the compact form. - return [parts.slice(0, sep), ['0', ...parts.slice(sep + 1)]] satisfies Fixed - } - - return [parts] satisfies Fixed -} - -function isValidFullIpV6(value: string[]) { - /* v8 ignore next 1 */ // Unlikely to be undefined. - const last = value.pop() ?? '' - - // IPv4 translation. - if (ipV4Pattern.test(last)) { - return value.length === 6 && value.every((p) => ipPairPattern.test(p)) - } - - // IPv6, only 7 since we pop'ped the last. - return value.length === 7 && value.every((p) => ipPairPattern.test(p)) && ipPairPattern.test(last) -} - -function isValidCompactIpV6([left, right]: [string[], string[]]) { - /* v8 ignore next 1 */ // Unlikely to be undefined. - const last = right.pop() ?? '' - - // IPv4 translation, won't test on an empty right. - if (ipV4Pattern.test(last)) { - return ( - left.length + right.length <= 6 && - left.every((p) => ipPairPattern.test(p)) && - right.every((p) => ipPairPattern.test(p)) - ) - } - - // IPv6, only 7 since we pop'ed the last. - // Empty arrays won't have anything to - // test and are valid as zero leading. - return ( - left.length + right.length <= 7 && - left.every((p) => ipPairPattern.test(p)) && - right.every((p) => ipPairPattern.test(p)) && - ipPairPattern.test(last) - ) -} - -export function isIpV6Address(value: string) { - if (value === '::') { - // Zero address short-circuit. - return true - } - - const parts = parsePossibleIpString(value) - if (parts.length === 1) { - return isValidFullIpV6(parts[0]) - } - - return isValidCompactIpV6(parts) -} - -export const ipV6AddressSchema = z.string().refine(isIpV6Address) - -// #endregion - -// #endregion diff --git a/src/core/rpc/ipc.ts b/src/core/rpc/ipc.ts deleted file mode 100644 index 0d20238..0000000 --- a/src/core/rpc/ipc.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { IpcMainInvokeEvent } from 'electron' - -export const theRpcChannel = 'trpc:msg' - -export interface CreateContextOptions { - event: IpcMainInvokeEvent -} diff --git a/src/core/rpc/transformer.ts b/src/core/rpc/transformer.ts deleted file mode 100644 index 8a096e2..0000000 --- a/src/core/rpc/transformer.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SuperJSON } from 'superjson' -import { Attachment } from '../attachments' -import { raiseError } from '../error-handling' -import { fromBase64, toBase64 } from '@/base64' - -function useSuperJsonCommon() { - const superJson = new SuperJSON() - - // HACK: tRPC and SuperJSON won't serialize functions; but - // doesn't filter them out, so the IPC throws an error - // if they are passed. They are also not passable via - // HTTP JSON serialization. This will ensure any - // types with functions won't be usable with - // routes, as functions will trigger an - // Error with this transformation. - superJson.registerCustom( - { - isApplicable: (v): v is () => undefined => typeof v === 'function', - serialize: (_: () => undefined) => raiseError(() => new TypeError('Functions may not be serialized')), - deserialize: (_: never) => raiseError(() => new TypeError('Functions may not be serialized')) - }, - 'Function' - ) - - return superJson -} - -/** - * Sets up SuperJSON for use over Electron IPC. - * - * This will attempt to translate data into a form compatible with the limited - * structuralClone ability of Electron's IPC channels. This means most - * builtin objects and a very limited set of host objects may be - * passed directly through without translation. - */ -export function useIpcJson() { - const superJson = useSuperJsonCommon() - superJson.registerCustom( - { - isApplicable: (v) => v instanceof Attachment, - serialize: (attachment) => ({ - name: attachment.name, - type: attachment.type, - data: new Uint8Array(attachment.buffer) as never - }), - deserialize: (attachment) => new Attachment(attachment.name, attachment.type, attachment.data) - }, - 'Attachment' - ) - - return superJson -} - -/** - * Sets up SuperJSON for use over HTTP. - * - * This will attempt to translate data into a form compatible with normal JSON. - * This means only data allowed in regular JSON may be passed, other types - * of data must be translated into the best possible representation. - * Binary data will need to be Base64 encoded. - */ -export function useWebJson() { - const superJson = useSuperJsonCommon() - superJson.registerCustom( - { - isApplicable: (v) => v instanceof Attachment, - serialize: (attachment) => ({ - name: attachment.name, - type: attachment.type, - data: toBase64(attachment) - }), - deserialize: (attachment) => new Attachment(attachment.name, attachment.type, fromBase64(attachment.data)) - }, - 'Attachment' - ) - - return superJson -} diff --git a/src/core/url.ts b/src/core/url.ts deleted file mode 100644 index bf1aafe..0000000 --- a/src/core/url.ts +++ /dev/null @@ -1,25 +0,0 @@ -const protocol = ['http:', 'ws:'] as const -const support = protocol.join(',') -export type Protocol = (typeof protocol)[number] - -function isSupportedProtocol(value: unknown): value is Protocol { - return protocol.includes(value as never) -} - -export type ServerSettings = [host: string, port: number, protocol: Protocol] - -export function getServerUrl(url: URL, defaultPort: number) { - if (!isSupportedProtocol(url.protocol)) throw new TypeError(`${url.protocol} is not supported; only ${support}`) - if (url.pathname.length > 1) throw new TypeError('Server must be at the root') - if (url.search.length > 0) throw new TypeError('Query parameters mean nothing') - if (url.hash.length > 0) throw new TypeError('Query parameters mean nothing') - if (url.username.length > 0) throw new TypeError('Username currently unsupported') - if (url.password.length > 0) throw new TypeError('Password currently unsupported') - if (url.hostname.length === 0) return ['127.0.0.1', defaultPort, url.protocol] satisfies ServerSettings - if (url.port.length === 0) return [url.hostname, defaultPort, url.protocol] satisfies ServerSettings - - const port = Number(url.port) - if (Number.isNaN(port)) throw new TypeError(`${url.port} is not a valid port`) - - return [url.hostname, port, url.protocol] satisfies ServerSettings -} diff --git a/src/main/boot/01-normalize-source-order.ts b/src/main/boot/01-normalize-source-order.ts deleted file mode 100644 index 737ee41..0000000 --- a/src/main/boot/01-normalize-source-order.ts +++ /dev/null @@ -1,6 +0,0 @@ -import useSourcesDatabase from '../dao/sources' - -export async function boot() { - const sourcesDb = useSourcesDatabase() - await sourcesDb.normalizeOrder() -} diff --git a/src/main/dao/devices.ts b/src/main/dao/devices.ts deleted file mode 100644 index 3b4417a..0000000 --- a/src/main/dao/devices.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import { - Database, - DocumentId, - inferDocumentOf, - inferNewDocumentOf, - inferUpdatesOf, - inferUpsertOf -} from '../services/database' -import useTiesDatabase from './ties' -import type { RevisionId } from '../services/database' - -export const DeviceModel = z.object({ - driverId: DocumentId, - title: z.string().min(1), - path: z.string().min(1) -}) - -const useDevicesDatabase = memo( - () => - new (class extends Database.of('devices', DeviceModel) { - readonly #ties = useTiesDatabase() - - override async remove(id: DocumentId, rev?: RevisionId) { - await super.remove(id, rev) - - const related = await this.#ties.forDevice(id) - await Promise.all( - related.map(async ({ _id, _rev }) => { - await this.#ties.remove(_id, _rev) - }) - ) - } - })() -) - -export type Device = inferDocumentOf -export const Device = inferDocumentOf(DeviceModel) -export type NewDevice = inferNewDocumentOf -export const NewDevice = inferNewDocumentOf(DeviceModel) -export type DeviceUpdate = inferUpdatesOf -export const DeviceUpdate = inferUpdatesOf(DeviceModel) -export type DeviceUpsert = inferUpsertOf -export const DeviceUpsert = inferUpsertOf(DeviceModel) - -export default useDevicesDatabase diff --git a/src/main/dao/sources.ts b/src/main/dao/sources.ts deleted file mode 100644 index f9b62c7..0000000 --- a/src/main/dao/sources.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import { Database, inferDocumentOf, inferNewDocumentOf, inferUpdatesOf, inferUpsertOf } from '../services/database' -import useTiesDatabase from './ties' -import type { DocumentId, RevisionId } from '../services/database' - -export const SourceModel = z.object({ - order: z.number().nonnegative().finite(), - title: z.string().min(1), - image: z.string().min(1).nullable() -}) - -const useSourcesDatabase = memo( - () => - new (class extends Database.of('sources', SourceModel) { - readonly #ties = useTiesDatabase() - - async getNextOrderValue() { - return await this.run(async function getNextOrderValue(db) { - const mapped = await db.query({ - /* v8 ignore next 2 */ // Not executed in a way V8 can see. - map: (doc, emit) => emit?.(doc.order, doc.order), - reduce: (_, values) => 1 + Math.max(...values.map(Number)) - }) - - const row = mapped.rows[0] - if (row == null) return 0 - - return row.value as number - }) - } - - async normalizeOrder() { - const sources = await this.all() - let i = 0 - for (const source of sources.toSorted((a, b) => a.order - b.order)) { - source.order = i - i += 1 - } - - await Promise.all( - sources.map(async (source) => { - await this.update(source) - }) - ) - } - - override async remove(id: DocumentId, rev?: RevisionId) { - await super.remove(id, rev) - - const related = await this.#ties.forSource(id) - await Promise.all( - related.map(async ({ _id, _rev }) => { - await this.#ties.remove(_id, _rev) - }) - ) - } - })() -) - -export type Source = inferDocumentOf -export const Source = inferDocumentOf(SourceModel) -export type NewSource = inferNewDocumentOf -export const NewSource = inferNewDocumentOf(SourceModel) -export type SourceUpdate = inferUpdatesOf -export const SourceUpdate = inferUpdatesOf(SourceModel) -export type SourceUpsert = inferUpsertOf -export const SourceUpsert = inferUpsertOf(SourceModel) - -export default useSourcesDatabase diff --git a/src/main/dao/storage.ts b/src/main/dao/storage.ts deleted file mode 100644 index 5d76638..0000000 --- a/src/main/dao/storage.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { memo } from 'radash' -import { useLevelDb } from '../services/level' - -const useUserStore = memo(function useUserStore() { - const { levelup } = useLevelDb() - - const booted = levelup('_userStorage') - - function defineOperation( - op: (db: Awaited, ...args: Args) => Promise - ) { - return async (...args: Args) => await op(await booted, ...args) - } - - const getItem = defineOperation(async function getItem(db, key: string) { - try { - return (await db.get(key, { asBuffer: false })) as string - } catch { - return null - } - }) - - const setItem = defineOperation(async function setItem(db, key: string, value: string) { - await db.put(key, value) - }) - - const removeItem = defineOperation(async function removeItem(db, key: string) { - await db.del(key) - }) - - const clear = defineOperation(async function clear(db) { - await db.clear() - }) - - return { - getItem, - setItem, - removeItem, - clear - } -}) - -export default useUserStore diff --git a/src/main/dao/ties.ts b/src/main/dao/ties.ts deleted file mode 100644 index 2859dbe..0000000 --- a/src/main/dao/ties.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { map, memo } from 'radash' -import { z } from 'zod' -import { - Database, - DocumentId, - inferDocumentOf, - inferNewDocumentOf, - inferUpdatesOf, - inferUpsertOf -} from '../services/database' - -export const TieModel = z.object({ - sourceId: DocumentId, - deviceId: DocumentId, - inputChannel: z.number().int().nonnegative(), - outputChannels: z.object({ - video: z.number().int().nonnegative().optional(), - audio: z.number().int().nonnegative().optional() - }) -}) - -const indices = { sourceId: ['sourceId'], deviceId: ['deviceId'] } - -const useTiesDatabase = memo( - () => - new (class extends Database.of('ties', TieModel, indices) { - async forDevice(deviceId: DocumentId) { - return await this.run( - async (db) => - await db - .find({ selector: { deviceId } }) - .then(async (r) => await map(r.docs, async (d) => await this.prepare(d))) - ) - } - - async forSource(sourceId: DocumentId) { - return await this.run( - async (db) => - await db - .find({ selector: { sourceId } }) - .then(async (r) => await map(r.docs, async (d) => await this.prepare(d))) - ) - } - })() -) - -export type Tie = inferDocumentOf -export const Tie = inferDocumentOf(TieModel) -export type NewTie = inferNewDocumentOf -export const NewTie = inferNewDocumentOf(TieModel) -export type TieUpdate = inferUpdatesOf -export const TieUpdate = inferUpdatesOf(TieModel) -export type TieUpsert = inferUpsertOf -export const TieUpsert = inferUpsertOf(TieModel) - -export default useTiesDatabase diff --git a/src/main/drivers/extron/sis.ts b/src/main/drivers/extron/sis.ts deleted file mode 100644 index 6a6c807..0000000 --- a/src/main/drivers/extron/sis.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceCanDecoupleAudioOutput, kDeviceSupportsMultipleOutputs } from '../../services/drivers' -import { useExtronSisProtocol } from '../../services/protocols/extronSis' - -const extronSisDriver = defineDriver({ - enabled: true, - experimental: false, - kind: 'switch', - guid: '4C8F2838-C91D-431E-84DD-3666D14A6E2C', - localized: { - en: { - title: 'Extron SIS-compatible matrix switch', - company: 'Extron Electronics', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput, - setup: useExtronSisProtocol -}) - -export default extronSisDriver diff --git a/src/main/drivers/shinybow/v2.ts b/src/main/drivers/shinybow/v2.ts deleted file mode 100644 index c5de8c3..0000000 --- a/src/main/drivers/shinybow/v2.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' -import { useShinybowV2Protocol } from '../../services/protocols/shinybow' - -const shinybowV2 = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C', - localized: { - en: { - title: 'Shinybow v2.0 compatible matrix switch', - company: 'ShinybowUSA', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs, - setup: useShinybowV2Protocol -}) - -export default shinybowV2 diff --git a/src/main/drivers/shinybow/v3.ts b/src/main/drivers/shinybow/v3.ts deleted file mode 100644 index e2c1b8d..0000000 --- a/src/main/drivers/shinybow/v3.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' -import { useShinybowV3Protocol } from '../../services/protocols/shinybow' - -const shinybowV3 = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: 'BBED08A1-C749-4733-8F2E-96C9B56C0C41', - localized: { - en: { - title: 'Shinybow v3.0 compatible matrix switch', - company: 'ShinybowUSA', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs, - setup: useShinybowV3Protocol -}) - -export default shinybowV3 diff --git a/src/main/drivers/sony/rs485.ts b/src/main/drivers/sony/rs485.ts deleted file mode 100644 index 9661623..0000000 --- a/src/main/drivers/sony/rs485.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' -import { useSonyBvmProtocol } from '../../services/protocols/sonyBvm' - -const sonyRs485Driver = defineDriver({ - enabled: true, - experimental: false, - kind: 'monitor', - guid: '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0', - localized: { - en: { - title: 'Sony RS-485 controllable monitor', - company: 'Sony Corporation', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities, - setup: useSonyBvmProtocol -}) - -export default sonyRs485Driver diff --git a/src/main/drivers/tesla-smart/kvm.ts b/src/main/drivers/tesla-smart/kvm.ts deleted file mode 100644 index 4d87926..0000000 --- a/src/main/drivers/tesla-smart/kvm.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' -import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' - -const teslaSmartKvmDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61', - localized: { - en: { - title: 'Tesla smart KVM-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities, - setup: useTeslaElecKvmProtocol -}) - -export default teslaSmartKvmDriver diff --git a/src/main/drivers/tesla-smart/matrix.ts b/src/main/drivers/tesla-smart/matrix.ts deleted file mode 100644 index ce03461..0000000 --- a/src/main/drivers/tesla-smart/matrix.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' -import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' - -const teslaSmartMatrixDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '671824ED-0BC4-43A6-85CC-4877890A7722', - localized: { - en: { - title: 'Tesla smart matrix-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs, - setup: useTeslaElecMatrixProtocol -}) - -export default teslaSmartMatrixDriver diff --git a/src/main/drivers/tesla-smart/sdi.ts b/src/main/drivers/tesla-smart/sdi.ts deleted file mode 100644 index 7e54084..0000000 --- a/src/main/drivers/tesla-smart/sdi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' -import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' - -const teslaSmartSdiDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD', - localized: { - en: { - title: 'Tesla smart SDI-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities, - setup: useTeslaElecSdiProtocol -}) - -export default teslaSmartSdiDriver diff --git a/src/main/drivers/tesmart/kvm.ts b/src/main/drivers/tesmart/kvm.ts deleted file mode 100644 index 56a4e93..0000000 --- a/src/main/drivers/tesmart/kvm.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' -import { useTeslaElecKvmProtocol } from '../../services/protocols/teslaElec' - -const tesmartKvmDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '2B4EDB8E-D2D6-4809-BA18-D5B1785DA028', - localized: { - en: { - title: 'TESmart KVM-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities, - setup: useTeslaElecKvmProtocol -}) - -export default tesmartKvmDriver diff --git a/src/main/drivers/tesmart/matrix.ts b/src/main/drivers/tesmart/matrix.ts deleted file mode 100644 index 76b85c6..0000000 --- a/src/main/drivers/tesmart/matrix.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceSupportsMultipleOutputs } from '../../services/drivers' -import { useTeslaElecMatrixProtocol } from '../../services/protocols/teslaElec' - -const tesmartMatrixDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '01B8884C-1D7D-4451-883D-3C8F18E17B14', - localized: { - en: { - title: 'TESmart matrix-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs, - setup: useTeslaElecMatrixProtocol -}) - -export default tesmartMatrixDriver diff --git a/src/main/drivers/tesmart/sdi.ts b/src/main/drivers/tesmart/sdi.ts deleted file mode 100644 index 696d6cc..0000000 --- a/src/main/drivers/tesmart/sdi.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defineDriver, kDeviceHasNoExtraCapabilities } from '../../services/drivers' -import { useTeslaElecSdiProtocol } from '../../services/protocols/teslaElec' - -const tesmartSdiDriver = defineDriver({ - enabled: true, - experimental: true, - kind: 'switch', - guid: '8C524E65-83EF-4AEF-B0DA-29C4582AA4A0', - localized: { - en: { - title: 'TESmart SDI-compatible switch', - company: 'Tesla Elec Technology Co.,Ltd', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceHasNoExtraCapabilities, - setup: useTeslaElecSdiProtocol -}) - -export default tesmartSdiDriver diff --git a/src/main/index.ts b/src/main/index.ts deleted file mode 100644 index 28ff8f0..0000000 --- a/src/main/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { app } from 'electron' -import unhandled from 'electron-unhandled' - -// Activate the unhandled handler... -unhandled() - -async function main() { - // Now boot the application. - await import('./main') -} - -main().catch((cause: unknown) => { - console.error(cause) - app.exit(1) -}) diff --git a/src/main/main.ts b/src/main/main.ts deleted file mode 100644 index 82ed647..0000000 --- a/src/main/main.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { join as joinPath, resolve as resolvePath } from 'node:path' -import process from 'node:process' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import { app, shell, BrowserWindow, nativeTheme } from 'electron' -import Logger from 'electron-log' -import { sleep } from 'radash' -import appIcon from '../../resources/icon.png?asset&asarUnpack' -import { useAppRouter } from './routes/router' -import useBootOperations from './services/boot' -import useMigrations from './services/migration' -import { createIpcHandler } from './services/rpc/ipc' -import { logError } from './utilities' -import { toError } from '@/error-handling' - -// In this file you can include the rest of your app"s specific main process -// code. You can also put them in separate files and require them here. - -Logger.transports.console.format = '{h}:{i}:{s}.{ms} [{level}] › {text}' -Logger.transports.file.level = 'debug' -Logger.errorHandler.startCatching() - -async function createWindow() { - const willStartWithDark = nativeTheme.shouldUseDarkColors || nativeTheme.shouldUseInvertedColorScheme - - const window = new BrowserWindow({ - width: 800, - height: 480, - backgroundColor: willStartWithDark ? '#121212' : 'white', - icon: appIcon, - show: true, - useContentSize: true, - webPreferences: { - preload: joinPath(__dirname, '../preload/index.mjs'), - sandbox: false - } - }) - - window.removeMenu() - if (import.meta.env.PROD) { - window.setFullScreen(true) - } else { - window.webContents.openDevTools({ mode: 'undocked' }) - } - - // Open all new window links in the system browser. - window.webContents.setWindowOpenHandler(function windowOpenHandler(details) { - shell.openExternal(details.url).catch((e: unknown) => { - Logger.error(e) - }) - - return { action: 'deny' } - }) - - const kWait = 2000 - let lastError: unknown - - window.webContents.on('console-message', (_, level, message) => { - switch (level) { - case 0: - Logger.verbose(message) - break - case 1: - Logger.info(message) - break - case 2: - Logger.warn(message) - break - case 3: - Logger.error(message) - break - default: - Logger.log(message) - break - } - }) - - /* eslint-disable no-await-in-loop -- Retry loop must be serial. */ - for (let tries = 3; tries > 0; --tries) { - try { - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env.ELECTRON_RENDERER_URL != null) { - await window.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - await window.loadFile(joinPath(__dirname, '../renderer/index.html')) - } - - return window - } catch (e) { - lastError = e - Logger.warn(e) - - await sleep(kWait) - } - } - - /* eslint-enable no-await-in-loop */ - throw logError(toError(lastError)) -} - -// Let's change the web session path. -const configDir = app.getPath('userData') -app.setPath('sessionData', resolvePath(configDir, '.websession')) - -// Quit when all windows are closed. -app.on('window-all-closed', () => { - app.quit() -}) - -// Default open or close DevTools by F12 in development -// and ignore CommandOrControl + R in production. -// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils -app.on('browser-window-created', (...[, window]) => { - optimizer.watchWindowShortcuts(window) -}) - -// Attempt a clean shutdown if SIGTERM is received. -process.on('SIGTERM', () => { - for (const window of BrowserWindow.getAllWindows()) { - window.close() - } -}) - -// This method will be called when Electron has finished -// initialization and is ready to create browser -// windows. Some APIs can only be used after -// this event occurs. -await app.whenReady() - -const migrate = useMigrations() -await migrate().catch((cause: unknown) => { - Logger.error(cause) -}) - -const boot = useBootOperations() -await boot() - -// Set app user model id for windows -electronApp.setAppUserModelId('org.sleepingcats.BridgeCmdr') - -// If macOS close vs quit behavior is reimplemented, we will have -// to make sure port and handler are accessible earlier. -const handler = createIpcHandler({ router: useAppRouter() }) -handler.attachWindow(await createWindow()) diff --git a/src/main/migrations/20241118211400-rename-switches.ts b/src/main/migrations/20241118211400-rename-switches.ts deleted file mode 100644 index a76860e..0000000 --- a/src/main/migrations/20241118211400-rename-switches.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod' -import { Database, DocumentId } from '../services/database' - -export async function migrate() { - const Model = z.object({ - driverId: DocumentId, - title: z.string().min(1), - path: z.string().min(1) - }) - - const switches = new Database('switches', Model) - const devices = new Database('devices', Model) - - for (const device of await switches.all()) { - // eslint-disable-next-line no-await-in-loop - await devices.upsert(device) - } - - await switches.destroy() - await devices.close() -} diff --git a/src/main/migrations/20241119202100-rename-switchId.ts b/src/main/migrations/20241119202100-rename-switchId.ts deleted file mode 100644 index 7bf0831..0000000 --- a/src/main/migrations/20241119202100-rename-switchId.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod' -import { Database, DocumentId } from '../services/database' - -export async function migrate() { - const Model = z.object({}).catchall(z.unknown()) - - const OldModel = z - .object({ - _id: DocumentId, - sourceId: DocumentId, - switchId: DocumentId, - inputChannel: z.number().int().nonnegative(), - outputChannels: z.object({ - video: z.number().int().nonnegative().optional(), - audio: z.number().int().nonnegative().optional() - }) - }) - .passthrough() - - const ties = new Database('ties', Model) - - for (const tie of await ties.all()) { - const { switchId, ...old } = OldModel.parse(tie) - - // eslint-disable-next-line no-await-in-loop - await ties.replace({ ...old, deviceId: switchId }) - } -} diff --git a/src/main/migrations/20241124121000-add-source-order.ts b/src/main/migrations/20241124121000-add-source-order.ts deleted file mode 100644 index bc7f5de..0000000 --- a/src/main/migrations/20241124121000-add-source-order.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from 'zod' -import { Database } from '../services/database' - -export async function migrate() { - const OldModel = z.object({ - order: z.number().nonnegative().finite().optional(), - title: z.string().min(1), - image: z.string().min(1).nullable() - }) - - const sourcesDb = new Database('sources', OldModel) - const sources = await sourcesDb.all() - await Promise.all( - sources.map(async (source, order) => { - await sourcesDb.update({ order, ...source }) - }) - ) - - await sourcesDb.close() -} diff --git a/src/main/routes/data/devices.ts b/src/main/routes/data/devices.ts deleted file mode 100644 index 62bde72..0000000 --- a/src/main/routes/data/devices.ts +++ /dev/null @@ -1,23 +0,0 @@ -import useDevicesDatabase, { NewDevice, DeviceUpdate, DeviceUpsert } from '../../dao/devices' -import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/rpc/trpc' - -export default function useDevicesRouter() { - const devices = useDevicesDatabase() - return router({ - compact: procedure.mutation(async () => { - await devices.compact() - }), - all: procedure.query(async () => await devices.all()), - get: procedure.input(DocumentId).query(async ({ input }) => await devices.get(input)), - add: procedure.input(NewDevice).mutation(async ({ input }) => await devices.add(input)), - update: procedure.input(DeviceUpdate).mutation(async ({ input }) => await devices.update(input)), - upsert: procedure.input(DeviceUpsert).mutation(async ({ input }) => await devices.upsert(input)), - remove: procedure.input(DocumentId).mutation(async ({ input }) => { - await devices.remove(input) - }), - clear: procedure.mutation(async () => { - await devices.clear() - }) - }) -} diff --git a/src/main/routes/data/sources.ts b/src/main/routes/data/sources.ts deleted file mode 100644 index c775020..0000000 --- a/src/main/routes/data/sources.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod' -import useSourcesDatabase, { NewSource, SourceUpdate, SourceUpsert } from '../../dao/sources' -import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/rpc/trpc' -import { Attachment } from '@/attachments' - -const InsertInputs = z.tuple([NewSource]).rest(z.instanceof(Attachment)) -const UpdateInputs = z.tuple([SourceUpdate]).rest(z.instanceof(Attachment)) -const UpsertInputs = z.tuple([SourceUpsert]).rest(z.instanceof(Attachment)) - -export default function useSourcesRouter() { - const sources = useSourcesDatabase() - return router({ - compact: procedure.mutation(async () => { - await sources.compact() - }), - all: procedure.query(async () => await sources.all()), - get: procedure.input(DocumentId).query(async ({ input }) => await sources.get(input)), - add: procedure.input(InsertInputs).mutation(async ({ input }) => await sources.add(...input)), - update: procedure.input(UpdateInputs).mutation(async ({ input }) => await sources.update(...input)), - upsert: procedure.input(UpsertInputs).mutation(async ({ input }) => await sources.upsert(...input)), - remove: procedure.input(DocumentId).mutation(async ({ input }) => { - await sources.remove(input) - }), - clear: procedure.mutation(async () => { - await sources.clear() - }), - // Utilities - getNextOrderValue: procedure.query(async () => await sources.getNextOrderValue()), - normalizeOrder: procedure.mutation(async () => { - await sources.normalizeOrder() - }) - }) -} diff --git a/src/main/routes/data/storage.ts b/src/main/routes/data/storage.ts deleted file mode 100644 index a91bc0c..0000000 --- a/src/main/routes/data/storage.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import useUserStore from '../../dao/storage' -import { procedure, router } from '../../services/rpc/trpc' - -const useUserStoreRouter = memo(function useUserStoreRouter() { - const storage = useUserStore() - - const SetItemInputs = z.tuple([z.string(), z.string()]) - - return router({ - getItem: procedure.input(z.string()).query(async ({ input }) => await storage.getItem(input)), - setItem: procedure.input(SetItemInputs).mutation(async ({ input }) => { - await storage.setItem(...input) - }), - removeItem: procedure.input(z.string()).mutation(async ({ input }) => { - await storage.removeItem(input) - }), - clear: procedure.mutation(async () => { - await storage.clear() - }) - }) -}) - -export default useUserStoreRouter diff --git a/src/main/routes/data/ties.ts b/src/main/routes/data/ties.ts deleted file mode 100644 index ef069b7..0000000 --- a/src/main/routes/data/ties.ts +++ /dev/null @@ -1,25 +0,0 @@ -import useTiesDatabase, { NewTie, TieUpdate, TieUpsert } from '../../dao/ties' -import { DocumentId } from '../../services/database' -import { procedure, router } from '../../services/rpc/trpc' - -export default function useTiesRouter() { - const ties = useTiesDatabase() - return router({ - compact: procedure.mutation(async () => { - await ties.compact() - }), - all: procedure.query(async () => await ties.all()), - get: procedure.input(DocumentId).query(async ({ input }) => await ties.get(input)), - add: procedure.input(NewTie).mutation(async ({ input }) => await ties.add(input)), - update: procedure.input(TieUpdate).mutation(async ({ input }) => await ties.update(input)), - upsert: procedure.input(TieUpsert).mutation(async ({ input }) => await ties.upsert(input)), - remove: procedure.input(DocumentId).mutation(async ({ input }) => { - await ties.remove(input) - }), - clear: procedure.mutation(async () => { - await ties.clear() - }), - forDevice: procedure.input(DocumentId).query(async ({ input }) => await ties.forDevice(input)), - forSource: procedure.input(DocumentId).query(async ({ input }) => await ties.forSource(input)) - }) -} diff --git a/src/main/routes/drivers.ts b/src/main/routes/drivers.ts deleted file mode 100644 index 58f781e..0000000 --- a/src/main/routes/drivers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import useDrivers from '../services/drivers' -import { procedure, router } from '../services/rpc/trpc' - -const Channel = z.number().int().nonnegative().finite() - -const ActivateInputs = z.tuple([z.string().uuid(), z.string().url(), Channel, Channel, Channel]) -const PowerInputs = z.tuple([z.string().uuid(), z.string().url()]) - -const useDriversRouter = memo(function useDriversRoute() { - const drivers = useDrivers() - - return router({ - all: procedure.query(async () => await drivers.allInfo()), - get: procedure.input(z.string().uuid()).query(async ({ input }) => { - await drivers.get(input) - }), - activate: procedure.input(ActivateInputs).mutation(async ({ input }) => { - await drivers.activate(...input) - }), - powerOn: procedure.input(PowerInputs).mutation(async ({ input }) => { - await drivers.powerOn(...input) - }), - powerOff: procedure.input(PowerInputs).mutation(async ({ input }) => { - await drivers.powerOff(...input) - }) - }) -}) - -export default useDriversRouter diff --git a/src/main/routes/migration.ts b/src/main/routes/migration.ts deleted file mode 100644 index cbb0553..0000000 --- a/src/main/routes/migration.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { memo } from 'radash' -import useMigrations from '../services/migration' -import { procedure, router } from '../services/rpc/trpc' - -const useMigrationRouter = memo(function useMigrationRouter() { - const migrate = useMigrations() - - return router({ - migrate: procedure.mutation(async () => { - await migrate() - }) - }) -}) - -export default useMigrationRouter diff --git a/src/main/routes/ports.ts b/src/main/routes/ports.ts deleted file mode 100644 index a37c4c5..0000000 --- a/src/main/routes/ports.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import useSerialPorts from '../services/ports' -import { procedure, router } from '../services/rpc/trpc' - -const useSerialPortRouter = memo(function useSerialPortRouter() { - const ports = useSerialPorts() - - return router({ - list: procedure.query(async () => await ports.listPorts()), - isPort: procedure.input(z.string()).query(async ({ input }) => await ports.isValidPort(input)) - }) -}) - -export default useSerialPortRouter diff --git a/src/main/routes/router.ts b/src/main/routes/router.ts deleted file mode 100644 index 4493b02..0000000 --- a/src/main/routes/router.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { memo } from 'radash' -import useAppInfo from '../services/app' -import { procedure, router } from '../services/rpc/trpc' -import useUserInfo from '../services/user' -import useDevicesRouter from './data/devices' -import useSourcesRouter from './data/sources' -import useUserStoreRouter from './data/storage' -import useTiesRouter from './data/ties' -import useDriversRouter from './drivers' -import useMigrationRouter from './migration' -import useSerialPortRouter from './ports' -import useStartupRouter from './startup' -import useSystemRouter from './system' -import useUpdaterRouter from './updater' - -export const useAppRouter = memo(() => - router({ - // Informational routes - appInfo: procedure.query(useAppInfo), - userInfo: procedure.query(useUserInfo), - // Functional service routes - ports: useSerialPortRouter(), - startup: useStartupRouter(), - system: useSystemRouter(), - drivers: useDriversRouter(), - // Data service routes - migration: useMigrationRouter(), - storage: useUserStoreRouter(), - ties: useTiesRouter(), - devices: useDevicesRouter(), - sources: useSourcesRouter(), - updates: useUpdaterRouter() - }) -) - -export type AppRouter = ReturnType diff --git a/src/main/routes/startup.ts b/src/main/routes/startup.ts deleted file mode 100644 index 073bb7e..0000000 --- a/src/main/routes/startup.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { memo } from 'radash' -import { procedure, router } from '../services/rpc/trpc' -import useStartup from '../services/startup' - -const useStartupRouter = memo(function useStartupRouter() { - const startup = useStartup() - return router({ - checkEnabled: procedure.query(async () => await startup.checkEnabled()), - checkUp: procedure.mutation(async () => { - await startup.checkUp() - }), - enable: procedure.mutation(async () => { - await startup.enable() - }), - disable: procedure.mutation(async () => { - await startup.disable() - }) - }) -}) - -export default useStartupRouter diff --git a/src/main/routes/system.ts b/src/main/routes/system.ts deleted file mode 100644 index fd8b41f..0000000 --- a/src/main/routes/system.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' -import { procedure, router } from '../services/rpc/trpc' -import useSystem from '../services/system' - -const useSystemRouter = memo(function useSystemRouter() { - const system = useSystem() - - return router({ - powerOff: procedure.input(z.boolean().optional()).mutation(async ({ input }) => { - await system.powerOff(input) - }) - }) -}) - -export default useSystemRouter diff --git a/src/main/routes/updater.ts b/src/main/routes/updater.ts deleted file mode 100644 index 1b8ea29..0000000 --- a/src/main/routes/updater.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { observable } from '@trpc/server/observable' -import { memo } from 'radash' -import { procedure, router } from '../services/rpc/trpc' -import useUpdater from '../services/updater' -import type { AppUpdaterEventMap } from '../services/updater' - -const useUpdaterRouter = memo(function useUpdaterRouter() { - const updater = useUpdater() - - function defineEvent(name: Name) { - type Args = AppUpdaterEventMap[Name] - // TODO: tRPC 11, use "for await...of on" with AbortSignal - return () => - observable((emit) => { - const proxy = (...args: Args) => { - emit.next(args) - } - - updater.on(name, proxy as never) - - return () => { - updater.off(name, proxy as never) - } - }) - } - - return router({ - onChecking: procedure.subscription(defineEvent('checking')), - onAvailable: procedure.subscription(defineEvent('available')), - onProgress: procedure.subscription(defineEvent('progress')), - onDownloaded: procedure.subscription(defineEvent('downloaded')), - onCancelled: procedure.subscription(defineEvent('cancelled')), - checkForUpdates: procedure.query(async () => await updater.checkForUpdates()), - downloadUpdate: procedure.query(async () => { - await updater.downloadUpdate() - }), - cancelUpdate: procedure.query(async () => { - await updater.cancelUpdate() - }), - installUpdate: procedure.query(async () => { - await updater.installUpdate() - }) - }) -}) - -export default useUpdaterRouter diff --git a/src/main/services/app.ts b/src/main/services/app.ts deleted file mode 100644 index 10243ba..0000000 --- a/src/main/services/app.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { app } from 'electron' -import { memo } from 'radash' - -/** Basic application information. */ -export type AppInfo = ReturnType - -const useAppInfo = memo(() => ({ - name: app.getName(), - version: app.getVersion() as `${number}.${number}.${number}` -})) - -export default useAppInfo diff --git a/src/main/services/boot.ts b/src/main/services/boot.ts deleted file mode 100644 index 908e5ee..0000000 --- a/src/main/services/boot.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { basename } from 'node:path' -import Logger from 'electron-log' -import { z } from 'zod' - -const BootModule = z.object({ - boot: z.function(z.tuple([]), z.unknown()) -}) - -export default function useBootOperations() { - Logger.debug('Loading boot modules') - const bootModules = import.meta.glob('../boot/**/*', { eager: true }) - return async function boot() { - Logger.debug('Starting boot up') - for (const [name, module] of Object.entries(bootModules)) { - const bootable = BootModule.parse(module) - Logger.debug(`Attempting boot: ${basename(name, '.ts')}`) - // eslint-disable-next-line no-await-in-loop -- Design to allow serialization. - await bootable.boot() - } - } -} diff --git a/src/main/services/database.ts b/src/main/services/database.ts deleted file mode 100644 index b30e7fb..0000000 --- a/src/main/services/database.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { randomUUID } from 'node:crypto' -import is from '@sindresorhus/is' -import PouchDb from 'pouchdb-core' -import find from 'pouchdb-find' -import mapReduce from 'pouchdb-mapreduce' -import { map } from 'radash' -import { z } from 'zod' -import { useLevelAdapter } from './level' -import type { Simplify } from 'type-fest' -import { Attachment } from '@/attachments' -import { isNotNullish } from '@/basics' - -type IndexFields = string[] -type IndexList = IndexFields[] -type NamedIndices = Record - -export type Indices = IndexList | NamedIndices - -export type DocumentId = z.output -export const DocumentId = z - .string() - .uuid() - .transform((value) => value.toUpperCase()) -export type RevisionId = z.output -export const RevisionId = z.string().min(1) - -function isFullAttachment(value: PouchDB.Core.Attachment): value is PouchDB.Core.FullAttachment { - return 'data' in value -} - -async function translateAttachment(key: string, attachment: PouchDB.Core.FullAttachment) { - return await Attachment.fromPouchAttachment(key, attachment) -} - -async function prepareAttachment(key: string, attachment: PouchDB.Core.Attachment) { - return isFullAttachment(attachment) - ? /* v8 ignore next 2 */ // `false` is actually unlikely due to design. - await translateAttachment(key, attachment) - : null -} - -async function prepareAttachments(attachments: Record | null | undefined) { - if (attachments == null) return [] - return ( - await Promise.all( - Object.entries(attachments).map(async ([key, attachment]) => await prepareAttachment(key, attachment)) - ) - ).filter(isNotNullish) -} - -export type inferDocumentOf = Schema extends z.AnyZodObject - ? Simplify>>> - : never -export function inferDocumentOf(schema: Schema) { - return schema.and( - z.object({ - _id: DocumentId, - _rev: RevisionId, - _attachments: z.array(z.instanceof(Attachment)) - }) - ) -} - -export type inferNewDocumentOf = Schema extends z.AnyZodObject - ? Simplify>>> - : never -export function inferNewDocumentOf(schema: Schema) { - return schema -} - -export type inferUpdatesOf = Schema extends z.AnyZodObject - ? Simplify>>> - : never -export function inferUpdatesOf(schema: Schema) { - type Shape = Schema['shape'] - // HACK: Partial looses the shape in this generic context. - const partial = schema.partial() as z.ZodObject<{ [K in keyof Shape]: z.ZodOptional }, 'strip'> - return partial.and( - z.object({ - _id: DocumentId, - _attachments: z.array(z.instanceof(Attachment)).optional() - }) - ) -} - -export type inferUpsertOf = Schema extends z.AnyZodObject - ? Simplify>>> - : never -export function inferUpsertOf(schema: Schema) { - return schema.and( - z.object({ - _id: DocumentId, - _attachments: z.array(z.instanceof(Attachment)).optional() - }) - ) -} - -PouchDb.plugin(useLevelAdapter()) -PouchDb.plugin(find) -PouchDb.plugin(mapReduce) - -/** The basis of a database. */ -export class Database { - /** The Zod schema for the raw document data, same as the new document payload. */ - readonly #schema - /** A promise that tracks if the database has booted. */ - readonly #booted - - /** The raw document data, same as the new document payload. */ - declare readonly __raw__: z.infer - /** The database document that is retrieved from the database. */ - declare readonly __document__: inferDocumentOf - /** The possible document updates. */ - declare readonly __updates__: inferUpdatesOf - /** The raw document is a predefined ID. */ - declare readonly __upsert__: inferUpsertOf - - /** - * Initializes a new instance of the Database class. - * @param name - The name of the database. - * @param schema - The raw document schema. - * @param indicesBlocks - The index definition blocks. - */ - constructor(name: string, schema: RawSchema, ...indicesBlocks: Indices[]) { - this.#schema = schema - this.#booted = this.boot(name, indicesBlocks) - } - - /** - * Defines the basis database of a specific schema. - * @param name - The name of the database. - * @param schema - The raw document schema. - * @param indicesBlocks - The index definition blocks. - * @returns A class that defines the database for the name and schema. - */ - static of(name: string, schema: RawSchema, ...indicesBlocks: Indices[]) { - return class extends Database { - constructor() { - super(name, schema, ...indicesBlocks) - } - } - } - - /** Boots the database with the given name and indices. */ - private async boot(name: string, indicesBlocks: Indices[]) { - const db = new PouchDb(name) - const namedIndices = new Map() - const basicIndices: IndexFields[] = [] - - // Record the indices - for (const indices of indicesBlocks) { - if (Array.isArray(indices)) { - basicIndices.push(...indices) - } else { - for (const [key, value] of Object.entries(indices)) { - namedIndices.set(key, value) - } - } - } - - for (const fields of basicIndices) { - // eslint-disable-next-line no-await-in-loop -- Should be serialized. - await db.createIndex({ index: { fields } }) - } - - for (const [index, fields] of namedIndices) { - // eslint-disable-next-line no-await-in-loop -- Should be serialized. - await db.createIndex({ index: { fields, name: index } }) - } - - return db - } - - /** Run an operation with a booted database. */ - protected async run(op: (db: PouchDB.Database) => Promise) { - return await this.#booted.then(op) - } - - /** Gets all documents, in raw form, from the database. */ - protected async allDocs() { - return await this.run(async (db) => { - const response = await db.allDocs({ - include_docs: true, - attachments: true, - binary: true, - // Since we use GUIDs, the first character will be between these values. - startkey: '0', - endkey: 'Z' - }) - - return response.rows.map((row) => row.doc).filter(isNotNullish) - }) - } - - /** Gets the specified document, in raw form, from the database. */ - protected async getDoc(id: DocumentId) { - return await this.run(async (db) => await db.get(id.toUpperCase(), { attachments: true, binary: true })) - } - - /** Adds attachments to a document. */ - protected async addAttachments(id: DocumentId, rev: RevisionId, attachments: Attachment[]) { - await this.run(async (db) => { - let revId = rev - for (const attachment of attachments) { - // eslint-disable-next-line no-await-in-loop -- Must be serialized. - const response = await db.putAttachment(id, attachment.name, revId, Buffer.from(attachment), attachment.type) - /* v8 ignore next 1 */ // Likely trigger by database corruption. - if (!response.ok) throw new Error(`Failed to add attachment "${attachment.name}"`) - revId = response.rev - } - }) - } - - /** Prepares the document. */ - protected async prepare(doc: T) { - const { _attachments, _conflicts, _revs_info, _revisions, ...document } = doc - const result = { ...document, _attachments: await prepareAttachments(_attachments as never) } - return result as Simplify - } - - /** Compacts the database. */ - async compact() { - await this.run(async (db) => { - await db.compact() - }) - } - - /** Gets all document from the database */ - async all() { - return await this.run(async () => await map(await this.allDocs(), async (d) => await this.prepare(d))) - } - - /** Gets the specified document from the database. */ - async get(id: DocumentId) { - return await this.getDoc(id).then(async (d) => await this.prepare(d)) - } - - /** Adds a document to the database. */ - async add(document: Simplify, ...attachments: Attachment[]) { - return await this.run(async (db) => { - const doc = { ...this.#schema.parse(document), _id: randomUUID().toUpperCase() } - const result = await db.put(doc) - /* v8 ignore next 1 */ // Likely trigger by database corruption. - if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) - if (attachments.length > 0) { - await this.addAttachments(result.id, result.rev, attachments) - } - - return await this.get(result.id) - }) - } - - /** Updates an existing document, or inserts a new one, with the given ID. */ - async upsert(document: Simplify, ...attachments: Attachment[]) { - return await this.run(async (db) => { - const id = document._id.toUpperCase() - const old = await this.getDoc(id).catch(() => ({ _rev: undefined, _attachments: undefined })) - const doc = old._rev - ? { ...this.#schema.parse(document), _id: id, _rev: old._rev } - : { ...this.#schema.parse(document), _id: id } - - const result = await db.put(doc) - /* v8 ignore next 1 */ // Likely trigger by database corruption. - if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) - if (attachments.length > 0) { - await this.addAttachments(result.id, result.rev, attachments) - } else if (document._attachments != null && document._attachments.length > 0) { - await this.addAttachments(result.id, result.rev, document._attachments) - } else if (old._attachments != null) { - await this.addAttachments(result.id, result.rev, await prepareAttachments(old._attachments)) - } - - return await this.get(id) - }) - } - - /** Updates an existing document in the database. */ - async update(document: Simplify, ...attachments: Attachment[]) { - return await this.run(async (db) => { - const id = document._id.toUpperCase() - const old = await this.getDoc(id) - const doc = { ...this.#schema.parse({ ...old, ...document }), _id: id, _rev: old._rev } - - const result = await db.put(doc) - /* v8 ignore next 1 */ // Likely trigger by database corruption. - if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) - if (attachments.length > 0) { - await this.addAttachments(result.id, result.rev, attachments) - } else if (document._attachments != null && document._attachments.length > 0) { - await this.addAttachments(result.id, result.rev, document._attachments) - } else if (old._attachments != null) { - await this.addAttachments(result.id, result.rev, await prepareAttachments(old._attachments)) - } - - return await this.get(id) - }) - } - - /** Replaces an existing document, or inserts a new one, with the given ID */ - async replace(document: Simplify, ...attachments: Attachment[]) { - return await this.run(async (db) => { - const id = document._id.toUpperCase() - const old = await this.getDoc(id).catch(() => ({ _rev: undefined, _attachments: undefined })) - const doc = old._rev - ? { ...this.#schema.parse(document), _id: id, _rev: old._rev } - : { ...this.#schema.parse(document), _id: id } - - const result = await db.put(doc) - // Unlike upsert, we don't transfer the existing attachments in the database to the new revision. - /* v8 ignore next 1 */ // Likely trigger by database corruption. - if (!result.ok) throw new Error(`Failed to insert document "${doc._id}"`) - if (attachments.length > 0) { - await this.addAttachments(result.id, result.rev, attachments) - } else if (document._attachments != null && document._attachments.length > 0) { - await this.addAttachments(result.id, result.rev, document._attachments) - } - - return await this.get(id) - }) - } - - /** Removes a document from the database. */ - async remove(id: DocumentId, rev?: RevisionId) { - await this.run(async (db) => { - const revId = is.nonEmptyString(rev) ? rev : (await this.getDoc(id))._rev - await db.remove(id, revId) - }) - } - - /** Removes all documents from the database. */ - async clear() { - await this.run(async (db) => { - const docs = await this.allDocs() - await Promise.all( - docs.map(async ({ _id, _rev }) => { - await this.remove(_id, _rev) - }) - ) - await db.compact() - }) - } - - /** Deletes all data related to the database. */ - async destroy() { - await this.run(async (db) => { - await db.destroy() - }) - } - - /** Closes the database. */ - async close() { - await this.run(async (db) => { - await db.close() - }) - } -} diff --git a/src/main/services/desktop.ts b/src/main/services/desktop.ts deleted file mode 100644 index af15e5f..0000000 --- a/src/main/services/desktop.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { memo } from 'radash' -import { z } from 'zod' - -/** Defines the Desktop Entry file data type schemas. */ -const useEntry = memo(function useEntry() { - const Boolean = z.coerce.boolean() - const String = z.string() - const StringList = z - .string() - .refine((v) => v.includes(';')) - .transform((v) => v.split(';')) - const Type = z.enum(['Application', 'Link', 'Directory']) - const Version = z.string().regex(/^\d+\.\d+/u) - const Url = z.string().url() - - const Any = z.union([Boolean, StringList, String]) - - const Section = z.record(Any) - - return { - Section, - Boolean, - String, - StringList, - Type, - Version, - Url, - Any - } -}) - -/** Desktop Entry file data type schemas. */ -export const Entry = useEntry() - -/** Basic entry. */ -export type BaseEntry = z.infer -/** Basic Zod schema for an entry. */ -export const BaseEntry = z.object({ - // Type: Entry.Type.default('Application'), - Version: Entry.Version.optional(), - Name: Entry.String.min(1), - GenericName: Entry.String.optional(), - NoDisplay: Entry.Boolean.optional(), - Comment: Entry.String.optional(), - Icon: Entry.String.optional(), - Hidden: Entry.Boolean.optional(), - OnlyShowIn: Entry.StringList.optional(), - NotShowIn: Entry.StringList.optional() -}) - -/** Application entry. */ -export type ApplicationEntry = z.infer -/** Zod schema for an Application entry. */ -export const ApplicationEntry = BaseEntry.extend({ - Type: z.literal('Application'), - TryExec: Entry.String.optional(), - Exec: Entry.String.optional(), - Path: Entry.String.optional(), - Terminal: Entry.Boolean.optional(), - Actions: Entry.StringList.optional(), - MimeType: Entry.StringList.optional(), - Categories: Entry.StringList.optional(), - Implements: Entry.StringList.optional(), - Keywords: Entry.StringList.optional(), - StartupNotify: Entry.String.optional(), - StartupWMClass: Entry.String.optional(), - PrefersNonDefaultGPU: Entry.Boolean.optional(), - SingleMainWindow: Entry.Boolean.optional() -}).catchall(Entry.Any) - -/** Link entry. */ -export type LinkEntry = z.infer -/** Basic Zod schema for a Link entry. */ -export const LinkEntry = BaseEntry.extend({ - Type: z.literal('Link'), - URL: Entry.Url -}).catchall(Entry.Any) - -/** Directory entry. */ -export type DirectoryEntry = z.infer -/** Basic Zod schema for a Directory entry. */ -export const DirectoryEntry = BaseEntry.extend({ - Type: z.literal('Directory') -}).catchall(Entry.Any) - -/** Desktop entry. */ -export type DesktopEntry = z.infer -/** Basic Zod schema for a Desktop Entry. */ -export const DesktopEntry = z.union([ApplicationEntry, LinkEntry, DirectoryEntry]) - -/** Parsed Desktop entry file. */ -export type DesktopEntryFile = z.infer -/** Basic Zod schema for Desktop Entry files. Does not validate all information only basic types. */ -export const DesktopEntryFile = z - .object({ - 'Desktop Entry': DesktopEntry - // TODO: Actions - }) - .catchall(Entry.Section) - -/** Makes sure the desktop structure is ready for INI serialization, joining arrays. */ -export function readyEntry(file: DesktopEntryFile) { - for (const section of Object.values(file)) { - for (const [key, value] of Object.entries(section)) { - if (Array.isArray(value)) { - section[key] = value.map((v) => String(v)).join(';') - } - } - } - - return file -} diff --git a/src/main/services/drivers.ts b/src/main/services/drivers.ts deleted file mode 100644 index 8101d5e..0000000 --- a/src/main/services/drivers.ts +++ /dev/null @@ -1,204 +0,0 @@ -// -// Device capabilities -// - -import { memo } from 'radash' -import useSerialPorts from './ports' -import type { ApiLocales } from './locale' -import type { MaybePromise } from '@/basics' -import { isIpOrValidPort } from '@/location' - -/** The device has no extended capabilities. */ -export type kDeviceHasNoExtraCapabilities = typeof kDeviceHasNoExtraCapabilities -export const kDeviceHasNoExtraCapabilities = 0 -/** The device has multiple output channels. */ -export type kDeviceSupportsMultipleOutputs = typeof kDeviceSupportsMultipleOutputs -export const kDeviceSupportsMultipleOutputs = 1 -/** The device support sending the audio output to a different channel. */ -export type kDeviceCanDecoupleAudioOutput = typeof kDeviceCanDecoupleAudioOutput -export const kDeviceCanDecoupleAudioOutput = 2 - -export type DriverKind = 'monitor' | 'switch' - -// -// Driver definition -// - -export interface DriverBasicInformation { - /** - * Indicates whether the driver is enabled, this is to allow partially coded drivers to be - * committed, but not usable to the UI or other code. - */ - readonly enabled: boolean - /** Indicates whether the driver is experimental, usually due to lack of testing. */ - readonly experimental: boolean - /** Identifies the kind of device driven by the driver. */ - readonly kind: DriverKind - /** A unique identifier for the driver. */ - readonly guid: string - /** Defines the capabilities of the device driven by the driver. */ - readonly capabilities: number -} - -/** Defines the localized metadata about a driver. */ -export interface LocalizedDriverInformation { - /** Defines the title for the driver in a specific locale. */ - readonly title: string - /** Defines the company for the driver in a specific locale. */ - readonly company: string - /** Defines the provider for the driver in a specific locale. */ - readonly provider: string -} - -/** Defines basic metadata about a device and driver. */ -export interface DriverInformation extends DriverBasicInformation { - /** Defines the localized driver information in all supported locales. */ - readonly localized: Readonly> -} - -/** Interacts with a device. */ -export interface DriverBindings { - /** - * Activates input and output ties. - * - * @param uri - URI identifying the location of the device. - * @param inputChannel - The input channel to tie. - * @param videoOutputChannel - The output video channel to tie. - * @param audioOutputChannel - The output audio channel to tie. - */ - readonly activate: ( - uri: string, - inputChannel: number, - videoOutputChannel: number, - audioOutputChannel: number - ) => Promise - - /** - * Powers on the switch or monitor. - * - * @param uri - URI identifying the location of the device. - */ - readonly powerOn: (uri: string) => Promise - - /** - * Powers off the switch or monitor. - * - * @param uri - URI identifying the location of the device. - */ - readonly powerOff: (uri: string) => Promise -} - -export interface DefineDriverOptions extends DriverInformation { - setup: () => DriverBindings -} - -const registry = new Map() - -export interface Driver extends DriverBasicInformation, DriverBindings { - /** Raw metadata from the registration options. */ - readonly metadata: DriverInformation - /** Gets the localized driver information. */ - getInfo: (locale: ApiLocales) => LocalizedDriverInformation -} - -export function defineDriver(options: DefineDriverOptions) { - let existing = registry.get(options.guid) - if (existing != null) return existing - - const { setup, ...info } = options - const implemented = setup() - - const getInfo = memo(function getInfo(locale: ApiLocales) { - const localizedInfo = info.localized[locale] - - return { - enabled: info.enabled, - experimental: info.experimental, - kind: info.kind, - guid: info.guid, - ...localizedInfo, - capabilities: info.capabilities - } - }) - - existing = Object.freeze({ - // Provided bindings. - ...implemented, - // Information and informational functionality. - enabled: info.enabled, - experimental: info.experimental, - kind: info.kind, - guid: info.guid, - capabilities: info.capabilities, - metadata: info, - getInfo - }) - - registry.set(options.guid, existing) - return existing -} - -const useDrivers = memo(function useDriver() { - const drivers = import.meta.glob('../drivers/**/*') - const ports = useSerialPorts() - - const booted = Promise.all( - Object.values(drivers).map(async (factory) => { - await factory() - }) - ) - - function defineOperation(op: (...args: Args) => MaybePromise) { - return async (...args: Args) => { - await booted - return await op(...args) - } - } - - const registered = defineOperation(() => Array.from(registry.values()).filter((driver) => driver.enabled)) - - const allInfo = defineOperation(() => - Array.from(registry.values()) - .filter((driver) => driver.enabled) - .map((d) => d.metadata) - ) - - const get = defineOperation((guid: string) => registry.get(guid) ?? null) - - function defineDriverOperation( - op: (driver: Driver, uri: string, ...args: Args) => MaybePromise - ) { - return async (guid: string, uri: string, ...args: Args) => { - const driver = await get(guid) - if (driver == null) throw new ReferenceError(`No such driver: "${guid}"`) - const valid = await ports.listPorts() - if (!isIpOrValidPort(uri, valid)) throw new TypeError(`"${uri}" is not a valid location`) - return await op(driver, uri, ...args) - } - } - - const activate = defineDriverOperation( - async (driver, uri: string, input: number, videoOutput: number, audioOutput: number) => { - await driver.activate(uri, input, videoOutput, audioOutput) - } - ) - - const powerOn = defineDriverOperation(async (driver, uri: string) => { - await driver.powerOn(uri) - }) - - const powerOff = defineDriverOperation(async (driver, uri: string) => { - await driver.powerOff(uri) - }) - - return { - registered, - allInfo, - get, - activate, - powerOn, - powerOff - } -}) - -export default useDrivers diff --git a/src/main/services/level.d.ts b/src/main/services/level.d.ts deleted file mode 100644 index 1153d00..0000000 --- a/src/main/services/level.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { LevelDown } from 'leveldown' -import { LevelUp } from 'levelup' - -export const useLevelDb: () => { - leveldown: (name: string) => LevelDown - levelup: (name: string) => Promise> -} - -export const useLevelAdapter: () => PouchDB.Plugin diff --git a/src/main/services/level.js b/src/main/services/level.js deleted file mode 100644 index 4ef40aa..0000000 --- a/src/main/services/level.js +++ /dev/null @@ -1,106 +0,0 @@ -import { resolve as resolvePath } from 'node:path' -import { app } from 'electron' -import Logger from 'electron-log' -import levelDown from 'leveldown' -import levelUp from 'levelup' -// @ts-expect-error -- No types -import LevelPouch from 'pouchdb-adapter-leveldb-core' -import { memo } from 'radash' - -/* globals PouchDB */ // Fixes PouchDB undefined in JSDoc. - -// -// NOTE: While PouchDB has a built-in LevelDB adapter, we want to have -// as minimum of an external footprint as possible. This will be -// done by using our own quick-and-dirty adapter that just -// uses the PouchDB built in adapter. -// - -/** @template ALD @typedef {import('levelup').LevelUp} LevelUp */ -/** @typedef {(err: Error | undefined) => void} ErrorCallback */ -/** @typedef {import('leveldown').LevelDown} LevelDown */ - -export const useLevelDb = memo(function useLevelDb() { - const leveldown = memo( - /** - * @param {string} name The name of the database. - */ - function leveldown(name) { - const path = resolvePath(app.getPath('userData'), name) - const db = levelDown(path) - app.on('will-quit', () => { - db.close((err) => { - /* v8 ignore next 1 */ // No way to spy or mock this deep in. - if (err != null) Logger.error(err) - }) - }) - - return db - } - ) - - const levelup = memo( - /** - * @param {string} name The name of the database. - */ - async function levelup(name) { - const db = leveldown(name) - return await new Promise( - /* eslint-disable jsdoc/no-undefined-types -- Has issue with generics. */ - /** - * @param {(db: LevelUp) => void} resolve Resolver - * @param {(error: Error) => void} reject Rejecter - */ - /* eslint-enable jsdoc/no-undefined-types */ - (resolve, reject) => { - /** - * @param {Error|undefined} error Error - */ - const cb = (error) => { - /* v8 ignore next 2 */ // No way to spy or mock this deep in. - if (error == null) resolve(up) - else reject(error) - } - - const up = levelUp(db, cb) - } - ) - } - ) - - return { - leveldown, - levelup - } -}) - -export const useLevelAdapter = memo(function useLevelAdapter() { - const { leveldown } = useLevelDb() - - /** @typedef {Record} LevelPouch */ - - /** - * @this {Partial} - * @param {Record} opts Plugin options - * @param {ErrorCallback} cb Error callback - */ - function MainDown(opts, cb) { - // eslint-disable-next-line -- Everything is messed up with no typings. - LevelPouch.call(this, { ...opts, db: leveldown }, cb) - } - - MainDown.valid = function () { - return true - } - - MainDown.use_prefix = true - - /** @type {PouchDB.Plugin} */ - const plugin = (pouch) => { - // @ts-expect-error -- Not defined in the types. - // eslint-disable-next-line -- Everything is messed up with no typings. - pouch.adapter('maindb', MainDown, true) - } - - return plugin -}) diff --git a/src/main/services/locale.ts b/src/main/services/locale.ts deleted file mode 100644 index cd01773..0000000 --- a/src/main/services/locale.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Supported API locales. */ -export type ApiLocales = 'en' diff --git a/src/main/services/migration.ts b/src/main/services/migration.ts deleted file mode 100644 index e795a28..0000000 --- a/src/main/services/migration.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { basename } from 'node:path' -import Logger from 'electron-log' -import { alphabetical, memo } from 'radash' -import { z } from 'zod' -import { useLevelDb } from './level' - -export type Migration = z.infer -export const Migration = z - .object({ - migrate: z.function(z.tuple([]), z.unknown()) - }) - .transform((m) => m.migrate) - -type State = z.infer -const State = z.enum(['missed', 'failed', 'done']) -const StateInDb = z - .union([z.instanceof(Buffer), State]) - /* v8 ignore next 1 */ // Generally one or the other most of the time. - .transform((v) => (Buffer.isBuffer(v) ? State.parse(v.toString()) : v)) - -const useMigrations = memo(function useMigrations() { - /** Gets the sorted list of migration modules. */ - function loadMigrations() { - const migrations = Object.entries(import.meta.glob('../migrations/**/*', { eager: true })).map( - ([name, module]) => ({ name, migrate: Migration.parse(module) }) - ) - - return alphabetical(migrations, (m) => m.name) - } - - /** Opens a connection to the migration database. */ - async function openMigrationDatabase() { - return await useLevelDb().levelup('_migrations') - } - - // Memoized so the UI can determine the results. - const performMigration = memo(async function performMigration() { - Logger.debug('Loading migration information') - const migrations = loadMigrations() - const database = await openMigrationDatabase() - - /* eslint-disable no-await-in-loop */ - for (const migration of migrations) { - const name = basename(migration.name, '.ts') - - const state = StateInDb.parse(await database.get(name).catch(() => 'missed')) - if (state === 'done') continue - - /* v8 ignore next 2 */ // Difficult to test without injecting a broken migration. - if (state === 'failed') { - Logger.warn(`Attempting failed migration again: ${name}`) - } else { - Logger.debug(`Attempting migration: ${name}`) - } - - try { - await migration.migrate() - /* v8 ignore next 4 */ // Difficult to test without injecting a broken migration. - } catch (cause) { - await database.put(name, 'failed') - throw new Error(`Failed to complete migration: ${name}`, { cause }) - } - - try { - await database.put(name, 'done') - /* v8 ignore next 4 */ // Difficult to test without injecting a broken migration. - } catch (cause) { - await database.put(name, 'failed') - throw new Error(`Failed to record migration completion: ${name}`, { cause }) - } - } - /* eslint-enable no-await-in-loop */ - - await database.close() - }) - - return performMigration -}) - -export default useMigrations diff --git a/src/main/services/ports.ts b/src/main/services/ports.ts deleted file mode 100644 index c27efe2..0000000 --- a/src/main/services/ports.ts +++ /dev/null @@ -1,119 +0,0 @@ -import is from '@sindresorhus/is' -import { memo } from 'radash' -import { SerialPort } from 'serialport' - -// HACK: Workaround legacy TypeDefinition from serialport PortInfo. -export interface PortInfo { - path: string - manufacturer: string | undefined - serialNumber: string | undefined - pnpId: string | undefined - locationId: string | undefined - productId: string | undefined - vendorId: string | undefined -} - -export interface PortEntry extends PortInfo { - title: string -} - -type BadPnpId = typeof BadPnpId -const BadPnpId = Symbol('UsePath') - -const useSerialPorts = memo(function useSerialPorts() { - // TODO: Determine a way to test the title caching. - - const titled = new Map() - - function getPortTitle(info: PortInfo) { - const { manufacturer, pnpId, path } = info - - if (!is.nonEmptyStringAndNotWhitespace(pnpId)) { - // If there is no pnpId; don't cache the name, - // it's less costly by avoiding the map - // lookup. We will still key on the - // manufacturer for weird PnP IDs. - return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path - } - - let title = titled.get(pnpId) - if (title === BadPnpId) { - return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path - } - - if (title != null) return title - - // The PnP ID seems to be based on this format; - // ${bus}-${snake_style_label}-${positions.join('-')}, - // where: - // - bus: 'usb' | 'tty' | etc. - // - snake_style_label: 'Friendly_name_in_snake_style' - // - positions: Array<`port{number}` | `if{number}`> - - // First, split by hyphen, this should produce - // the bus/label/position combo. - let labelParts = pnpId.split('-') - if (labelParts.length < 3) { - titled.set(pnpId, BadPnpId) - return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path - } - - // Pop any positions off the end. - for (;;) { - const part = labelParts.at(-1) - if (part == null) { - titled.set(pnpId, BadPnpId) - return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path - } - - if (/^port\d+$/u.test(part)) { - labelParts.pop() - } else if (/^if\d+$/u.test(part)) { - labelParts.pop() - } else { - break - } - } - - // Slice off the bus. - labelParts = labelParts.slice(1) - if (labelParts.length === 0) { - titled.set(pnpId, BadPnpId) - return is.nonEmptyStringAndNotWhitespace(manufacturer) ? manufacturer : path - } - - // Now, rejoin the label by hyphens, in case - // those were in the friendly name, and - // replace underscores with spaces. - title = labelParts.join('-').replace(/_/gu, ' ') - titled.set(pnpId, title) - return title - } - - async function listRawPorts() { - return (await SerialPort.list()) as PortInfo[] - } - - async function listPorts() { - const ports = await listRawPorts() - - return ports.map(function parsePortInfo(port) { - return { - ...port, - title: getPortTitle(port) - } - }) - } - - async function isValidPort(path: string) { - const ports = await listRawPorts() - return ports.find((port) => port.path === path) != null - } - - return { - listPorts, - isValidPort - } -}) - -export default useSerialPorts diff --git a/src/main/services/protocols/extronSis.ts b/src/main/services/protocols/extronSis.ts deleted file mode 100644 index 23dfca4..0000000 --- a/src/main/services/protocols/extronSis.ts +++ /dev/null @@ -1,32 +0,0 @@ -import Logger from 'electron-log' -import { memo } from 'radash' -import { useProtocol } from './protocols' - -export const useExtronSisProtocol = memo(function useExtronSisProtocol() { - const kProtocol = 'extron/sis' - - const { sendCommand } = useProtocol(kProtocol) - - async function activate(uri: string, input: number, videoOutput: number, audioOutput: number) { - Logger.log(`${kProtocol}/tie(${input}, ${videoOutput}, ${audioOutput})`) - const videoCommand = `${input}*${videoOutput}%` - const audioCommand = `${input}*${audioOutput}$` - await sendCommand(uri, `${videoCommand}\r\n${audioCommand}\r\n`) - } - - async function powerOn() { - Logger.log(`${kProtocol}/powerOn; no-op`) - await Promise.resolve() - } - - async function powerOff() { - Logger.log(`${kProtocol}/powerOff; no-op`) - await Promise.resolve() - } - - return { - activate, - powerOn, - powerOff - } -}) diff --git a/src/main/services/protocols/protocols.ts b/src/main/services/protocols/protocols.ts deleted file mode 100644 index 6523c30..0000000 --- a/src/main/services/protocols/protocols.ts +++ /dev/null @@ -1,49 +0,0 @@ -import Logger from 'electron-log' -import { memo } from 'radash' -import { createCommandStream } from '../stream' - -interface ProtocolOptions { - baudRate?: number - dataBits?: 5 | 6 | 7 | 8 - stopBits?: 1 | 1.5 | 2 - parity?: 'none' | 'odd' | 'even' - timeout?: number - // TODO: onError? - // TODO: onData? - // TODO: Other situation handlers... -} - -export const useProtocol = memo(function useProtocol(name: string, options: ProtocolOptions = {}) { - // Options and defaults. - const { baudRate = 9600, dataBits = 8, stopBits = 1, parity = 'none', timeout = 5000 } = options - - async function sendCommand(uri: string, command: Buffer): Promise - async function sendCommand(uri: string, command: string, encoding?: BufferEncoding): Promise - async function sendCommand(uri: string, command: Buffer | string, encoding?: BufferEncoding) { - const connection = await createCommandStream(uri, { - baudRate, - dataBits, - stopBits, - parity, - timeout, - keepAlive: true - }) - - connection.on('data', (data) => { - Logger.debug(`${name}; returned ${String(data)}`) - }) - connection.on('error', (error) => { - /* v8 ignore next 1 */ // TODO: Will attempt to mock later. - Logger.error(`${name}; ${error.message}`) - }) - - if (typeof command === 'string') { - command = Buffer.from(command, encoding ?? 'ascii') - } - - await connection.write(command) - await connection.close() - } - - return { sendCommand } -}) diff --git a/src/main/services/protocols/shinybow.ts b/src/main/services/protocols/shinybow.ts deleted file mode 100644 index 9200530..0000000 --- a/src/main/services/protocols/shinybow.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Logger from 'electron-log' -import { memo } from 'radash' -import { useProtocol } from './protocols' - -export const useShinybowV2Protocol = memo(function useShinybowV2Protocol() { - const kProtocol = 'shinybow/v2.0' - const { sendCommand } = useProtocol(kProtocol) - - const toChannel = (n: number) => String(n).padStart(2, '0') - - async function activate(uri: string, input: number, output: number) { - Logger.log(`${kProtocol}/tie(${input}, ${output})`) - await sendCommand(uri, `OUTPUT${toChannel(output)} ${toChannel(input)};\r\n`) - } - - async function powerOn(uri: string) { - Logger.log(`${kProtocol}/powerOn`) - await sendCommand(uri, 'POWER 01;\r\n') - } - - async function powerOff(uri: string) { - Logger.log(`${kProtocol}/powerOff`) - await sendCommand(uri, 'POWER 00;\r\n') - } - - return { - activate, - powerOn, - powerOff - } -}) - -export const useShinybowV3Protocol = memo(function useShinybowV3Protocol() { - const kProtocol = 'shinybow/v3.0' - const { sendCommand } = useProtocol(kProtocol) - - const toChannel = (n: number) => String(n).padStart(3, '0') - - async function activate(uri: string, input: number, output: number) { - Logger.log(`${kProtocol}/tie(${input}, ${output})`) - await sendCommand(uri, `OUTPUT${toChannel(output)} ${toChannel(input)};\r\n`) - } - - async function powerOn(uri: string) { - Logger.log(`${kProtocol}/powerOn`) - await sendCommand(uri, 'POWER 001;\r\n') - } - - async function powerOff(uri: string) { - Logger.log(`${kProtocol}/powerOff`) - await sendCommand(uri, 'POWER 000;\r\n') - } - - return { - activate, - powerOn, - powerOff - } -}) diff --git a/src/main/services/protocols/sonyBvm.ts b/src/main/services/protocols/sonyBvm.ts deleted file mode 100644 index 5719ab1..0000000 --- a/src/main/services/protocols/sonyBvm.ts +++ /dev/null @@ -1,46 +0,0 @@ -import Logger from 'electron-log' -import { memo } from 'radash' -import { useProtocol } from './protocols' -import { createAddress, createCommand, kAddressAll, kPowerOff, kPowerOn, kSetChannel } from './sonyRs485' -import type { Command, CommandArg } from './sonyRs485' - -export const useSonyBvmProtocol = memo(function useSonyBvmProtocol() { - const kProtocol = 'sony/bvm' - - const { sendCommand: sendRawCommand } = useProtocol(kProtocol, { - baudRate: 38400, - dataBits: 8, - stopBits: 1, - parity: 'odd', - timeout: 5000 - }) - - async function sendCommand(uri: string, command: Command, arg0?: CommandArg, arg1?: CommandArg) { - const source = createAddress(kAddressAll, 0) - const destination = createAddress(kAddressAll, 0) - const packet = createCommand(destination, source, command, arg0, arg1) - - await sendRawCommand(uri, packet) - } - - async function activate(uri: string, input: number) { - Logger.log(`${kProtocol}/channel(${input})`) - await sendCommand(uri, kSetChannel, 1, input) - } - - async function powerOn(uri: string) { - Logger.log(`${kProtocol}/powerOn`) - await sendCommand(uri, kPowerOn) - } - - async function powerOff(uri: string) { - Logger.log(`${kProtocol}/powerOff`) - await sendCommand(uri, kPowerOff) - } - - return { - activate, - powerOn, - powerOff - } -}) diff --git a/src/main/services/protocols/sonyRs485.ts b/src/main/services/protocols/sonyRs485.ts deleted file mode 100644 index c6a75fa..0000000 --- a/src/main/services/protocols/sonyRs485.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Buffer } from 'node:buffer' -import { z } from 'zod' - -/* - * Sony BVM D-series RS-485 Serial Protocol (Current Understanding) - * - * This driver is based on the current understanding of the protocol from observation of the remote output. This may - * not be entirely correct and will be missing other information. Since the packet format vaguely mimics the 9-pin - * protocol, the packet @types use those names. - * - * Packet Format - * - Byte 1: Packet type - * - Byte 2: Packet size (N) - * - Byte 3..N-1: Packet data (D) - * - Byte 3+N: Packet checksum; ~(sum(D)) - (N - 1) - * - * Packet Type - * - 2: Command package - * - * Command-block Format - * - Byte 1: Destination address - * - Byte 2: Source address - * - Byte 3..4: Command - * - Byte 5: Arg zero - * - Byte 6: Arg one - * - * Address Format - * - Bits 7..5: Address kind - * - C - All connected monitors, the address number should be zero. - * - 8 - A monitor group, the address number should be the group number. - * - 0 - A single monitor, the address number should be the monitor number. - * - Bits 4..0: Address number - */ - -export class SonyDriverError extends Error { - constructor(message?: string) { - super(`[Sony Driver]: ${message ?? 'Unknown error'}`) - } -} - -export class PacketError extends SonyDriverError { - constructor(message?: string) { - super(message ?? 'Unknown package error') - } -} - -/** Calculates the checksum for a packet. */ -export function calculateChecksum(data: Buffer) { - let x = 0n - for (const byte of data) { - x = x + BigInt(byte) - } - - x = ~x & 0xffn - x = x - (BigInt(data.byteLength) - 1n) - - return Number(x) -} - -export type PacketType = z.infer -export const PacketType = z.number().int().brand('PacketType') - -/** Identifies a command packet. */ -export const kCommandPacket = PacketType.parse(0x2) - -export type Package = z.infer -export const Package = z.instanceof(Buffer).brand('Packet') - -/** Create a packet. */ -export function createPacket(type: PacketType, data: Buffer) { - if (data.byteLength === 0) { - throw new PacketError('Attempting to send empty packet') - } - - if (data.byteLength > 0xff) { - throw new PacketError(`Packet is too large at ${data.byteLength}B`) - } - - const checksum = calculateChecksum(data) - const buffer = Buffer.alloc(3 + data.byteLength) - let pos = 0 - - buffer.writeUInt8(type, pos) - pos += 1 - buffer.writeUInt8(data.byteLength, pos) - pos += 1 - data.copy(buffer, pos) - pos += data.byteLength - buffer.writeUInt8(checksum, pos) - pos += 1 - - return Package.parse(buffer.subarray(0, pos)) -} - -export type AddressKind = z.infer -export const AddressKind = z.number().int().brand('AddressKind') - -/** Identifier all devices are being addressed. */ -export const kAddressAll = AddressKind.parse(0xc0) -/** Identifier a group of monitors are being addressed. */ -export const kAddressGroup = AddressKind.parse(0x80) -/** Identifier a single monitor are being addressed. */ -export const kAddressMonitor = AddressKind.parse(0x00) - -export type AddressNumber = z.infer -export const AddressNumber = z.number().int().min(0).max(0x1f) - -export type Address = z.infer -export const Address = z.number().int().min(0).max(0xff).brand('Address') - -export function createAddress(kind: AddressKind, address: AddressNumber) { - return Address.parse(kind | AddressNumber.parse(address)) -} - -export type Command = z.infer -export const Command = z.number().int().brand('Command') - -export const kSetChannel = Command.parse(0x2100) -export const kPowerOn = Command.parse(0x293e) -export const kPowerOff = Command.parse(0x2a3e) -export const kPressButton = Command.parse(0x3f44) - -export type CommandArg = z.infer -export const CommandArg = z.number().int().min(0).max(0xff) - -export function createCommand( - destination: Address, - source: Address, - command: Command, - arg0?: CommandArg, - arg1?: CommandArg -) { - arg0 = CommandArg.optional().parse(arg0) - arg1 = CommandArg.optional().parse(arg1) - - const buffer = Buffer.alloc(6, 0) - let pos = 0 - - buffer.writeUInt8(destination, pos) - pos += 1 - buffer.writeUInt8(source, pos) - pos += 1 - buffer.writeUInt16BE(command, pos) - pos += 2 - if (arg0 == null || arg0 === 0) { - return createPacket(kCommandPacket, buffer.subarray(0, pos)) - } - - buffer.writeUInt8(arg0, pos) - pos += 1 - if (arg1 == null || arg1 === 0) { - return createPacket(kCommandPacket, buffer.subarray(0, pos)) - } - - buffer.writeUInt8(arg1, pos) - pos += 1 - - return createPacket(kCommandPacket, buffer.subarray(0, pos)) -} diff --git a/src/main/services/protocols/teslaElec.ts b/src/main/services/protocols/teslaElec.ts deleted file mode 100644 index 867e931..0000000 --- a/src/main/services/protocols/teslaElec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import Logger from 'electron-log' -import { memo } from 'radash' -import { useProtocol } from './protocols' - -export const useTeslaElecKvmProtocol = memo(function useTeslaElecKvmProtocol() { - const kProtocol = 'teslaElec/kvm' - const { sendCommand } = useProtocol(kProtocol) - - async function activate(uri: string, input: number) { - Logger.log(`${kProtocol} << channel(${input})`) - await sendCommand(uri, Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee)) - } - - async function powerOn() { - Logger.log(`${kProtocol}/powerOn; no-op`) - await Promise.resolve() - } - - async function powerOff() { - Logger.log(`${kProtocol}/powerOff; no-op`) - await Promise.resolve() - } - - return { - activate, - powerOn, - powerOff - } -}) - -export const useTeslaElecMatrixProtocol = memo(function useTeslaElecMatrixProtocol() { - const kProtocol = 'teslaElec/matrix' - const { sendCommand } = useProtocol(kProtocol) - - const toChannel = (n: number) => String(n).padStart(2, '0') - - async function activate(uri: string, input: number, output: number) { - Logger.log(`${kProtocol} << tie(${input}, ${output})`) - await sendCommand(uri, `MT00SW${toChannel(input)}${toChannel(output)}NT\r\n`) - - await Promise.resolve() - } - - async function powerOn() { - Logger.log(`${kProtocol}/powerOn; no-op`) - await Promise.resolve() - } - - async function powerOff() { - Logger.log(`${kProtocol}/powerOff; no-op`) - await Promise.resolve() - } - - return { - activate, - powerOn, - powerOff - } -}) - -export const useTeslaElecSdiProtocol = memo(function useTeslaElecSdiProtocol() { - const kProtocol = 'teslaElec/sdi' - const { sendCommand } = useProtocol(kProtocol) - - async function activate(uri: string, input: number) { - Logger.log(`${kProtocol} << channel(${input})`) - await sendCommand(uri, Buffer.of(0xaa, 0xcc, 0x01, input)) - } - - async function powerOn() { - Logger.log(`${kProtocol}/powerOn; no-op`) - await Promise.resolve() - } - - async function powerOff() { - Logger.log(`${kProtocol}/powerOff; no-op`) - await Promise.resolve() - } - - return { - activate, - powerOn, - powerOff - } -}) diff --git a/src/main/services/rpc/ipc.ts b/src/main/services/rpc/ipc.ts deleted file mode 100644 index 08d69a6..0000000 --- a/src/main/services/rpc/ipc.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { callProcedure, getDataTransformer, getTRPCErrorFromUnknown, TRPCError } from '@trpc/server' -import { isObservable } from '@trpc/server/observable' -import { getErrorShape, transformTRPCResponse } from '@trpc/server/shared' -import { ipcMain } from 'electron' -import Logger from 'electron-log' -import type { MaybePromise } from '@/basics' -import type { CreateContextOptions } from '@/rpc/ipc' -import type { AnyRouter, inferRouterContext } from '@trpc/server' -import type { Unsubscribable } from '@trpc/server/observable' -import type { TRPCClientOutgoingMessage, TRPCResponseMessage } from '@trpc/server/rpc' -import type { BrowserWindow, IpcMainEvent } from 'electron' -import { theRpcChannel } from '@/rpc/ipc' - -interface CreateIpcHandlerOptions { - router: Router - createContext?: (opts: CreateContextOptions) => MaybePromise> - windows?: BrowserWindow[] -} - -/** - * Creates a tRPC handler using the Electron IPC. - * - * This handler is modelled after the web-socket handler in tRPC. - * - * @param options - Handler options - * @returns Return a tRPC handler for Electron IPC. - */ -export function createIpcHandler(options: CreateIpcHandlerOptions) { - const windows = new Set() - const subscriptions = new Map() - const { router, createContext } = options - const config = router._def._config - - const transformer = getDataTransformer(config.transformer as never) - - function getSubscriptionId(id: number, frameId: number | undefined) { - return frameId != null ? `${id}-${frameId}` : `${id}-` - } - - function cleanUpSubscriptions(id: number, frameId?: number) { - const removing = new Set() - for (const [key, subscription] of subscriptions) { - if (key.startsWith(getSubscriptionId(id, frameId))) { - subscription.unsubscribe() - removing.add(key) - } - } - for (const key of removing) { - subscriptions.delete(key) - } - } - - function setupSubscriptionCleanup(window: BrowserWindow) { - window.webContents.on('did-start-navigation', (event) => { - cleanUpSubscriptions(window.webContents.id, event.frame.routingId) - }) - window.webContents.on('destroyed', () => { - detachWindow(window) - }) - } - - function attachWindow(window: BrowserWindow) { - if (windows.has(window)) return - - windows.add(window) - setupSubscriptionCleanup(window) - } - - function detachWindow(window: BrowserWindow) { - if (!windows.has(window)) return - - windows.delete(window) - cleanUpSubscriptions(window.webContents.id) - } - - function unsubscribe(id: string) { - const subscription = subscriptions.get(id) - if (subscription == null) return - - subscription.unsubscribe() - subscriptions.delete(id) - } - - async function handleMessage(event: IpcMainEvent, msg: TRPCClientOutgoingMessage) { - if (msg.id == null) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '`id` is required' }) - } - - const { id, jsonrpc = '2.0' } = msg - const subscriptionId = `${event.sender.id}-${event.senderFrame.routingId}:${id}` - function respond(response: TRPCResponseMessage) { - if (event.sender.isDestroyed()) return - const rpcResponse = transformTRPCResponse(config, response) - event.reply(theRpcChannel, rpcResponse) - } - - function stopSubscription() { - unsubscribe(subscriptionId) - respond({ id, jsonrpc, result: { type: 'stopped' } }) - } - - if (msg.method === 'subscription.stop') { - stopSubscription() - return - } - - const path = msg.params.path - const input = transformer.input.deserialize(msg.params.input) as unknown - const type = msg.method - const ctx = (await createContext?.({ event })) ?? {} - - try { - const result = await callProcedure({ - procedures: router._def.procedures as never, - path, - rawInput: input, - ctx, - type - }) - - if (type !== 'subscription') { - respond({ id, jsonrpc, result: { type: 'data', data: result } }) - return - } else if (!isObservable(result)) { - throw new TRPCError({ - message: `Subscription ${path} did not return an observable`, - code: 'INTERNAL_SERVER_ERROR' - }) - } - - const observable = result - const sub = observable.subscribe({ - next(data) { - respond({ id, jsonrpc, result: { type: 'data', data } }) - }, - error(err) { - const error = getTRPCErrorFromUnknown(err) - respond({ id, jsonrpc, error: getErrorShape({ config, error, type, path, input, ctx }) as never }) - }, - complete() { - respond({ id, jsonrpc, result: { type: 'stopped' } }) - } - }) - - if (subscriptions.has(subscriptionId)) { - stopSubscription() - throw new TRPCError({ message: `Duplicate id ${id}`, code: 'BAD_REQUEST' }) - } - - subscriptions.set(subscriptionId, sub) - - respond({ id, jsonrpc, result: { type: 'started' } }) - } catch (err) { - const error = getTRPCErrorFromUnknown(err) - respond({ id, jsonrpc, error: getErrorShape({ config, error, type, path, input, ctx }) as never }) - } - } - - // Initialization - - for (const window of options.windows ?? []) { - attachWindow(window) - } - - ipcMain.on(theRpcChannel, (event, message: TRPCClientOutgoingMessage) => { - handleMessage(event, message).catch((err: unknown) => { - Logger.error('Fatal error in IPC', err, message) - throw err - }) - }) - - return { - attachWindow, - detachWindow - } -} diff --git a/src/main/services/rpc/trpc.ts b/src/main/services/rpc/trpc.ts deleted file mode 100644 index 1a471fc..0000000 --- a/src/main/services/rpc/trpc.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { initTRPC } from '@trpc/server' -import { useIpcJson } from '@/rpc/transformer' - -const t = initTRPC.create({ transformer: useIpcJson() }) - -export const { router, procedure } = t diff --git a/src/main/services/startup.ts b/src/main/services/startup.ts deleted file mode 100644 index 2e6332d..0000000 --- a/src/main/services/startup.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { unlink as deleteFile, mkdir, stat, writeFile, readFile } from 'node:fs/promises' -import { homedir } from 'node:os' -import { resolve as resolvePath, join as joinPath } from 'node:path' -import { app } from 'electron' -import Logger from 'electron-log' -import * as INI from 'ini' -import { memo } from 'radash' -import { xdgConfig } from 'xdg-basedir' -import { DesktopEntryFile, readyEntry } from './desktop' - -const useStartup = memo(function useStartup() { - const configPath = xdgConfig != null ? resolvePath(xdgConfig) : resolvePath(homedir(), '.config') - - const autoStartDir = joinPath(configPath, 'autostart') - const autoStartFile = 'org.sleepingcats.BridgeCmdr.desktop' - const autoStartPath = joinPath(autoStartDir, autoStartFile) - const exePath = process.env['APPIMAGE'] ?? app.getPath('exe') - - /** Checks if the auto-start entry is enabled. */ - async function checkEnabled() { - return await stat(autoStartPath) - .then((s) => s.isFile()) - .catch(() => false) - } - - /** Enables the auto-start entry. */ - async function enable() { - await mkdir(autoStartDir, { recursive: true }) - - const entry: DesktopEntryFile = { - 'Desktop Entry': { - Type: 'Application', - Name: 'BridgeCmdr', - Exec: exePath, - NoDisplay: true, - Terminal: false - } - } - - await writeFile(autoStartPath, INI.stringify(readyEntry(entry)), { encoding: 'utf-8', mode: 0o644, flag: 'w' }) - } - - /** Disables the auto-start entry. */ - async function disable() { - await deleteFile(autoStartPath) - } - - /** Ensure that the file is valid and still points to the right location. */ - async function checkUp() { - const enabled = await checkEnabled() - if (!enabled) { - Logger.debug('No auto-start entry; auto-start disabled, so skipping entry check') - - return - } - - let reenable = false - try { - const file = DesktopEntryFile.parse(INI.parse(await readFile(autoStartPath, { encoding: 'utf-8' }))) - const entry = file['Desktop Entry'] - if (entry.Type !== 'Application' || entry.Exec !== exePath) { - // Rewrite the entry to fix the type of path. - reenable = true - } - } catch (e) { - Logger.error(`Entry file parse error, rewriting: ${String(e)}`) - reenable = true - } - - if (reenable) { - await enable() - } - } - - return { - checkEnabled, - checkUp, - enable, - disable - } -}) - -export default useStartup diff --git a/src/main/services/stream.ts b/src/main/services/stream.ts deleted file mode 100644 index 0f7d968..0000000 --- a/src/main/services/stream.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createConnection } from 'node:net' -import { pipeline, finished } from 'node:stream/promises' -import { SerialPort } from 'serialport' -import { z } from 'zod' -import type { Socket, NetConnectOpts, TcpNetConnectOpts } from 'node:net' -import type { Duplex } from 'node:stream' -import type { Simplify } from 'type-fest' -import { toError } from '@/error-handling' -import { hostWithOptionalPortPattern } from '@/location' - -export type NetStreamOptions = Omit - -type CommonPortStreamOptions = ConstructorParameters[0] - -export type PortStreamOptions = Omit, 'path'> - -interface StreamImplementation { - close: () => Promise -} - -function createStream(stream: Duplex, implementation: StreamImplementation) { - async function write(data: Buffer | string) { - if (typeof data === 'string') { - data = Buffer.from(data, 'ascii') - } - - await pipeline(function* () { - yield data - }, stream) - } - - function once(event: 'data', listener: (chunk: unknown) => void): void - function once(event: 'error', listener: (error: Error) => void): void - - function once(event: string | symbol, listener: (...args: any[]) => void) { - stream.once(event, listener) - } - - function on(event: 'data', listener: (chunk: unknown) => void): () => void - function on(event: 'error', listener: (error: Error) => void): () => void - - function on(event: string | symbol, listener: (...args: any[]) => void) { - const off = () => { - stream.off(event, listener) - } - stream.on(event, listener) - - return off - } - - return { - ...implementation, - write, - once, - on - } -} - -async function createSocketStream(options: NetConnectOpts) { - const socket = await new Promise(function createSocketStreamInner(resolve, reject) { - try { - const stream = createConnection(options, () => { - resolve(stream) - }) - } catch (e) { - reject(toError(e)) - } - }) - - async function close() { - await new Promise((resolve, reject) => { - socket.once('error', (e) => { - reject(e) - }) - socket.end(() => { - resolve() - }) - }) - - await finished(socket) - - socket.destroy() - } - - return createStream(socket, { close }) -} - -async function createNetStream(target: string, options: NetStreamOptions) { - const parts = hostWithOptionalPortPattern.exec(target) - if (parts?.[1] == null) { - throw new Error(`target "${target}" is not a valid host or host:port combination`) - } - - // Right now, we will trust the front-end to confirm - // this or Socket should throw an error. - const host = parts[1] - const port = z.coerce.number().int().positive().default(23).parse(parts[2]) - - return await createSocketStream({ host, port, ...options }) -} - -async function createPortStream(path: string, options: PortStreamOptions) { - const port = await new Promise(function createPortStreamInner(resolve, reject) { - const stream = new SerialPort({ path, ...options }, (e) => { - if (e != null) reject(e) - else resolve(stream) - }) - }) - - async function close() { - await new Promise((resolve, reject) => { - port.close((e) => { - if (e != null) reject(e) - else resolve() - }) - }) - - await finished(port) - - port.destroy() - } - - return createStream(port, { close }) -} - -export type CommandStreamOptions = NetStreamOptions | PortStreamOptions - -export type CommandStream = ReturnType - -export async function createCommandStream(path: string, options: CommandStreamOptions): Promise { - if (path.startsWith('port:')) { - return await createPortStream(path.substring(5), options as PortStreamOptions) - } - - if (path.startsWith('ip:')) { - return await createNetStream(path.substring(3), options as NetStreamOptions) - } - - throw new TypeError('Unsupported stream address') -} diff --git a/src/main/services/system.ts b/src/main/services/system.ts deleted file mode 100644 index 8f8f952..0000000 --- a/src/main/services/system.ts +++ /dev/null @@ -1,33 +0,0 @@ -import Logger from 'electron-log' -import { execa } from 'execa' -import { memo } from 'radash' - -const useSystem = memo(function useSystem() { - async function powerOff(interactive = false) { - const args = interactive ? ['boolean:true'] : ['boolean:false'] - const params = [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - ...args - ] - - Logger.debug('execa:dbus-send:params', ...params) - const { stdout, stderr, exitCode } = await execa('dbus-send', params) - if (exitCode !== 0) { - throw new Error(stderr) - } - - if (stdout) { - Logger.debug('execa:dbus-send:output', stdout) - } - } - - return { - powerOff - } -}) - -export default useSystem diff --git a/src/main/services/updater.ts b/src/main/services/updater.ts deleted file mode 100644 index a024f83..0000000 --- a/src/main/services/updater.ts +++ /dev/null @@ -1,163 +0,0 @@ -import EventEmitter from 'node:events' -import { writeFile } from 'node:fs/promises' -import { resolve as resolvePath } from 'node:path' -import autoBind from 'auto-bind' -import { app } from 'electron' -import { autoUpdater } from 'electron-updater' -import { memo } from 'radash' -import { logError } from '../utilities' -import type { UpdateCheckResult, ProgressInfo, CancellationToken, UpdateInfo } from 'electron-updater' -import { isNodeError } from '@/error-handling' - -export type { UpdateInfo, ProgressInfo } from 'electron-updater' - -export interface AppUpdaterEventMap { - checking: [] - available: [info: UpdateInfo | null] - progress: [progress: ProgressInfo] - downloaded: [info: UpdateInfo] - cancelled: [] -} - -export type AppUpdater = ReturnType - -const useUpdater = memo(function useUpdater() { - /** The internal application updater for AppImage. */ - autoUpdater.autoDownload = false - autoUpdater.autoInstallOnAppQuit = false - autoUpdater.forceDevUpdateConfig = true - // FIXME: Find some way to prevent if from logging - // errors that are caught and handled. - // autoUpdater.logger = Logger - - /** - * Application auto update. - * - * We are using a class for EventEmitter's sake. - */ - class AppUpdater extends EventEmitter { - #checkPromise: Promise | undefined = undefined - #cancelToken: CancellationToken | undefined = undefined - #downloadPromise: Promise | undefined = undefined - - constructor() { - super() - autoUpdater.on('checking-for-update', () => { - this.emit('checking') - }) - autoUpdater.on('update-available', (info) => { - this.emit('available', info) - }) - autoUpdater.on('update-not-available', () => { - this.emit('available', null) - }) - autoUpdater.on('update-downloaded', (info) => { - this.emit('downloaded', info) - }) - autoUpdater.on('update-cancelled', () => { - this.emit('cancelled') - }) - } - - private async getUpdateInfo() { - if (this.#checkPromise == null) { - throw logError(new ReferenceError('Cannot get update information, no check in progress')) - } - - let result - try { - result = await this.#checkPromise - } catch (cause) { - if (isNodeError(cause) && cause.code === 'ENOENT') { - return null - } - - throw cause - } - - // If the result is null or the cancel token is null, no update is available. - if (result?.cancellationToken == null) { - return null - } - - this.#cancelToken = result.cancellationToken - - return result.updateInfo - } - - private async attemptCheckForUpdates() { - if (this.#downloadPromise != null) { - throw logError(new ReferenceError('Update download already in progress')) - } - - try { - if (import.meta.env.DEV) { - // Force update check on for testing. - const installPath = resolvePath(app.getAppPath(), 'dist', 'BridgeCmdr') - await writeFile(installPath, '') - process.env['APPIMAGE'] = installPath - } - - this.#checkPromise = autoUpdater.checkForUpdates() - - return await this.getUpdateInfo() - } finally { - this.#checkPromise = undefined - } - } - - async checkForUpdates() { - if (this.#checkPromise != null) { - return await this.getUpdateInfo() - } - - return await this.attemptCheckForUpdates() - } - - private async attemptDownloadUpdate() { - if (this.#cancelToken == null) { - throw logError(new ReferenceError('No update available for download, check first')) - } - - const handleProgress = (progress: ProgressInfo) => { - this.emit('progress', progress) - } - try { - autoUpdater.on('download-progress', handleProgress) - this.#downloadPromise = autoUpdater.downloadUpdate(this.#cancelToken) - await this.#downloadPromise - } finally { - autoUpdater.off('download-progress', handleProgress) - this.#cancelToken = undefined - } - } - - async downloadUpdate() { - if (this.#downloadPromise != null) { - await this.#downloadPromise - } else { - await this.attemptDownloadUpdate() - } - } - - async cancelUpdate() { - if (this.#cancelToken == null || this.#cancelToken.cancelled) { - return - } - - this.#cancelToken.cancel() - - await Promise.resolve() - } - - async installUpdate() { - autoUpdater.quitAndInstall() - - await Promise.resolve() - } - } - - return autoBind(new AppUpdater()) -}) - -export default useUpdater diff --git a/src/main/services/user.ts b/src/main/services/user.ts deleted file mode 100644 index 1af19e5..0000000 --- a/src/main/services/user.ts +++ /dev/null @@ -1,13 +0,0 @@ -import os from 'node:os' -import { app } from 'electron' -import { memo } from 'radash' - -/** Basic user information. */ -export type UserInfo = ReturnType - -const useUserInfo = memo(() => ({ - name: os.userInfo().username, - locale: app.getLocale() -})) - -export default useUserInfo diff --git a/src/main/utilities.ts b/src/main/utilities.ts deleted file mode 100644 index c7ed711..0000000 --- a/src/main/utilities.ts +++ /dev/null @@ -1,6 +0,0 @@ -import Logger from 'electron-log' - -export function logError(e: E) { - Logger.error(e) - return e -} diff --git a/src/preload/api.d.ts b/src/preload/api.d.ts deleted file mode 100644 index 4ad82a5..0000000 --- a/src/preload/api.d.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AppConfig } from '../main/info/config' -import type { RpcInterface } from './index' - -// -// Exposed via tRPC -// - -export type { AppRouter } from '../main/routes/router' -export type { AppInfo } from '../main/services/app' -export type { UserInfo } from '../main/services/user' -export type { DocumentId } from '../main/services/database' -export type { ApiLocales } from '../main/services/locale' -export type { PortEntry } from '../main/services/ports' -export type { UpdateInfo, ProgressInfo } from '../main/services/updater' - -export type { Source, NewSource, SourceUpdate, SourceUpsert } from '../main/dao/sources' -export type { Device, NewDevice, DeviceUpdate, DeviceUpsert } from '../main/dao/devices' -export type { Tie, NewTie, TieUpdate, TieUpsert } from '../main/dao/ties' -export type { ApiLocales } from '../main/locale' - -export type { - DriverKind, - DriverBindings, - DriverInformation, - DriverBasicInformation, - LocalizedDriverInformation, - // Cannot be exported as values, but they are literals. - kDeviceCanDecoupleAudioOutput, - kDeviceHasNoExtraCapabilities, - kDeviceSupportsMultipleOutputs -} from '../main/services/drivers' - -declare global { - var rpc: RpcInterface -} diff --git a/src/preload/index.ts b/src/preload/index.ts deleted file mode 100644 index 3100738..0000000 --- a/src/preload/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron' -import type { TRPCClientIncomingMessage, TRPCClientOutgoingMessage } from '@trpc/server/rpc' -import { theRpcChannel } from '@/rpc/ipc' - -/* eslint-disable n/no-process-exit -- No real way to do this otherwise */ - -export type RpcInterface = ReturnType - -function exposeRpc() { - const rpc = { - sendMessage(operation: TRPCClientOutgoingMessage) { - ipcRenderer.send(theRpcChannel, operation) - }, - onMessage(callback: (args: TRPCClientIncomingMessage) => void) { - ipcRenderer.on(theRpcChannel, (_, args: TRPCClientIncomingMessage) => { - callback(args) - }) - } - } - - contextBridge.exposeInMainWorld('rpc', rpc) - return rpc -} - -exposeRpc() - -if (!process.contextIsolated) { - console.error('Context isolation is not enabled') - process.exit(1) -} diff --git a/src/renderer/BridgeCmdr.vue b/src/renderer/BridgeCmdr.vue deleted file mode 100644 index 38f28ea..0000000 --- a/src/renderer/BridgeCmdr.vue +++ /dev/null @@ -1,141 +0,0 @@ - - - - - -en: - message: - confirmUpdate: Do you want to update {0}? - versionAvailable: The new version, {version}, is available for download. - progress: Downloaded {amount} of {total} at {speed}... - updateReady: Download complete, ready to restart and update - action: - update: Update - later: Maybe later - diff --git a/src/renderer/assets/main.scss b/src/renderer/assets/main.scss deleted file mode 100644 index 4b5c311..0000000 --- a/src/renderer/assets/main.scss +++ /dev/null @@ -1,72 +0,0 @@ -@use 'sass:string'; -@use 'sass:map'; -@use 'vuetify/settings' as settings; -@use 'vuetify/tools' as tools; - -$widths: 0 5 10 20 25 30 40 50 60 70 75 80 90 100; -$widthspx: 50 75 100 125 150 175 200 250 300 400 500 600 700 800 900; - -@each $breakpoint in map.keys(settings.$grid-breakpoints) { - @include tools.media-breakpoint-up($breakpoint) { - @each $width in $widths { - .minw-#{$breakpoint}-#{$width} { - min-width: string.unquote('#{$width}%') !important; - } - .maxw-#{$breakpoint}-#{$width} { - max-width: string.unquote('#{$width}%') !important; - } - .w-#{$breakpoint}-#{$width} { - max-width: string.unquote('#{$width}%') !important; - } - } - @each $width in $widthspx { - .minw-#{$breakpoint}-#{$width}px { - min-width: string.unquote('#{$width}px') !important; - } - .maxw-#{$breakpoint}-#{$width}px { - max-width: string.unquote('#{$width}px') !important; - } - .w-#{$breakpoint}-#{$width}px { - width: string.unquote('#{$width}px') !important; - } - } - .colg-#{$breakpoint} { - column-gap: map.get(settings.$grid-gutters, $breakpoint); - } - .rowg-#{$breakpoint} { - row-gap: map.get(settings.$grid-gutters, $breakpoint); - } - } -} - -@each $width in $widths { - .minw-#{$width} { - min-width: string.unquote('#{$width}%') !important; - } - .maxw-#{$width} { - max-width: string.unquote('#{$width}%') !important; - } - .w-#{$width} { - width: string.unquote('#{$width}%') !important; - } -} - -@each $width in $widthspx { - .minw-#{$width}px { - min-width: string.unquote('#{$width}px') !important; - } - .maxw-#{$width}px { - max-width: string.unquote('#{$width}px') !important; - } - .w-#{$width}px { - width: string.unquote('#{$width}px') !important; - } -} - -.colg { - column-gap: settings.$grid-gutter; -} - -.rowg { - row-gap: settings.$grid-gutter; -} diff --git a/src/renderer/components.d.ts b/src/renderer/components.d.ts deleted file mode 100644 index a5e6dbe..0000000 --- a/src/renderer/components.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Minimal file that seem to make Volar happy to see -// global components from third-party libraries. -import type * as Vuetify from 'vuetify/components' -declare module '@vue/runtime-core' { - export interface GlobalComponents {} -} diff --git a/src/renderer/components/DropMenu.vue b/src/renderer/components/DropMenu.vue deleted file mode 100644 index 93890e3..0000000 --- a/src/renderer/components/DropMenu.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/src/renderer/components/Highlight.tsx b/src/renderer/components/Highlight.tsx deleted file mode 100644 index 0d3b623..0000000 --- a/src/renderer/components/Highlight.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Comment, h, Text } from 'vue' - -interface HighlightProps { - text?: string - search?: string -} - -export default function Highlight({ text, search }: HighlightProps) { - if (!text) return h(Comment, 'Nothing') - if (!search) return h(Text, text) - const start = text.indexOf(search) - if (start === -1) return h(Text, text) - const end = start + search.length - - return ( - <> - {text.substring(0, start)} - {text.substring(start, end)} - {text.substring(end)} - - ) -} diff --git a/src/renderer/components/InputDialog.vue b/src/renderer/components/InputDialog.vue deleted file mode 100644 index b9a9feb..0000000 --- a/src/renderer/components/InputDialog.vue +++ /dev/null @@ -1,149 +0,0 @@ - - - diff --git a/src/renderer/components/NumberInput.vue b/src/renderer/components/NumberInput.vue deleted file mode 100644 index 77f7234..0000000 --- a/src/renderer/components/NumberInput.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - - diff --git a/src/renderer/components/OptionDialog.vue b/src/renderer/components/OptionDialog.vue deleted file mode 100644 index d02848a..0000000 --- a/src/renderer/components/OptionDialog.vue +++ /dev/null @@ -1,230 +0,0 @@ - - - diff --git a/src/renderer/components/Page.vue b/src/renderer/components/Page.vue deleted file mode 100644 index 1b029e1..0000000 --- a/src/renderer/components/Page.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/renderer/components/ReplaceableImage.vue b/src/renderer/components/ReplaceableImage.vue deleted file mode 100644 index 75445ef..0000000 --- a/src/renderer/components/ReplaceableImage.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - -en: - action: - select: Select new image - label: - select: Select image - filter: - all: Support images - svg: SVG images - png: PNG images - gif: GIF images - jpg: JPEG Images - diff --git a/src/renderer/hooks/assets.ts b/src/renderer/hooks/assets.ts deleted file mode 100644 index aea0873..0000000 --- a/src/renderer/hooks/assets.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { tryOnScopeDispose } from '@vueuse/core' -import { computed, readonly, ref, toValue, unref, watch } from 'vue' -import type { MaybeRefOrGetter } from 'vue' - -/* eslint-disable n/no-unsupported-features/node-builtins -- In browser environment. */ - -export function useImages() { - const images = ref<(string | undefined)[]>([]) - - const unloadImages = () => { - for (const image of images.value) { - if (image != null) { - URL.revokeObjectURL(image) - } - } - } - - function loadImages(files: (File | undefined)[]) { - unloadImages() - images.value = files.map((file) => (file != null ? URL.createObjectURL(file) : undefined)) - } - - tryOnScopeDispose(unloadImages) - - return { images, loadImages } -} - -export function useObjectUrls(sources: MaybeRefOrGetter<(Blob | MediaSource | undefined)[]>) { - const urls = ref<(string | undefined)[]>([]) - function release() { - for (const url of urls.value) { - if (url != null) { - URL.revokeObjectURL(url) - } - } - - urls.value = [] - } - - watch( - () => unref(sources), - function handleSourceChange(files) { - release() - for (const file of toValue(files)) { - urls.value.push(file != null ? URL.createObjectURL(file) : undefined) - } - }, - { immediate: true, deep: true } - ) - - tryOnScopeDispose(release) - - return computed(() => readonly(urls.value)) -} diff --git a/src/renderer/hooks/element.ts b/src/renderer/hooks/element.ts deleted file mode 100644 index 96e2a45..0000000 --- a/src/renderer/hooks/element.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useScroll, useResizeObserver } from '@vueuse/core' -import { toValue, watch, computed, ref } from 'vue' -import type { MaybeComputedElementRef } from '@vueuse/core' - -interface ScrollingBoundsEntry { - left: number - top: number - width: number - height: number -} -interface ScrollingBounds { - client: ScrollingBoundsEntry - scrolling: ScrollingBoundsEntry -} - -export function useElementScrollingBounds(el: MaybeComputedElementRef) { - const scrolling = ref({ - client: { left: 0, top: 0, width: 0, height: 0 }, - scrolling: { left: 0, top: 0, width: 0, height: 0 } - }) - - useResizeObserver(el, function handleResize(entries) { - for (const entry of entries) { - const { target } = entry - const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = target - const { clientLeft, clientTop, clientWidth, clientHeight } = target - scrolling.value = { - client: { left: scrollLeft, top: scrollTop, width: scrollWidth, height: scrollHeight }, - scrolling: { left: clientLeft, top: clientTop, width: clientWidth, height: clientHeight } - } - } - }) - - const $el = computed(function findElement() { - const target = toValue(el) - - if (target == null) { - return target - } - - if (target instanceof HTMLElement || target instanceof SVGElement) { - return target - } - - return target.$el as HTMLElement | null | undefined - }) - - const scroll = useScroll($el) - watch([scroll.x, scroll.y], function handleScroll(value) { - scrolling.value.client.left = value[0] - scrolling.value.client.top = value[1] - }) - - return scrolling -} diff --git a/src/renderer/hooks/errors.ts b/src/renderer/hooks/errors.ts deleted file mode 100644 index ecdf9b7..0000000 --- a/src/renderer/hooks/errors.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useI18n } from 'vue-i18n' -import { z } from 'zod' -import type { I18nSchema } from '../locales/locales' -import { getMessage, getZodMessage } from '@/error-handling' - -interface ErrorMessage { - header?: string | undefined - message: string -} - -export function useErrors() { - const { t } = useI18n() - - function toErrorMessage(error: unknown): ErrorMessage { - // Any value that is not an error message may - // only be used as the message body. - if (!(error instanceof Error)) { - return { message: getMessage(error) } - } - - // Zod errors may only be used as the message - // body. They may be too verbose for the - // title. - if (error instanceof z.ZodError) { - return { message: getZodMessage(error) ?? t('error.zod') } - } - - // If the error does not have a cause, - // it can only be the message body. - if (error.cause == null) { - return { message: error.message } - } - - // Any non-nullish value that is not an error - // can be used as the message body, so this - // error may be used as the header. - if (!(error.cause instanceof Error)) { - return { header: error.message, message: getMessage(error.cause) } - } - - // Zod errors may be used as the message body - // and this one as the header, but only if - // a message can be extracted from the - // Zod error; otherwise, this error - // will be the message body. - if (error.cause instanceof z.ZodError) { - const message = getZodMessage(error.cause) - return message != null ? { header: error.message, message } : { message: error.message } - } - - return { header: error.message, message: error.cause.message } - } - - return { - toErrorMessage - } -} diff --git a/src/renderer/hooks/location.ts b/src/renderer/hooks/location.ts deleted file mode 100644 index c0ee473..0000000 --- a/src/renderer/hooks/location.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { helpers } from '@vuelidate/validators' -import { toValue, computed, readonly } from 'vue' -import { useI18n } from 'vue-i18n' -import type { I18nSchema } from '../locales/locales' -import type { PortEntry } from '../services/ports' -import type { LocationType } from '@/location' -import type { MessageProps } from '@vuelidate/validators' -import type { MaybeRefOrGetter, Ref } from 'vue' -import { isValidLocation } from '@/location' - -export function useLocationUtils(validPorts: MaybeRefOrGetter) { - const { t } = useI18n() - - function locMsg({ $model }: MessageProps) { - const path = String($model) - switch (true) { - case path.startsWith('port:'): - return t('validations.location.port') - case path.startsWith('ip:'): - return t('validations.location.ip') - default: - return t('validations.location.invalid') - } - } - - const locationPath = helpers.withMessage( - locMsg, - // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- Can't be help, not well typed. - (value: unknown) => !(helpers.req(value) as boolean) || isValidLocation(String(value), toValue(validPorts)) - ) - - return { - locationPath - } -} - -interface LocationTypeMetaData { - title: string - value: LocationType -} - -export default function useLocation( - location: Ref, - validPorts: MaybeRefOrGetter -) { - const { t } = useI18n() - const { locationPath } = useLocationUtils(validPorts) - - const pathTypes = readonly([ - { title: t('label.remote'), value: 'ip' }, - { title: t('label.port'), value: 'port' } - ] satisfies LocationTypeMetaData[]) - - const pathType = computed({ - get: () => { - switch (true) { - case location.value?.startsWith('port:'): - return 'port' - case location.value?.startsWith('ip:'): - return 'ip' - default: - return undefined - } - }, - set: (value) => { - location.value = value != null ? (location.value = `${value}:${path.value}`) : undefined - } - }) - - const pathLabel = computed(() => { - switch (true) { - case pathType.value === 'port': - return t('label.port') - case pathType.value === 'ip': - return t('label.remote') - default: - return undefined - } - }) - - const path = computed({ - get: () => { - switch (pathType.value) { - case 'port': - return location.value?.substring(5) - case 'ip': - return location.value?.substring(3) - default: - return undefined - } - }, - set: (value) => { - location.value = pathType.value != null ? (location.value = `${pathType.value}:${value}`) : undefined - } - }) - - return { - locationPath, - pathTypes, - pathType, - pathLabel, - path - } -} diff --git a/src/renderer/hooks/tracking.ts b/src/renderer/hooks/tracking.ts deleted file mode 100644 index 420b791..0000000 --- a/src/renderer/hooks/tracking.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { toValue, computed, ref, shallowRef } from 'vue' -import type { MaybeRefOrGetter } from 'vue' -import { toError } from '@/error-handling' - -type Trackable = (() => PromiseLike) | (() => Promise) | (() => T) | PromiseLike | Promise - -export function trackBusy(...chain: MaybeRefOrGetter[]) { - /** The busy weight. */ - const weight = ref(0) - - /** The last error. */ - const error = shallowRef() - - /** Tracks an asynchronous operation or promise, return its result. */ - async function wait(trackable: Trackable) { - try { - ++weight.value - const result = await (typeof trackable === 'function' ? trackable() : trackable) - error.value = undefined - - return result - } catch (e) { - error.value = toError(e) - - throw e - } finally { - --weight.value - } - } - - /** Wraps an asynchronous operation. */ - function track(cb: (...args: Args) => PromiseLike) { - return async (...args: Args) => await wait(() => cb(...args)) - } - - return { - isBusy: computed(() => chain.some((r) => toValue(r)) || weight.value > 0), - error: computed(() => error.value), - wait, - track - } -} - -export type Tracker = ReturnType diff --git a/src/renderer/hooks/utilities.ts b/src/renderer/hooks/utilities.ts deleted file mode 100644 index a4dd383..0000000 --- a/src/renderer/hooks/utilities.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useDialogs } from '../modals/dialogs' - -export const forceUndefined = () => undefined as T - -export function useGuardedAsyncOp(fn: () => Promise) { - const dialogs = useDialogs() - - return async () => { - await fn().catch(async (e: unknown) => { - await dialogs.error(e) - }) - } -} diff --git a/src/renderer/hooks/validation.ts b/src/renderer/hooks/validation.ts deleted file mode 100644 index 68c1ec4..0000000 --- a/src/renderer/hooks/validation.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { useVuelidate } from '@vuelidate/core' -import { createI18nMessage, helpers } from '@vuelidate/validators' -import * as builtInRules from '@vuelidate/validators' -import { unref, computed, reactive, ref } from 'vue' -import { useI18n } from 'vue-i18n' -import type { I18nSchema } from '../locales/locales' -import type { - ValidationRuleWithoutParams, - BaseValidation, - GlobalConfig, - ValidationArgs, - ValidationRuleCollection -} from '@vuelidate/core' -import type { Ref, ToRefs } from 'vue' - -export interface ValidationStatus { - readonly errorMessages?: string | string[] - readonly messages?: string | string[] - readonly color?: string -} - -interface ValidationCacheEntry { - readonly status: ValidationStatus | undefined - readonly class: string | undefined -} - -type RuleCollection = ValidationRuleCollection | undefined - -export function useValidation< - SArgs extends unknown[], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Matching Vuelidate. - SRoot extends { [key in keyof VArgs]: any }, - VArgs extends ValidationArgs = ValidationArgs ->( - validationsArgs: Ref | VArgs, - state: SRoot | Ref | ToRefs, - submit: (...args: SArgs) => unknown, - globalConfig?: GlobalConfig -) { - const config = { ...(globalConfig ?? {}) } satisfies GlobalConfig - const v$ = unref(useVuelidate(validationsArgs, state, config)) - - const cache = reactive(new Map()) - const ready = ref(false) - - const reset = () => { - v$.$reset() - ready.value = false - cache.clear() - } - - const touch = () => { - v$.$touch() - ready.value = true - cache.clear() - } - - function checkNestedDirty(validation: BaseValidation) { - if (validation.$dirty) { - return true - } - - for (const [key, value] of Object.entries(validation)) { - if ( - !key.startsWith('$') && - typeof value === 'object' && - value != null && - '$dirty' in value && - checkNestedDirty(value as BaseValidation) - ) { - return true - } - } - - return false - } - - const dirty = computed(() => checkNestedDirty(v$)) - - function getEntry>(validator: BaseValidation) { - let entry = cache.get(validator.$path) - if (entry != null) { - return entry - } - - entry = - validator.$error && ready.value - ? { - status: { - errorMessages: validator.$errors.map((error) => unref(error.$message)), - color: 'error' - }, - class: 'error--text' - } - : { - status: undefined, - class: undefined - } - - cache.set(validator.$path, entry) - - return entry - } - - function getMessages>(validator: BaseValidation) { - return getEntry(validator).status?.errorMessages - } - - function getClass>(validator: BaseValidation) { - return getEntry(validator).class - } - - function getColor>(validator: BaseValidation) { - return getEntry(validator).status?.color - } - - function getStatus>(validator: BaseValidation) { - return getEntry(validator).status - } - - async function handleCall(fn: () => unknown) { - touch() - - const good = await v$.$validate() - if (!good) { - return - } - - await fn() - } - - function guardCall(fn: (...args: Args) => unknown) { - return async (...args: Args) => { - await handleCall(() => fn(...args)) - } - } - - return { - dirty, - getMessages, - getClass, - getColor, - getStatus, - handleCall, - guardCall, - submit: guardCall(submit), - reset, - v$ - } -} - -export function useRules() { - // - // i18n - // - const { t } = useI18n() - const withI18nMessage = createI18nMessage({ t }) - - // - // Built-in Vuelidate rules - // - const required = withI18nMessage(builtInRules.required) - const requiredIf = withI18nMessage(builtInRules.requiredIf, { withArguments: true }) - const requiredUnless = withI18nMessage(builtInRules.requiredIf, { withArguments: true }) - - const alpha = withI18nMessage(builtInRules.alpha) - const alphaNum = withI18nMessage(builtInRules.alphaNum) - const numeric = withI18nMessage(builtInRules.numeric) - const integer = withI18nMessage(builtInRules.integer) - const decimal = withI18nMessage(builtInRules.decimal) - const email = withI18nMessage(builtInRules.email) - const ipAddress = withI18nMessage(builtInRules.ipAddress) - const url = withI18nMessage(builtInRules.url) - - const minLength = withI18nMessage(builtInRules.minLength, { withArguments: true }) - const maxLength = withI18nMessage(builtInRules.maxLength, { withArguments: true }) - const minValue = withI18nMessage(builtInRules.minValue, { withArguments: true }) - const maxValue = withI18nMessage(builtInRules.maxValue, { withArguments: true }) - const between = withI18nMessage(builtInRules.between, { withArguments: true }) - - const { or, and, not } = builtInRules - - // - // Custom rules - // - const uuid = withI18nMessage( - // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- Can't be help, not well typed. - helpers.regex(/^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/u), - { messagePath: () => 'validations.uuid' } - ) as ValidationRuleWithoutParams - - return { - // Built-in rules - required, - requiredIf: (...args: Parameters) => ({ requiredIf: requiredIf(...args) }), - requiredUnless: (...args: Parameters) => ({ - requiredUnless: requiredUnless(...args) - }), - alpha, - alphaNum, - numeric, - integer, - decimal, - email, - ipAddress, - url, - minLength: (...args: Parameters) => ({ - minLength: withI18nMessage(minLength(...args), { withArguments: true }) - }), - maxLength: (...args: Parameters) => ({ - maxLength: withI18nMessage(maxLength(...args), { withArguments: true }) - }), - minValue: (...args: Parameters) => ({ - minValue: withI18nMessage(minValue(...args), { withArguments: true }) - }), - maxValue: (...args: Parameters) => ({ - maxValue: withI18nMessage(maxValue(...args), { withArguments: true }) - }), - between: (...args: Parameters) => ({ - between: withI18nMessage(between(...args), { withArguments: true }) - }), - or: (...args: Parameters) => ({ or: or(...args) }), - and: (...args: Parameters) => ({ and: and(...args) }), - not: (...args: Parameters) => ({ not: not(...args) }), - // Our rules - uuid - } -} diff --git a/src/renderer/hooks/vue.ts b/src/renderer/hooks/vue.ts deleted file mode 100644 index a2a4b9b..0000000 --- a/src/renderer/hooks/vue.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { nextTick } from 'vue' -import { useDialogs } from '../modals/dialogs' - -export function useNextTick(fn: () => unknown) { - const dialogs = useDialogs() - - return () => { - nextTick() - .then(fn) - .catch(async (e: unknown) => { - await dialogs.error(e) - }) - } -} diff --git a/src/renderer/hooks/vuetify.ts b/src/renderer/hooks/vuetify.ts deleted file mode 100644 index 0278bff..0000000 --- a/src/renderer/hooks/vuetify.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { toValue, computed, ref } from 'vue' -import { z } from 'zod' -import { useElementScrollingBounds } from './element' -import type { MaybeRefOrGetter, ComponentPublicInstance } from 'vue' - -const Block = ['top', 'bottom'] as const -type Block = (typeof Block)[number] - -const Inline = ['start', 'end', 'left', 'right'] as const -type Inline = (typeof Inline)[number] - -export type Anchor = - | Block - | Inline - | 'center' - | 'center center' - | `${Block} ${Inline | 'center'}` - | `${Inline} ${Block | 'center'}` - -export type Origin = 'auto' | Anchor | 'overlap' - -export type SelectItemKey = - | boolean // Ignored - | string // Lookup by key, can use dot notation for nested objects - | (string | number)[] // Nested lookup by key, each array item is a key in the next level - | ((item: Record, fallback?: unknown) => unknown) - -export type ResponsiveModalOptions = z.input -export const ResponsiveModalOptions = z.object({ - rounded: z.union([z.boolean(), z.string(), z.number()]).default('xl'), - scrim: z.union([z.boolean(), z.string()]).default(true), - width: z.number().optional(), - height: z.number().optional(), - minWidth: z.number().optional(), - minHeight: z.number().optional(), - maxWidth: z.number().optional(), - maxHeight: z.number().optional() -}) - -const DialogProps = ResponsiveModalOptions.pick({ - scrim: true, - width: true, - height: true, - minWidth: true, - minHeight: true, - maxWidth: true, - maxHeight: true -}) - -const CardProps = ResponsiveModalOptions.pick({ - rounded: true -}) - -export function useResponsiveModal( - breakpoint: MaybeRefOrGetter, - options: MaybeRefOrGetter = {} -) { - const dialogProps = computed(() => - toValue(breakpoint) - ? { fullscreen: true, scrim: false, scrollable: true } - : { ...DialogProps.parse(toValue(options)), scrollable: true } - ) - - const cardProps = computed(() => - toValue(breakpoint) ? { rounded: false } : { ...CardProps.parse(toValue(options)) } - ) - - const isFullscreen = computed(() => toValue(breakpoint)) - - const body = ref(null) - const scrolling = useElementScrollingBounds(body) - const showDividers = computed(() => scrolling.value.client.height !== scrolling.value.scrolling.height) - - return { - body, - dialogProps, - cardProps, - isFullscreen, - showDividers - } -} diff --git a/src/renderer/index.html b/src/renderer/index.html deleted file mode 100644 index b91666f..0000000 --- a/src/renderer/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - BridgeCmdr - - -
- - - diff --git a/src/renderer/index.ts b/src/renderer/index.ts deleted file mode 100644 index 56ec1d0..0000000 --- a/src/renderer/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import log from 'electron-log' - -// -// Earliest to enable logger. -// - -if (import.meta.env.PROD) { - log.errorHandler.startCatching() -} - -// -// Dynamic load the main module. -// - -async function main() { - await import('./main') -} - -main().catch((e: unknown) => { - console.error('Fatal application boot failure', e) -}) diff --git a/src/renderer/locales/en/datetimes.ts b/src/renderer/locales/en/datetimes.ts deleted file mode 100644 index f6b75fb..0000000 --- a/src/renderer/locales/en/datetimes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { IntlDateTimeFormat } from 'vue-i18n' - -export default {} satisfies IntlDateTimeFormat diff --git a/src/renderer/locales/en/functions.ts b/src/renderer/locales/en/functions.ts deleted file mode 100644 index 83c9443..0000000 --- a/src/renderer/locales/en/functions.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MessageContext } from '@intlify/core-base' - -// HACK: Very likely Vuelidate doesn't pass the plural/count, so Vue i18n plural won't work. -// Must match, or be, the pluralization rule/handler for the current locale. -const pluralUsing = (messages: [T, T, ...T[]], plural: number) => { - if (messages[2] == null) { - return plural === 1 ? messages[0] : messages[1] - } - - if (plural === 0) { - return messages[0] - } - - return plural === 1 ? messages[1] : messages[2] -} - -const en = { - validations: { - // eslint-disable-next-line @typescript-eslint/unbound-method -- Not a concern. - minLength: ({ named }: MessageContext) => { - const min = Number(named('min')) - - return typeof named('model') === 'string' - ? pluralUsing(['The value must not be empty', `The value must at least ${min} characters long`], min) - : pluralUsing(['The value must not be empty', `The value must have at least ${min} entries`], min) - }, - // eslint-disable-next-line @typescript-eslint/unbound-method -- Not a concern. - maxLength: ({ named }: MessageContext) => { - const max = Number(named('max')) - - return typeof named('model') === 'string' - ? pluralUsing( - [ - 'The value must be empty', - 'The value cannot be more than one character long', - `The value cannot be more than ${max} characters long` - ], - max - ) - : pluralUsing( - [ - 'The value must be empty', - 'The value cannot have more than one entry', - `The value cannot have more than ${max} entries` - ], - max - ) - } - } -} - -export default en diff --git a/src/renderer/locales/en/messages.json b/src/renderer/locales/en/messages.json deleted file mode 100644 index 51c02cb..0000000 --- a/src/renderer/locales/en/messages.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "driver": { - "provider": "BridgeCmdr contributors" - }, - "action": { - "add": "Add", - "delete": "Delete", - "discard": "Discard", - "edit": "Edit", - "keep": "Keep", - "save": "Save" - }, - "common": { - "cancel": "Cancel", - "confirm": "OK", - "no": "No", - "yes": "Yes" - }, - "error": { - "zod": "Validation failed" - }, - "label": { - "backup": "Backup settings", - "driver": "Driver", - "general": "General", - "image": "Image", - "loading": "Loading...", - "monitor": "Monitor", - "monitors": "Monitors", - "name": "Name", - "port": "Port", - "remote": "Remote host", - "settings": "Settings", - "source": "Source", - "sources": "Sources", - "switch": "Switch", - "switches": "Switches", - "switchesAndMonitors": "Switches and Monitors", - "switchOrMonitor": "Switch or monitor", - "tie": "Tie", - "ties": "Ties", - "type": "Type" - }, - "message": { - "discardChanges": "Do you want to discard these changes?" - }, - "placeholder": { - "optional": "optional", - "required": "required" - }, - "validations": { - "required": "A value is required", - "requiredIf": "A value is required", - "alpha": "The value must be alphabetic", - "alphaNum": "The value must be a mix of alphabetic or numeric", - "numeric": "The value must be numeric", - "integer": "The value must be an integer", - "decimal": "The value must be a number", - "email": "The value must be an e-mail address", - "ipAddress": "The value must be an IP address", - "url": "The value must be a URL", - "minLength": "The value must have at least {min} entries", - "maxLength": "The value cannot have more than {max} entries", - "minValue": "The value must be {min} or more", - "maxValue": "The value must be {max} or more", - "between": "The value must be between {min} and {max}", - "uuid": "The value must be a UUID", - "locationPath": "This must be a valid location path", - "location": { - "port": "This must be a valid port", - "ip": "This must be a host or IP address, and optionally port", - "invalid": "This must be a valid location" - } - } -} diff --git a/src/renderer/locales/en/numbers.ts b/src/renderer/locales/en/numbers.ts deleted file mode 100644 index cfdb4f9..0000000 --- a/src/renderer/locales/en/numbers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IntlNumberFormat } from 'vue-i18n' - -export default { - integer: { - style: 'decimal', - minimumIntegerDigits: 1, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - useGrouping: true - } -} satisfies IntlNumberFormat diff --git a/src/renderer/locales/locales.ts b/src/renderer/locales/locales.ts deleted file mode 100644 index 309505d..0000000 --- a/src/renderer/locales/locales.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type enDateTimes from './en/datetimes' -import type en from './en/messages.json' -import type enNumbers from './en/numbers' -import type { DefineDateTimeFormatSchema, DefineMessageSchema, DefineNumberFormatSchema } from '../support/i18n' - -export type Locales = 'en' -export type MessageSchema = DefineMessageSchema -export type NumberSchema = DefineNumberFormatSchema -export type DateTimeSchema = DefineDateTimeFormatSchema -export interface I18nSchema { - message: MessageSchema - datetime: DateTimeSchema - number: NumberSchema -} diff --git a/src/renderer/main.ts b/src/renderer/main.ts deleted file mode 100644 index 7025005..0000000 --- a/src/renderer/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { createPinia } from 'pinia' -import { createApp } from 'vue' -import BridgeCmdr from './BridgeCmdr.vue' -import i18n from './plugins/i18n' -import router from './plugins/router' -import vuetify from './plugins/vuetify' - -import './assets/main.scss' - -export const app = createApp(BridgeCmdr) - -app.use(createPinia()) -app.use(vuetify) -app.use(router) -app.use(i18n) - -app.mount('#root') diff --git a/src/renderer/modals/AlertModal.vue b/src/renderer/modals/AlertModal.vue deleted file mode 100644 index 23307d8..0000000 --- a/src/renderer/modals/AlertModal.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/src/renderer/modals/ConfirmModal.vue b/src/renderer/modals/ConfirmModal.vue deleted file mode 100644 index 25aa802..0000000 --- a/src/renderer/modals/ConfirmModal.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/renderer/modals/DeviceDialog.vue b/src/renderer/modals/DeviceDialog.vue deleted file mode 100644 index 4af9498..0000000 --- a/src/renderer/modals/DeviceDialog.vue +++ /dev/null @@ -1,197 +0,0 @@ - - - - - -en: - label: - monitor: monitor - switch: switch - addDevice: Add {0} - editDevice: Edit {0} - experimental: Experimental driver - message: - discardNew: Do you want to discard this {0}? - diff --git a/src/renderer/modals/SourceDialog.vue b/src/renderer/modals/SourceDialog.vue deleted file mode 100644 index 0e14524..0000000 --- a/src/renderer/modals/SourceDialog.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - -en: - title: Add source - message: - discardNew: Do you want to discard this source? - diff --git a/src/renderer/modals/TieDialog.vue b/src/renderer/modals/TieDialog.vue deleted file mode 100644 index 323ceda..0000000 --- a/src/renderer/modals/TieDialog.vue +++ /dev/null @@ -1,224 +0,0 @@ - - - - - -en: - label: - addTie: Add tie - editTie: Edit tie - inputChannel: Input channel - outputChannel: Output channel - audioChannel: Output audio channel - sync: Synchronize audio and video channels - message: - discardNew: Do you want to discard this tie? - diff --git a/src/renderer/modals/dialogs.ts b/src/renderer/modals/dialogs.ts deleted file mode 100644 index 2b77dac..0000000 --- a/src/renderer/modals/dialogs.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createSharedComposable, useConfirmDialog } from '@vueuse/core' -import { computed } from 'vue' -import { useDisplay } from 'vuetify' -import { z } from 'zod' -import { useErrors } from '../hooks/errors' -import { useResponsiveModal } from '../hooks/vuetify' - -export const AlertModalOptions = z - .string() - .min(1) - .or( - z.object({ - title: z.string().min(1).optional(), - message: z.string().min(1), - button: z.string().min(1).optional(), - color: z.string().min(1).optional() - }) - ) - .transform((value) => (typeof value === 'string' ? { message: value } : value)) - -export type AlertModalOptions = z.input -export type AlertModalConfiguration = z.output - -export const useAlertModal = createSharedComposable(() => { - const { isRevealed, onReveal, confirm, reveal } = useConfirmDialog() - - const show = async (options: AlertModalOptions) => { - await reveal(AlertModalOptions.parse(options)) - } - - return { - isRevealed, - onReveal, - confirm, - show - } -}) - -const ConfirmModalOptions = z - .string() - .min(1) - .or( - z.object({ - title: z.string().min(1).optional(), - message: z.string().min(1), - confirmButton: z.string().min(1).optional(), - cancelButton: z.string().min(1).optional(), - color: z.string().min(1).default('primary') - }) - ) - .transform((value) => (typeof value === 'string' ? { message: value, color: 'primary' } : value)) - -export type ConfirmModalOptions = z.input -export type ConfirmModalConfiguration = z.output - -export const useConfirmModal = createSharedComposable(function useConfirmModal() { - const { isRevealed, onReveal, confirm, cancel, reveal } = useConfirmDialog() - - const show = async (options: ConfirmModalOptions) => { - const result = await reveal(ConfirmModalOptions.parse(options)) - - return !result.isCanceled - } - - return { - isRevealed, - onReveal, - confirm, - cancel, - show - } -}) - -const ErrorModalOptions = z - .string() - .min(1) - .or( - z.object({ - title: z.string().min(1).optional(), - button: z.string().min(1).optional(), - color: z.string().min(1).default('error') - }) - ) - .optional() - .transform((value) => (typeof value === 'string' ? { title: value, color: 'error' } : (value ?? { color: 'error' }))) - -export type ErrorModalOptions = z.input -export type ErrorModalConfiguration = z.output - -export const useDialogs = createSharedComposable(function useDialogs() { - const { toErrorMessage } = useErrors() - const alertModal = useAlertModal() - const confirmModal = useConfirmModal() - - const alert = async (...args: Parameters) => { - await alertModal.show(...args) - } - - const confirm = async (...args: Parameters) => await confirmModal.show(...args) - - const error = async (cause: unknown, options?: ErrorModalOptions) => { - const { header, message } = toErrorMessage(cause) - const { title = header, button, color } = ErrorModalOptions.parse(options) - - console.error(cause) - - await alert({ title, message, button, color }) - } - - return { - alert, - confirm, - error - } -}) - -export function useTieDialog() { - const { smAndDown } = useDisplay() - const model = useResponsiveModal(smAndDown, { maxWidth: 640, rounded: 'xl' }) - - return { breakpoint: smAndDown, ...model } -} - -export function useSourceDialog() { - const { xs } = useDisplay() - const model = useResponsiveModal(xs, { maxWidth: 480, rounded: 'xl' }) - - return { breakpoint: xs, ...model } -} - -export function useDeviceDialog() { - const { width } = useDisplay() - const breakpoint = computed(() => width.value <= 700) - - const model = useResponsiveModal(breakpoint, { minWidth: 640, maxWidth: 800, rounded: 'xl' }) - - return { breakpoint, ...model } -} diff --git a/src/renderer/pages/DeviceList.vue b/src/renderer/pages/DeviceList.vue deleted file mode 100644 index aa27877..0000000 --- a/src/renderer/pages/DeviceList.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - - - -en: - action: - addDevice: New switch or monitor - message: - confirmDeleteDevice: Do you want to remove this {0}? - deleteDeviceWarning: This will also remove any tie relationships on sources. - label: - monitor: monitor - switch: switch - diff --git a/src/renderer/pages/FirstRunLogic.vue b/src/renderer/pages/FirstRunLogic.vue deleted file mode 100644 index 5952683..0000000 --- a/src/renderer/pages/FirstRunLogic.vue +++ /dev/null @@ -1,100 +0,0 @@ - - - - - -en: - label: - v1Settings: Old setting not migrated - message: - autoStartConfirm: Do you want {0} to start on boot? - autoStartError: Unable to create start-up entry - v1Settings: | - Due to unavoidable reasons, your data from v1.x cannot migrate to v2.x automatically. - Your should export your version v1.x data and import it into v2.x. - This should not be an issue in the future. - action: - understood: Got it - diff --git a/src/renderer/pages/GeneralPage.vue b/src/renderer/pages/GeneralPage.vue deleted file mode 100644 index aa69a24..0000000 --- a/src/renderer/pages/GeneralPage.vue +++ /dev/null @@ -1,194 +0,0 @@ - - - - - -en: - option: - autoStart: Auto start - iconSize: Dashboard icon size - colorTheme: Color theme - powerOffWhen: Power button will power off when - powerOnSwitchesAtStart: Auto power on - closeApp: Close {0} - description: - autoStart: Start {0} when the system starts - powerOnSwitchesAtStart: Power on switches & monitors when {0} starts - closeApp: Close {0} without powering down - label: - iconSize: Icon size - powerOffWhen: Power off by - item: - iconSize: '{size} × {size}' - powerOff: - single: Single-tapped / single-clicked - double: Double-tapped / double-clicked - theme: - dark: Dark - light: Light - no-preference: System theme - diff --git a/src/renderer/pages/MainDashboard.vue b/src/renderer/pages/MainDashboard.vue deleted file mode 100644 index 83930a7..0000000 --- a/src/renderer/pages/MainDashboard.vue +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - - -en: - tooltip: - powerOff: Power off - doubleTapPowerOff: Power off (double-click) - openSettings: Open settings - diff --git a/src/renderer/pages/SettingsBackupPage.vue b/src/renderer/pages/SettingsBackupPage.vue deleted file mode 100644 index 69bb275..0000000 --- a/src/renderer/pages/SettingsBackupPage.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - -en: - label: - export: Export settings - import: Import settings - description: - export: Export settings from to archive - import: Import settings from an archive - archive: Settings archive - error: - export: Failed to export settings - import: Failed to import settings - diff --git a/src/renderer/pages/SettingsPage.vue b/src/renderer/pages/SettingsPage.vue deleted file mode 100644 index 2510ce6..0000000 --- a/src/renderer/pages/SettingsPage.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - - - -en: - label: - about: About {0} - description: - general: Basic settings for {0} - backup: Import or export setting as a file. - about: Version {0} - count: - sources: No sources | One source | {n} sources - devices: No devices | One device | {n} devices - diff --git a/src/renderer/pages/SourceList.vue b/src/renderer/pages/SourceList.vue deleted file mode 100644 index e04b1dc..0000000 --- a/src/renderer/pages/SourceList.vue +++ /dev/null @@ -1,123 +0,0 @@ - - - - - -en: - action: - addSource: New source - message: - confirmSourceDelete: Do you want to delete this source? - diff --git a/src/renderer/pages/SourcePage.vue b/src/renderer/pages/SourcePage.vue deleted file mode 100644 index b48bcef..0000000 --- a/src/renderer/pages/SourcePage.vue +++ /dev/null @@ -1,272 +0,0 @@ - - - - - -en: - action: - addTie: New tie - message: - confirmDeleteTie: Do you want to delete this tie? - noTies: No ties, add some - diff --git a/src/renderer/plugins/i18n.ts b/src/renderer/plugins/i18n.ts deleted file mode 100644 index 702b64c..0000000 --- a/src/renderer/plugins/i18n.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { createI18n } from 'vue-i18n' -import { en as vuetifyEn$ } from 'vuetify/locale' -import enDateTimes from '../locales/en/datetimes' -import enFunction from '../locales/en/functions' -import en from '../locales/en/messages.json' -import enNumbers from '../locales/en/numbers' -import { mergeLocaleParts } from '../support/locale' -import type { DateTimeSchema, Locales, MessageSchema, NumberSchema } from '../locales/locales' -import type { MergeDeep } from 'type-fest' - -type CompleteSchema = MergeDeep -interface CompleteI18nSchema { - message: CompleteSchema - datetime: DateTimeSchema - number: NumberSchema -} - -// Missing translation warning tracker. -const warned = new Set() - -const i18n = createI18n({ - // Using the composition API. - legacy: false, - globalInjection: true, - // Locales. - locale: 'en', - fallbackLocale: 'en', - // Handling the warnings due to mixed global and local messages. - fallbackWarn: false, - missingWarn: false, - missing: (...[locale, key, , type]) => { - const checkKey = type != null ? `${locale}/${type}/${key}` : `${locale}/key/${key}` - - if (!warned.has(checkKey)) { - console.warn( - type != null ? `Missing ${type} key "${key}" for "${locale}"` : `Missing key "${key}" for "${locale}"` - ) - } - - warned.add(checkKey) - }, - // Localization messages and data. - messages: { - en: { ...mergeLocaleParts(en, enFunction), $vuetify: vuetifyEn$ } - }, - numberFormats: { - en: enNumbers - }, - datetimeFormats: { - en: enDateTimes - } -}) - -export default i18n diff --git a/src/renderer/plugins/router.ts b/src/renderer/plugins/router.ts deleted file mode 100644 index 6c3d1ef..0000000 --- a/src/renderer/plugins/router.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { createRouter, createWebHashHistory } from 'vue-router' -import DeviceList from '../pages/DeviceList.vue' -import GeneralPage from '../pages/GeneralPage.vue' -import MainDashboard from '../pages/MainDashboard.vue' -import SettingsBackupPage from '../pages/SettingsBackupPage.vue' -import SettingsPage from '../pages/SettingsPage.vue' -import SourceList from '../pages/SourceList.vue' -import SourcePage from '../pages/SourcePage.vue' -import type { RouteRecordRaw } from 'vue-router' - -const routes = [ - { name: 'index', path: '/', component: MainDashboard }, - { name: 'settings', path: '/settings', component: SettingsPage }, - { name: 'settings-general', path: '/settings/general', component: GeneralPage }, - { name: 'settings-backup', path: '/settings/backup', component: SettingsBackupPage }, - { name: 'sources', path: '/sources', component: SourceList }, - { name: 'sources-id', path: '/sources/:id', component: SourcePage, props: true }, - { name: 'devices', path: '/devices', component: DeviceList } -] satisfies RouteRecordRaw[] - -const router = createRouter({ - history: createWebHashHistory(), - routes -}) - -export default router diff --git a/src/renderer/plugins/vuetify.ts b/src/renderer/plugins/vuetify.ts deleted file mode 100644 index 5953f21..0000000 --- a/src/renderer/plugins/vuetify.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useI18n } from 'vue-i18n' -import { createVuetify } from 'vuetify' -import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' -import { createVueI18nAdapter } from 'vuetify/locale/adapters/vue-i18n' -import i18n from './i18n' -import type { I18n } from 'vue-i18n' - -import 'vuetify/styles' - -type AnyI18n = I18n, Record, Record, string, false> - -const vuetify = createVuetify({ - locale: { - adapter: createVueI18nAdapter({ i18n: i18n as AnyI18n, useI18n }) - }, - defaults: { - VBtn: { rounded: 'pill' }, - VBtnGroup: { - rounded: 'pill', - variant: 'outlined', - VBtn: { rounded: undefined } - }, - VCard: { rounded: 'xl', variant: 'flat', elevation: 0 }, - VFileInput: { variant: 'outlined' }, - VSelect: { variant: 'outlined' }, - VSwitch: { inset: true }, - VTextField: { variant: 'outlined' } - }, - display: { - mobileBreakpoint: 'sm' - }, - icons: { - defaultSet: 'mdi', - aliases, - sets: { - mdi - } - }, - theme: { - variations: { - colors: ['primary', 'secondary', 'surface'], - lighten: 4, - darken: 4 - } - } -}) - -export default vuetify diff --git a/src/renderer/services/appUpdates.ts b/src/renderer/services/appUpdates.ts deleted file mode 100644 index a8b9661..0000000 --- a/src/renderer/services/appUpdates.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createSharedComposable, tryOnScopeDispose } from '@vueuse/core' -import useTypedEventTarget from '../support/events' -import { useClient } from './rpc/trpc' -import type { ProgressInfo } from 'electron-updater' - -export class UpdateProgressEvent extends Event implements ProgressInfo { - declare readonly type: 'progress' - declare readonly total: number - declare readonly delta: number - declare readonly transferred: number - declare readonly percent: number - declare readonly bytesPerSecond: number - constructor(options: EventInit & ProgressInfo) { - super('progress', options) - Object.assign(this, options) - } -} - -const useAppUpdates = createSharedComposable(function useAppUpdates() { - const client = useClient() - - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- Inappropriate, type !== interface - type Events = { - progress: (ev: UpdateProgressEvent) => void - } - - const target = useTypedEventTarget() - - const appUpdater = { - ...target, - checkForUpdates: async () => await client.updates.checkForUpdates.query(), - downloadUpdate: async () => { - await client.updates.downloadUpdate.query() - }, - cancelUpdate: async () => { - await client.updates.cancelUpdate.query() - }, - installUpdate: async () => { - await client.updates.installUpdate.query() - } - } - - function progressProxy(info: ProgressInfo) { - appUpdater.dispatchEvent(new UpdateProgressEvent(info)) - } - - const checking = client.updates.onChecking.subscribe(undefined, { - onData() { - console.log('Checking for updates...') - } - }) - - const available = client.updates.onAvailable.subscribe(undefined, { - onData([info]) { - if (info != null) console.log(`Update available to ${info.version}`) - else console.log('No updates available.') - } - }) - - const progress = client.updates.onProgress.subscribe(undefined, { - onData([info]) { - progressProxy(info) - } - }) - - tryOnScopeDispose(() => { - checking.unsubscribe() - available.unsubscribe() - progress.unsubscribe() - }) - - return appUpdater -}) - -export default useAppUpdates diff --git a/src/renderer/services/backup/export.ts b/src/renderer/services/backup/export.ts deleted file mode 100644 index 381590e..0000000 --- a/src/renderer/services/backup/export.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { BlobReader, BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js' -import { pick } from 'radash' -import { toFiles } from '../../support/files' -import { useDevices } from '../data/devices' -import { useSources } from '../data/sources' -import { useTies } from '../data/ties' -import useSettings from '../settings' -import { isNotNullish } from '@/basics' - -export async function exportSettings() { - const settings = useSettings() - const sources = useSources() - const devices = useDevices() - const ties = useTies() - - await sources.all() - await devices.all() - await ties.all() - - const zipFile = new BlobWriter() - const zipWriter = new ZipWriter(zipFile) - - // FIXME: Images should be given unique names rather than - // relying on the name used in the "source" document. - // This will prevent any issues where two or more - // "source" documents share the same image name. - - const configText = new TextReader( - JSON.stringify({ - version: 3, - settings: pick(settings, ['iconSize', 'colorScheme', 'powerOnSwitchesAtStart', 'powerOffWhen']), - layouts: { - sources: sources.items.map((item) => pick(item, ['_id', 'title', 'image'])), - devices: devices.items.map((item) => pick(item, ['_id', 'driverId', 'path', 'title'])), - ties: ties.items.map((item) => pick(item, ['_id', 'sourceId', 'deviceId', 'inputChannel', 'outputChannels'])) - } - }) - ) - - await zipWriter.add('config.json', configText) - - const images = sources.items - .map((item) => toFiles(item._attachments).find((file) => file.name === item.image)) - .filter(isNotNullish) - - for (const image of images) { - const imageData = new BlobReader(image) - // eslint-disable-next-line no-await-in-loop -- Must be serial. - await zipWriter.add(image.name, imageData) - } - - return new File([await zipWriter.close()], 'BridgeCmdr.config.zip') -} diff --git a/src/renderer/services/backup/formats/version0.ts b/src/renderer/services/backup/formats/version0.ts deleted file mode 100644 index 7ed9cc5..0000000 --- a/src/renderer/services/backup/formats/version0.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from 'zod' - -/** General document header. */ -export const DocHeader = z.object({ - _id: z.string().uuid() -}) diff --git a/src/renderer/services/backup/formats/version1.ts b/src/renderer/services/backup/formats/version1.ts deleted file mode 100644 index 167d18c..0000000 --- a/src/renderer/services/backup/formats/version1.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { z } from 'zod' -import { DocHeader } from './version0' -import type { Export as V3 } from './version3' - -/** - * Layout v1, will be used in Export v1 but - * may be reused by later versions. - */ -export type Layouts = z.output -export const Layouts = z.object({ - sources: z - .array( - DocHeader.extend({ - title: z.string().min(1), - image: z.string().min(1).nullable() - }) - ) - .transform((sources) => sources.map((source, order) => ({ order, ...source }))), - switches: z.array( - DocHeader.extend({ - driverId: z.string().uuid(), - title: z.string(), - path: z.string() - }) - ), - ties: z.array( - DocHeader.extend({ - sourceId: z.string().uuid(), - switchId: z.string().uuid(), - inputChannel: z.number().int(), - outputChannels: z - .object({ - video: z.number().int().optional(), - audio: z.number().int().optional() - }) - .default({}) - }).transform(({ switchId, ...tie }) => ({ ...tie, deviceId: switchId })) - ) -}) - -/** - * Export format v1. - */ -export type Export = z.output -export const Export = z - .object({ - version: z.literal(1), - ...Layouts.shape - }) - .transform( - ({ sources, switches, ties }): V3 => ({ - version: 3, - layouts: { sources, devices: switches, ties } - }) - ) diff --git a/src/renderer/services/backup/formats/version2.ts b/src/renderer/services/backup/formats/version2.ts deleted file mode 100644 index 9e46373..0000000 --- a/src/renderer/services/backup/formats/version2.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { z } from 'zod' -import { ColorScheme, IconSize, PowerOffTaps } from '../../settings' -import { Layouts } from './version1' - -/** - * Settings v2, will be used in Export v2 but - * may be reused by later versions. - */ -export type Settings = z.output -export const Settings = z - .object({ - iconSize: IconSize.optional(), - colorScheme: ColorScheme.optional(), - powerOnSwitchesAtStart: z.boolean().optional(), - powerOffWhen: PowerOffTaps.optional() - }) - .optional() - -/** - * Export format v2. - */ -export type Export = z.output -export const Export = z.object({ - version: z.literal(2).transform(() => 3 as const), - settings: Settings, - layouts: Layouts.transform(({ sources, switches, ties }) => ({ sources, devices: switches, ties })) -}) diff --git a/src/renderer/services/backup/formats/version3.ts b/src/renderer/services/backup/formats/version3.ts deleted file mode 100644 index c89c00f..0000000 --- a/src/renderer/services/backup/formats/version3.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from 'zod' -import { DocHeader } from './version0' -import { Settings } from './version2' - -/** - * Layout v3, will be used in Export v3 but - * may be reused by later versions. - */ -export type Layouts = z.output -export const Layouts = z.object({ - sources: z.array( - DocHeader.extend({ - order: z.number().nonnegative().finite().default(0), - title: z.string().min(1), - image: z.string().min(1).nullable() - }) - ), - devices: z.array( - DocHeader.extend({ - driverId: z.string().uuid(), - title: z.string(), - path: z.string() - }) - ), - ties: z.array( - DocHeader.extend({ - sourceId: z.string().uuid(), - deviceId: z.string().uuid(), - inputChannel: z.number().int(), - outputChannels: z - .object({ - video: z.number().int().optional(), - audio: z.number().int().optional() - }) - .default({}) - }) - ) -}) - -/** - * Export format v3. - */ -export type Export = z.output -export const Export = z.object({ - version: z.literal(3), - settings: Settings, - layouts: Layouts -}) diff --git a/src/renderer/services/backup/import.ts b/src/renderer/services/backup/import.ts deleted file mode 100644 index a5503ec..0000000 --- a/src/renderer/services/backup/import.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { BlobReader, BlobWriter, TextWriter, ZipReader } from '@zip.js/zip.js' -import mime from 'mime' -import { z } from 'zod' -import { toAttachment } from '../../support/files' -import { useDevices } from '../data/devices' -import { useSources } from '../data/sources' -import { useTies } from '../data/ties' -import useDrivers from '../driver' -import useSettings from '../settings' - -export async function importSettings(file: File) { - const settings = useSettings() - const formats = await Promise.all([ - // HACK: Don't use map to retain the tuple, and type for - // each element, for z.union, which wants a tuple. - import('./formats/version1').then((m) => m.Export), - import('./formats/version2').then((m) => m.Export), - import('./formats/version3').then((m) => m.Export) - ]) - - const backupSettings = z.union(formats) - - const zipFile = new BlobReader(file) - const zipReader = new ZipReader(zipFile) - const entries = await zipReader.getEntries() - const configEntry = entries.find((e) => e.filename === 'config.json') - if (configEntry?.getData == null) { - throw new TypeError(`${file.name} does not have a configuration file`) - } - - const configFile = new TextWriter() - await configEntry.getData(configFile) - const configData = await configFile.getData() - - const data = backupSettings.parse(JSON.parse(configData)) - - settings.iconSize = data.settings?.iconSize ?? settings.iconSize - settings.colorScheme = data.settings?.colorScheme ?? settings.colorScheme - settings.powerOnSwitchesAtStart = data.settings?.powerOnSwitchesAtStart ?? settings.powerOnSwitchesAtStart - settings.powerOffWhen = data.settings?.powerOffWhen ?? settings.powerOffWhen - - const imageCache = new Map() - - const drivers = useDrivers() - await drivers.all() - - const sources = useSources() - const devices = useDevices() - const ties = useTies() - - await Promise.all([ - Promise.all( - data.layouts.sources.map(async (item) => { - if (item.image == null) { - await sources.upsert(item) - return - } - - const type = mime.getType(item.image) ?? 'application/octet-stream' - let imageAttachment = imageCache.get(item.image) - if (imageAttachment != null) { - await sources.upsert(item, await toAttachment(imageAttachment)) - return - } - - const imageEntry = entries.find((e) => e.filename === item.image) - if (imageEntry?.getData == null) { - // It's not fatal if the image is missing. - console.warn(`Image for ${item._id}, "${item.image}", is missing`) - await sources.upsert({ ...item, image: null }) - return - } - - const imageFile = new BlobWriter() - await imageEntry.getData(imageFile) - const imageData = await imageFile.getData() - imageAttachment = new File([imageData], item.image, { type }) - imageCache.set(item.image, imageAttachment) - await sources.upsert(item, await toAttachment(imageAttachment)) - }) - ), - Promise.all( - data.layouts.devices.map(async (device) => { - const driver = drivers.items.find((d) => d.guid === device.driverId) - // Non-fatally skip devices from drivers that don't exist. - if (driver == null) { - console.warn(`Driver for ${device.title} no longer support; ${device.driverId}`) - return - } - - await devices.upsert(device) - }) - ) - ]) - - await Promise.all( - data.layouts.ties.map(async (item) => { - const source = sources.items.find((s) => s._id === item.sourceId) - const device = devices.items.find((d) => d._id === item.deviceId) - // Non-fatally skip ties that reference missing devices or sources. - if (source == null) { - console.warn(`Source for tie no longer present; ${item.sourceId}`) - return - } - - if (device == null) { - console.warn(`Device for tie no longer present; ${item.deviceId}`) - return - } - - await ties.upsert(item) - }) - ) -} diff --git a/src/renderer/services/dashboard.ts b/src/renderer/services/dashboard.ts deleted file mode 100644 index 5b58a2b..0000000 --- a/src/renderer/services/dashboard.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { defineStore } from 'pinia' -import { computed, readonly, ref } from 'vue' -import { useImages } from '../hooks/assets' -import { trackBusy } from '../hooks/tracking' -import { toFiles } from '../support/files' -import { useDevices } from './data/devices' -import { useSources } from './data/sources' -import { useTies } from './data/ties' -import useDrivers from './driver' -import useSettings from './settings' -import type { Source } from './data/sources' -import type { Driver } from './driver' -import type { DocumentId } from './store' -import type { ReadonlyDeep } from 'type-fest' -import { isNotNullish } from '@/basics' - -export interface Button { - readonly guid: string - readonly title: string - readonly image: string | undefined - order: number - isActive: boolean - activate: () => Promise - setOrder: (to: number) => void -} - -export const useDashboard = defineStore('dashboard', function defineDashboard() { - const isReady = ref(false) - - const settings = useSettings() - const drivers = useDrivers() - const ties = useTies() - const sources = useSources() - const devices = useDevices() - const tracker = trackBusy( - () => !isReady.value, - () => drivers.isBusy, - () => sources.isBusy, - () => devices.isBusy, - () => ties.isBusy - ) - - const loadedDrivers = new Map() - - const { images, loadImages } = useImages() - const items = ref([]) - - async function loadDrivers() { - await drivers.all() - - // Remove drivers for devices removed - const closing: Driver[] = [] - for (const guid of loadedDrivers.keys()) { - if (devices.items.find((device) => device._id === guid) == null) { - const driver = loadedDrivers.get(guid) - if (driver != null) { - loadedDrivers.delete(guid) - closing.push(driver) - } - } - } - - // Load any drivers that are new or are being replaced. - const loading = new Array<[string, Driver]>() - for (const device of devices.items) { - const driver = loadedDrivers.get(device._id) - if (driver == null || driver.uri !== device.path) { - loading.push([device._id, drivers.load(device.driverId, device.path)]) - } - } - - // Add the replaced and new driver to the loaded registry. - for (const [guid, driver] of loading) { - loadedDrivers.set(guid, driver) - } - } - - let orderUpdates = 0 - async function normalize() { - orderUpdates += 1 - if (orderUpdates === 100) { - orderUpdates = 0 - await sources.normalizeOrder() - await refresh() - } - } - - function defineButton(source: ReadonlyDeep, index: number): Button { - const commands = ties.items - .filter((tie) => tie.sourceId === source._id) - .map(function makeCommand(tie) { - const device = devices.items.find((item) => tie.deviceId === item._id) - const driver = loadedDrivers.get(tie.deviceId) - - if (device == null) { - console.error(`${tie.deviceId}: No such device for source "${source.title}"`) - - return undefined - } - - if (driver == null) { - console.error( - `${device.driverId}: No such driver for device "${device.title}" used by source "${source.title}"` - ) - - return undefined - } - - return { tie, device, driver } - }) - .filter(isNotNullish) - - async function activate() { - for (const button of items.value) { - button.isActive = false - } - - await Promise.allSettled( - commands.map(async ({ tie, driver }) => { - await driver - .activate(tie.inputChannel, tie.outputChannels.video ?? 0, tie.outputChannels.audio ?? 0) - .catch((cause: unknown) => { - console.warn('tie activation failure', cause) - }) - }) - ) - } - - function setOrder(to: number) { - orderUpdates += 1 - sources - .update({ _id: source._id, order: to }) - .then(normalize) - // .then(refresh) - .catch((cause: unknown) => { - console.warn('Failed to update order', cause) - }) - } - - return { - guid: source._id, - title: source.title, - image: images.value[index], - isActive: false, - order: source.order, - activate, - setOrder - } - } - - function prepareButton(button: Button) { - const activate = button.activate - const setOrder = button.setOrder - - button.activate = async () => { - await activate() - button.isActive = true - } - - button.setOrder = (to: number) => { - button.order = to - setOrder(to) - - // HACK: To allow external reference to trigger, resort the list - // completely. - items.value.sort((a, b) => a.order - b.order) - } - - return button - } - - async function setupDashboard() { - loadImages( - sources.items.map((source) => - toFiles(source._attachments).find((f) => source.image != null && f.name === source.image) - ) - ) - items.value = (await Promise.all(sources.items.map(defineButton).map(prepareButton))).sort( - (a, b) => a.order - b.order - ) - } - - let poweredOn = false - async function powerOnOnce() { - if (poweredOn) return - if (!settings.powerOnSwitchesAtStart) return - await Promise.allSettled( - [...loadedDrivers.values()].map(async (driver) => { - await driver.powerOn().catch((cause: unknown) => { - console.warn('device power off failure', cause) - }) - }) - ) - - poweredOn = true - } - - const refresh = tracker.track(async function refresh() { - items.value = [] - await Promise.all([ties.compact(), sources.compact(), devices.compact()]) - await Promise.all([ties.all(), sources.all(), devices.all()]) - await loadDrivers() - await setupDashboard() - await powerOnOnce() - isReady.value = true - }) - - async function powerOff() { - await Promise.allSettled( - [...loadedDrivers.values()].map(async (driver) => { - await driver.powerOff().catch((cause: unknown) => { - console.warn('device power off failure', cause) - }) - }) - ) - } - - return { - isBusy: computed(() => tracker.isBusy.value), - items: computed(() => readonly(items.value)), - refresh, - powerOff - } -}) diff --git a/src/renderer/services/data/devices.ts b/src/renderer/services/data/devices.ts deleted file mode 100644 index 2290f37..0000000 --- a/src/renderer/services/data/devices.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { defineStore } from 'pinia' -import { forceUndefined } from '../../hooks/utilities' -import { useClient } from '../rpc/trpc' -import { useDataStore } from '../store' -import type { NewDevice, Device, DeviceUpdate, DeviceUpsert } from '../../../preload/api' -import type { DocumentId } from '../store' - -export type { NewDevice, Device, DeviceUpdate } from '../../../preload/api' - -export const useDevices = defineStore('devices', function defineDevices() { - const { devices } = useClient() - const store = useDataStore() - - function blank(): NewDevice { - return { - driverId: forceUndefined(), - title: forceUndefined(), - path: 'port:' - } - } - - const compact = store.defineOperation(async () => { - await devices.compact.mutate() - }) - - const all = store.defineFetchMany(async () => await devices.all.query()) - - const get = store.defineFetch(async (id: DocumentId) => await devices.get.query(id)) - - const add = store.defineInsertion(async (document: NewDevice) => await devices.add.mutate(document)) - - const update = store.defineMutation(async (document: DeviceUpdate) => await devices.update.mutate(document)) - - const upsert = store.defineMutation(async (document: DeviceUpsert) => await devices.upsert.mutate(document)) - - const remove = store.defineRemoval(async (id: DocumentId) => { - await devices.remove.mutate(id) - return id - }) - - return { - isBusy: store.isBusy, - error: store.error, - items: store.items, - current: store.current, - blank, - compact, - all, - get, - add, - update, - upsert, - remove, - dismiss: store.unsetCurrent, - clear: store.clearItems - } -}) diff --git a/src/renderer/services/data/sources.ts b/src/renderer/services/data/sources.ts deleted file mode 100644 index 2922089..0000000 --- a/src/renderer/services/data/sources.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { defineStore } from 'pinia' -import { forceUndefined } from '../../hooks/utilities' -import { useClient } from '../rpc/trpc' -import { useDataStore } from '../store' -import type { NewSource, Source, SourceUpdate, SourceUpsert } from '../../../preload/api' -import type { DocumentId } from '../store' -import type { Attachment } from '@/attachments' - -export type { NewSource, Source, SourceUpdate } from '../../../preload/api' - -export const useSources = defineStore('sources', function defineSources() { - const { sources } = useClient() - const store = useDataStore() - - function blank(): NewSource { - return { - order: -1, - title: forceUndefined(), - image: null - } - } - - const compact = store.defineOperation(async () => { - await sources.compact.mutate() - }) - - const all = store.defineFetchMany(async () => await sources.all.query()) - - const get = store.defineFetch(async (id: DocumentId) => await sources.get.query(id)) - - const add = store.defineInsertion(async (document: NewSource, ...attachments: Attachment[]) => { - if (document.order === -1) { - document.order = await getNextOrderValue() - } - - return await sources.add.mutate([document, ...attachments]) - }) - - const update = store.defineMutation(async (document: SourceUpdate, ...attachments: Attachment[]) => { - if (document.order === -1) { - document.order = await getNextOrderValue() - } - - return await sources.update.mutate([document, ...attachments]) - }) - - const upsert = store.defineMutation(async (document: SourceUpsert, ...attachments: Attachment[]) => { - if (document.order === -1) { - document.order = await getNextOrderValue() - } - - return await sources.upsert.mutate([document, ...attachments]) - }) - - const remove = store.defineRemoval(async (id: DocumentId) => { - await sources.remove.mutate(id) - return id - }) - - async function getNextOrderValue() { - return await sources.getNextOrderValue.query() - } - - async function normalizeOrder() { - await sources.normalizeOrder.mutate() - } - - return { - isBusy: store.isBusy, - error: store.error, - items: store.items, - current: store.current, - blank, - compact, - all, - get, - add, - update, - upsert, - remove, - dismiss: store.unsetCurrent, - clear: store.clearItems, - // Utilities - getNextOrderValue, - normalizeOrder - } -}) diff --git a/src/renderer/services/data/storage.ts b/src/renderer/services/data/storage.ts deleted file mode 100644 index b6eaffe..0000000 --- a/src/renderer/services/data/storage.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createSharedComposable, useStorageAsync } from '@vueuse/core' -import { useClient } from '../rpc/trpc' -import type { RemovableRef, UseStorageAsyncOptions } from '@vueuse/core' -import type { MaybeRefOrGetter } from 'vue' - -// To make this work well, we will require async methods. -export interface StorageLikeAsync { - getItem: (key: string) => Promise - setItem: (key: string, value: string) => Promise - removeItem: (key: string) => Promise -} - -interface AsyncStorageEventInitDict { - /** Returns the key of the storage item being changed. */ - readonly key: string | null - /** Returns the new value of the key of the storage item whose value is being changed. */ - readonly newValue: string | null - /** Returns the old value of the key of the storage item whose value is being changed. */ - readonly oldValue: string | null - /** Returns the StorageLikeAsync object that was affected. */ - readonly storageArea: StorageLikeAsync | null -} - -const useUserStore = createSharedComposable(function useUserStore() { - const client = useClient() - - function emit(data: AsyncStorageEventInitDict) { - const event = new StorageEvent('storage', { - ...data, - storageArea: null, - url: '', - bubbles: false, - cancelable: false, - composed: false - }) - - globalThis.dispatchEvent(event) - } - - async function getItem(key: string) { - return await client.storage.getItem.query(key) - } - - async function setItem(key: string, newValue: string) { - const oldValue = await client.storage.getItem.query(key) - await client.storage.setItem.mutate([key, newValue]) - emit({ key, newValue, oldValue, storageArea }) - } - - async function removeItem(key: string) { - const oldValue = await client.storage.getItem.query(key) - await client.storage.removeItem.mutate(key) - emit({ key, newValue: null, oldValue, storageArea }) - } - - async function clear() { - await client.storage.clear.mutate() - } - - const storageArea = { - getItem, - setItem, - removeItem, - clear - } - - return storageArea -}) - -export default useUserStore - -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options?: UseStorageAsyncOptions -): RemovableRef -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options?: UseStorageAsyncOptions -): RemovableRef -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options?: UseStorageAsyncOptions -): RemovableRef -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options?: UseStorageAsyncOptions -): RemovableRef - -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options?: UseStorageAsyncOptions -): RemovableRef - -export function useUserStorage( - key: string, - initialValue: MaybeRefOrGetter, - options: UseStorageAsyncOptions = {} -): RemovableRef { - return useStorageAsync(key, initialValue, useUserStore(), options) -} diff --git a/src/renderer/services/data/ties.ts b/src/renderer/services/data/ties.ts deleted file mode 100644 index 7ac51f9..0000000 --- a/src/renderer/services/data/ties.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { defineStore } from 'pinia' -import { forceUndefined } from '../../hooks/utilities' -import { useClient } from '../rpc/trpc' -import { useDataStore } from '../store' -import type { NewTie, TieUpdate, Tie, TieUpsert } from '../../../preload/api' -import type { DocumentId } from '../store' - -export type { NewTie, TieUpdate, Tie } from '../../../preload/api' - -export const useTies = defineStore('ties', function defineTies() { - const { ties } = useClient() - const store = useDataStore() - - function blank(): NewTie { - return { - sourceId: forceUndefined(), - deviceId: forceUndefined(), - inputChannel: forceUndefined(), - outputChannels: { - video: undefined, - audio: undefined - } - } - } - - const compact = store.defineOperation(async () => { - await ties.compact.mutate() - }) - - const all = store.defineFetchMany(async () => await ties.all.query()) - - const get = store.defineFetch(async (id: DocumentId) => await ties.get.query(id)) - - const add = store.defineInsertion(async (document: NewTie) => await ties.add.mutate(document)) - - const update = store.defineMutation(async (document: TieUpdate) => await ties.update.mutate(document)) - - const upsert = store.defineMutation(async (document: TieUpsert) => await ties.upsert.mutate(document)) - - const remove = store.defineRemoval(async (id: DocumentId) => { - await ties.remove.mutate(id) - return id - }) - - const forDevice = store.defineFetchMany(async (id: DocumentId) => await ties.forDevice.query(id)) - - const forSource = store.defineFetchMany(async (id: DocumentId) => await ties.forSource.query(id)) - - return { - isBusy: store.isBusy, - error: store.error, - items: store.items, - current: store.current, - blank, - compact, - all, - get, - add, - update, - upsert, - remove, - forDevice, - forSource, - dismiss: store.unsetCurrent, - clear: store.clearItems - } -}) diff --git a/src/renderer/services/driver.ts b/src/renderer/services/driver.ts deleted file mode 100644 index f82c9f9..0000000 --- a/src/renderer/services/driver.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createSharedComposable } from '@vueuse/shared' -import { computed, reactive, readonly, ref, shallowReadonly } from 'vue' -import { trackBusy } from '../hooks/tracking' -import i18n from '../plugins/i18n' -import { useClient } from './rpc/trpc' -import type { - kDeviceHasNoExtraCapabilities as HasNoExtraCapabilities, - kDeviceSupportsMultipleOutputs as SupportsMultipleOutputs, - kDeviceCanDecoupleAudioOutput as CanDecoupleAudioOutput, - LocalizedDriverInformation, - DriverBasicInformation -} from '../../preload/api' - -/** The device has no extended capabilities. */ -export type kDeviceHasNoExtraCapabilities = HasNoExtraCapabilities -export const kDeviceHasNoExtraCapabilities: HasNoExtraCapabilities = 0 -/** The device has multiple output channels. */ -export type kDeviceSupportsMultipleOutputs = SupportsMultipleOutputs -export const kDeviceSupportsMultipleOutputs: SupportsMultipleOutputs = 1 -/** The device support sending the audio output to a different channel. */ -export type kDeviceCanDecoupleAudioOutput = CanDecoupleAudioOutput -export const kDeviceCanDecoupleAudioOutput: CanDecoupleAudioOutput = 2 - -/** Informational metadata about a device and driver. */ -export interface DriverInformation extends DriverBasicInformation, LocalizedDriverInformation { - // Just combines the localized information with the basic information. -} - -/** Driver to interact with monitors or switches. */ -export interface Driver extends DriverInformation { - /** - * Sets input and output ties. - * - * @param input - The input channel to tie. - * @param videoOutput - The output video channel to tie. - * @param audioOutput - The output audio channel to tie. - */ - readonly activate: (input: number, videoOutput: number, audioOutput: number) => Promise - /** Powers on the switch or monitor. */ - readonly powerOn: () => Promise - /** Powers on the switch or monitor and closes the driver. */ - readonly powerOff: () => Promise - /** The URI to the device being driven. */ - readonly uri: string -} - -const useDrivers = createSharedComposable(function useDrivers() { - const client = useClient() - - /** Busy tracking. */ - const tracker = trackBusy() - - /** The registered drivers. */ - const items = ref(new Array()) - - const registry = new Map() - - async function all() { - if (items.value.length > 0) return items.value - const drivers = await tracker.wait(client.drivers.all.query()) - for (const { enabled, experimental, kind, guid, localized, capabilities } of drivers) { - /** The localized driver information made i18n compatible. */ - for (const [locale, description] of Object.entries(localized)) { - i18n.global.mergeLocaleMessage(locale as never, { - $driver: { [guid]: { ...description } } - }) - } - - registry.set( - guid, - readonly({ - enabled, - experimental, - kind, - guid, - get title() { - return i18n.global.t(`$driver.${guid}.title`) - }, - get company() { - return i18n.global.t(`$driver.${guid}.company`) - }, - get provider() { - return i18n.global.t(`$driver.${guid}.provider`) - }, - capabilities - }) - ) - } - - items.value = Array.from(registry.values()) - return items.value - } - - function load(guid: string, uri: string): Driver { - if (items.value.length === 0) { - throw new ReferenceError(`No driver "${guid}"`) - } - - const driver = registry.get(guid) - if (driver == null) { - throw new ReferenceError(`No driver "${guid}"`) - } - - async function activate(input: number, videoOutput: number, audioOutput: number) { - await client.drivers.activate.mutate([guid, uri, input, videoOutput, audioOutput]) - } - - async function powerOn() { - await client.drivers.powerOn.mutate([guid, uri]) - } - - async function powerOff() { - await client.drivers.powerOff.mutate([guid, uri]) - } - - return readonly({ - ...driver, - activate, - powerOn, - powerOff, - uri - }) - } - - return reactive({ - isBusy: tracker.isBusy, - error: tracker.error, - items: computed(() => shallowReadonly(items.value)), - all, - load - }) -}) - -export default useDrivers diff --git a/src/renderer/services/migration.ts b/src/renderer/services/migration.ts deleted file mode 100644 index 14a68e5..0000000 --- a/src/renderer/services/migration.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createSharedComposable } from '@vueuse/shared' -import { trackBusy } from '../hooks/tracking' -import { useClient } from './rpc/trpc' - -const useMigration = createSharedComposable(function useMigration() { - const tracker = trackBusy() - const client = useClient() - - const migrate = tracker.track(async function migrate() { - await client.migration.migrate.mutate() - }) - - return migrate -}) - -export default useMigration diff --git a/src/renderer/services/ports.ts b/src/renderer/services/ports.ts deleted file mode 100644 index 2e82277..0000000 --- a/src/renderer/services/ports.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createSharedComposable } from '@vueuse/shared' -import { ref, computed, readonly, reactive } from 'vue' -import { trackBusy } from '../hooks/tracking' -import { useClient } from './rpc/trpc' -import type { PortEntry } from '../../preload/api' - -export type { PortEntry } from '../../preload/api' - -const usePorts = createSharedComposable(function usePorts() { - const tracker = trackBusy() - const client = useClient() - - const items = ref(new Array()) - - const all = tracker.track(async function all() { - items.value = await client.ports.list.query() - }) - - return reactive({ - isBusy: tracker.isBusy, - error: tracker.error, - items: computed(() => readonly(items.value)), - all - }) -}) - -export default usePorts diff --git a/src/renderer/services/rpc/link.ts b/src/renderer/services/rpc/link.ts deleted file mode 100644 index 978c71f..0000000 --- a/src/renderer/services/rpc/link.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { TRPCClientError } from '@trpc/client' -import { observable } from '@trpc/server/observable' -import { transformResults } from './utilities' -import type { Operation, TRPCLink } from '@trpc/client' -import type { AnyRouter, inferRouterContext, ProcedureType } from '@trpc/server' -import type { Observer } from '@trpc/server/observable' -import type { TRPCClientIncomingRequest, TRPCRequestMessage, TRPCResponseMessage } from '@trpc/server/rpc' - -type CallbackResult = TRPCResponseMessage< - Output, - inferRouterContext -> - -type CallbackObserver = Observer< - CallbackResult, - TRPCClientError -> - -interface Request { - type: ProcedureType - callbacks: CallbackObserver - op: Operation -} - -export function useIpcLink(): TRPCLink { - const pendingRequests = new Map>() - const rpc = globalThis.rpc - - function handleIncomingRequest(_req: TRPCClientIncomingRequest) { - console.debug('handleIncomingRequest is unnecessary') - } - - function handleIncomingResponse(data: TRPCResponseMessage) { - const req = data.id != null ? pendingRequests.get(data.id) : null - if (req == null) return - - req.callbacks.next(data) - if ('result' in data && data.result.type === 'stopped') { - req.callbacks.complete() - } - } - - rpc.onMessage(function handleMessage(msg) { - if ('method' in msg) { - handleIncomingRequest(msg) - } else { - handleIncomingResponse(msg) - } - }) - - function request(op: Operation, callbacks: CallbackObserver) { - const { type, input, path, id } = op - const envelope = { id, method: type, params: { input, path } } satisfies TRPCRequestMessage - pendingRequests.set(id, { type, callbacks, op }) - - rpc.sendMessage(envelope) - - return () => { - const cbs = pendingRequests.get(id)?.callbacks - pendingRequests.delete(id) - - cbs?.complete() - if (type === 'subscription') { - rpc.sendMessage({ id, method: 'subscription.stop' }) - } - } - } - - return function ipcLink(runtime) { - return function ipcProcess({ op }) { - return observable(function ipcObserve(observer) { - const { type, path, id, context } = op - const input = runtime.transformer.serialize(op.input) as unknown - - const unsubscribe = request( - { type, path, input, id, context }, - { - error(err) { - observer.error(err) - unsubscribe() - }, - complete() { - observer.complete() - }, - next(response) { - const transformed = transformResults(response, runtime) - if (!transformed.ok) { - observer.error(TRPCClientError.from(transformed.error)) - return - } - - observer.next({ result: transformed.result }) - if (op.type !== 'subscription') { - unsubscribe() - observer.complete() - } - } - } - ) - - return unsubscribe - }) - } - } -} diff --git a/src/renderer/services/rpc/trpc.ts b/src/renderer/services/rpc/trpc.ts deleted file mode 100644 index 6a1cc85..0000000 --- a/src/renderer/services/rpc/trpc.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createTRPCProxyClient } from '@trpc/client' -import { createGlobalState } from '@vueuse/shared' -import { useIpcLink } from './link' -import type { AppRouter } from '../../../preload/api' -import { useIpcJson } from '@/rpc/transformer' - -export const useClient = createGlobalState(function useClient() { - return createTRPCProxyClient({ transformer: useIpcJson(), links: [useIpcLink()] }) -}) diff --git a/src/renderer/services/rpc/utilities.ts b/src/renderer/services/rpc/utilities.ts deleted file mode 100644 index df9eb3f..0000000 --- a/src/renderer/services/rpc/utilities.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { TRPCClientRuntime } from '@trpc/client' -import type { AnyRouter, inferRouterContext, inferRouterError } from '@trpc/server' -import type { TRPCResponse, TRPCResponseMessage } from '@trpc/server/rpc' - -export function transformResults( - response: TRPCResponseMessage> | TRPCResponse>, - runtime: TRPCClientRuntime -) { - if ('error' in response) { - const error = runtime.transformer.deserialize(response.error) as inferRouterError - return { ok: false, error: { ...response, error } } as const - } - - const result = - !response.result.type || response.result.type === 'data' - ? { - ...response.result, - type: 'data' as const, - data: runtime.transformer.deserialize(response.result.data) as Output - } - : { ...response.result } - return { ok: true, result } as const -} diff --git a/src/renderer/services/settings.ts b/src/renderer/services/settings.ts deleted file mode 100644 index 9089e71..0000000 --- a/src/renderer/services/settings.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { usePreferredColorScheme } from '@vueuse/core' -import { defineStore } from 'pinia' -import { readonly, computed } from 'vue' -import { z } from 'zod' -import { useUserStorage } from './data/storage' -import useStartup from './startup' -import type { UseStorageOptions } from '@vueuse/core' - -interface JsonObject { - [property: string]: JsonValue -} -type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject -const JsonValue = z.string().transform((data) => JSON.parse(data) as JsonValue) - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Need to match anything, as Zod does. -type JsonType = z.ZodType | z.ZodCatch> - -const kIconSizes = [48, 64, 96, 128, 192, 256] as const -export type IconSize = z.infer -export const IconSize = z.custom<(typeof kIconSizes)[number]>((value) => kIconSizes.includes(value as never)) - -export type PowerOffTaps = z.infer -export const PowerOffTaps = z.enum(['single', 'double']) - -export type ColorScheme = z.infer -export const ColorScheme = z.enum(['light', 'dark', 'no-preference']) - -function useSchema(schema: Schema, deep = false) { - return { - deep, - listenToStorageChanges: true, - writeDefaults: true, - serializer: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- TS is unable to infer properly. - read: (raw) => JsonValue.pipe(schema).parse(raw), - write: (value) => JSON.stringify(value) - } - } satisfies UseStorageOptions> -} - -const useSettings = defineStore('settings', function defineSettings() { - const iconSize = useUserStorage('iconSize', 128, useSchema(IconSize)) - const iconSizes = readonly(kIconSizes) - - const preferredColorScheme = usePreferredColorScheme() - const colorScheme = useUserStorage('colorScheme', 'no-preference', useSchema(ColorScheme.catch('no-preference'))) - const colorSchemes = readonly(ColorScheme.options) - const activeColorScheme = computed(function getActiveColorSchema() { - if (colorScheme.value === 'no-preference') { - return preferredColorScheme.value === 'no-preference' ? 'light' : 'dark' - } - - return colorScheme.value - }) - - const powerOnSwitchesAtStart = useUserStorage('powerOnSwitchesAtStart', false, useSchema(z.boolean())) - - const powerOffWhen = useUserStorage('powerOffWhen', 'single', useSchema(PowerOffTaps)) - const powerOffWhenOptions = readonly(PowerOffTaps.options) - - const startup = useStartup() - - return { - iconSize, - iconSizes, - colorScheme, - colorSchemes, - activeColorScheme, - powerOnSwitchesAtStart, - powerOffWhen, - powerOffWhenOptions, - checkAutoStart: startup.checkEnabled, - enableAutoStart: startup.enable, - disableAutoStart: startup.disable - } -}) - -export default useSettings diff --git a/src/renderer/services/startup.ts b/src/renderer/services/startup.ts deleted file mode 100644 index 8d0d47f..0000000 --- a/src/renderer/services/startup.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createSharedComposable } from '@vueuse/shared' -import { useClient } from './rpc/trpc' - -const useStartup = createSharedComposable(function useStartup() { - const client = useClient() - const checkEnabled = async () => await client.startup.checkEnabled.query() - const checkUp = async () => { - await client.startup.checkUp.mutate() - } - const enable = async () => { - await client.startup.enable.mutate() - } - const disable = async () => { - await client.startup.disable.mutate() - } - - return { - checkEnabled, - checkUp, - enable, - disable - } -}) - -export default useStartup diff --git a/src/renderer/services/store.ts b/src/renderer/services/store.ts deleted file mode 100644 index 4cfc892..0000000 --- a/src/renderer/services/store.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { computed, nextTick, ref, shallowReadonly } from 'vue' -import { trackBusy } from '../hooks/tracking' -import type { DocumentId } from '../../preload/api' -import type { Ref } from 'vue' - -export type { DocumentId } from '../../preload/api' - -export function useDataStore() { - const { isBusy, error, track, wait } = trackBusy() - - const items: Ref = ref([]) - - const active = ref() - - function initialize(docs: Document[]) { - items.value = [...docs] - for (const doc of docs) { - if (doc._id === active.value?._id) { - active.value = doc - } - } - } - - function defineOperation(op: (...args: Args) => Promise) { - return async function storeOperation(...args: Args) { - await wait(op(...args)) - } - } - - function defineFetchMany(op: (...args: Args) => Promise) { - return async function storeCollect(...args: Args) { - // Clear the list before load. - initialize([]) - - // Next tick give the UI a task cycle to catch up. - await nextTick() - const docs = await wait(op(...args)) - initialize(docs) - return docs - } - } - - function defineFetch(op: (...args: Args) => Promise) { - return async function storeFetch(...args: Args) { - const fetchedItem = await wait(op(...args)) - replaceItem(fetchedItem) - return fetchedItem - } - } - - function insertItem(doc: Document) { - items.value.push(doc) - } - - function defineInsertion(op: (...args: Args) => Promise) { - return async function storeInsert(...args: Args) { - const newItem = await wait(op(...args)) - insertItem(newItem) - return newItem - } - } - - function replaceItem(doc: Document) { - const idx = items.value.findIndex((item) => item._id === doc._id) - if (idx !== -1) { - items.value.splice(idx, 1, doc) - } else { - items.value.push(doc) - } - - if (doc._id === active.value?._id) { - active.value = doc - } - } - - function defineMutation(op: (...args: Args) => Promise) { - return async function storeMutate(...args: Args) { - const updatedItem = await wait(op(...args)) - replaceItem(updatedItem) - return updatedItem - } - } - - function deleteItem(id: DocumentId) { - const idx = items.value.findIndex((item) => item._id === id) - if (idx !== -1) { - items.value.splice(idx, 1) - } - - if (id === active.value?._id) { - active.value = undefined - } - } - - function defineRemoval(op: (...args: Args) => Promise) { - return async function storeRemove(...args: Args) { - deleteItem(await wait(op(...args))) - } - } - - function clearItems() { - items.value = [] - } - - function unsetCurrent() { - active.value = undefined - } - - return { - isBusy, - error, - items: computed(() => shallowReadonly(items.value)), - // active, - current: computed(() => active.value), - track, - wait, - initialize, - defineOperation, - defineFetchMany, - defineFetch, - insertItem, - defineInsertion, - replaceItem, - defineMutation, - deleteItem, - defineRemoval, - clearItems, - unsetCurrent - } -} - -export type DataSet = ReturnType> diff --git a/src/renderer/support/events.ts b/src/renderer/support/events.ts deleted file mode 100644 index 5a5e572..0000000 --- a/src/renderer/support/events.ts +++ /dev/null @@ -1,79 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Won't match with unknown -export type EventMap = Record void> - -export interface TypedEvent extends Event { - type: E -} - -export type TypedEventConstructor = new (type: E, init?: I) => TypedEvent - -export type TypedEventListener> = (event: Event) => void - -export interface TypedEventListenerObject> { - handleEvent: TypedEventListener -} - -export type TypedEventListenerOrListenerObject> = - | TypedEventListener - | TypedEventListenerObject - -export type Listener void> = TypedEventListenerOrListenerObject< - Parameters[0]['type'], - Parameters[0] -> - -export interface TypedEventTarget extends EventTarget { - addEventListener: ( - type: E, - listener: Listener | null, - options?: AddEventListenerOptions | boolean - ) => void - removeEventListener: ( - type: E, - listener: Listener | null, - options?: EventListenerOptions | boolean - ) => void - dispatchEvent: (event: Parameters[0]) => boolean -} - -export interface TypedEventTargetEx extends TypedEventTarget { - on: ( - type: E, - listener: Listener | null, - options?: AddEventListenerOptions | boolean - ) => void - once: ( - type: E, - listener: Listener | null, - options?: Omit | boolean - ) => void - off: ( - type: E, - listener: Listener | null, - options?: EventListenerOptions | boolean - ) => void - emit: (event: Parameters[0]) => boolean -} - -function useTypedEventTarget(existing?: EventTarget) { - const target = (existing ?? new EventTarget()) as TypedEventTarget - - const addEventListener = target.addEventListener.bind(target) - const removeEventListener = target.removeEventListener.bind(target) - const dispatchEvent = target.dispatchEvent.bind(target) - - return { - addEventListener, - dispatchEvent, - removeEventListener, - on: addEventListener, - off: removeEventListener, - emit: dispatchEvent, - once: (type, listener, options?) => { - if (typeof options === 'boolean') addEventListener(type, listener, { capture: options, once: true }) - else addEventListener(type, listener, { ...options, once: true }) - } - } satisfies TypedEventTargetEx -} - -export default useTypedEventTarget diff --git a/src/renderer/support/files.ts b/src/renderer/support/files.ts deleted file mode 100644 index 9494268..0000000 --- a/src/renderer/support/files.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { defer } from 'radash' -import { Attachment } from '@/attachments' -import { asMicrotask, isNotNullish, withResolvers } from '@/basics' -import { raiseError } from '@/error-handling' - -/* eslint-disable n/no-unsupported-features/node-builtins -- In browser environment. */ - -export async function saveFile(source: File): Promise -export async function saveFile(source: Blob, defaultName: string): Promise -export async function saveFile(source: File | (Blob & { name?: undefined }), defaultName?: string) { - await defer(async (cleanup) => { - const saver = globalThis.document.createElement('a') - saver.style.display = 'none' - saver.href = URL.createObjectURL(source) - saver.download = source.name ?? defaultName ?? raiseError(() => new TypeError('No file name specified')) - cleanup(() => { - URL.revokeObjectURL(saver.href) - }) - - await asMicrotask(() => { - globalThis.document.body.appendChild(saver) - cleanup(() => { - globalThis.document.body.removeChild(saver) - }) - }) - - await asMicrotask(() => { - saver.click() - }) - }) -} - -interface OpenFileOptions { - accepts?: string | undefined - multiple?: true | undefined -} - -export async function openFile(options: OpenFileOptions = {}) { - return await defer(async (cleanup) => { - const opener = globalThis.document.createElement('input') - opener.style.display = 'none' - opener.type = 'file' - opener.accept = options.accepts ?? '' - opener.multiple = options.multiple ?? false - - await asMicrotask(() => { - globalThis.document.body.appendChild(opener) - cleanup(() => { - globalThis.document.body.removeChild(opener) - }) - }) - - return await asMicrotask(async () => { - const { resolve, promise } = withResolvers() - opener.addEventListener( - 'cancel', - () => { - resolve(null) - }, - { once: true } - ) - opener.addEventListener( - 'change', - () => { - resolve([...(opener.files ?? [])].filter(isNotNullish)) - }, - { once: true } - ) - - opener.click() - - return await promise - }) - }) -} - -type MaybeAttachment = Attachment | null | undefined - -export function toFile(attachment: Attachment): File -export function toFile(attachment: MaybeAttachment): File | null -export function toFile(attachment: MaybeAttachment) { - return attachment != null ? new File([attachment], attachment.name, { type: attachment.type }) : null -} - -export function toFiles(attachments: MaybeAttachment[] | null | undefined) { - if (attachments == null) return [] - return attachments.map(toFile).filter(isNotNullish) -} - -type MaybeFile = File | null | undefined - -export function toAttachment(file: File): Promise -export function toAttachment(file: MaybeFile): Promise -export async function toAttachment(file: MaybeFile) { - return file != null ? new Attachment(file.name, file.type, await file.arrayBuffer()) : null -} - -export async function toAttachments(files: MaybeFile[] | null | undefined) { - if (files == null) return [] - const attachments = await Promise.all(files.map(toAttachment)) - return attachments.filter(isNotNullish) -} diff --git a/src/renderer/support/i18n.ts b/src/renderer/support/i18n.ts deleted file mode 100644 index 00450e9..0000000 --- a/src/renderer/support/i18n.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NumberFormatOptions, DateTimeFormatOptions, LocaleMessage } from '@intlify/core-base' -import type { IntlNumberFormat, IntlDateTimeFormat, DefaultLocaleMessageSchema, MessageFunction } from 'vue-i18n' - -type DefineMessageSubSchema = - Schema extends LocaleMessage> - ? { [K in keyof Schema]: DefineMessageSubSchema } - : MessageFunction | string - -/** - * Defines an i18n message schema that can except either strings or message functions, - * while leaving the general schema structure intact. - */ -export type DefineMessageSchema = - Schema extends LocaleMessage> - ? { [K in keyof Schema]: DefineMessageSubSchema } - : DefaultLocaleMessageSchema - -/** - * Defines an i18n date/time format schema that is only typed as the generic set of options, - * while leaving the general schema structure intact. - */ -export type DefineDateTimeFormatSchema = { - [K in keyof Schema]: DateTimeFormatOptions -} - -/** - * Defines an i18n date/time format schema that is only typed as the generic set of options, - * while leaving the general schema structure intact. - */ -export type DefineNumberFormatSchema = { [K in keyof Schema]: NumberFormatOptions } diff --git a/src/renderer/support/locale.ts b/src/renderer/support/locale.ts deleted file mode 100644 index 68baf25..0000000 --- a/src/renderer/support/locale.ts +++ /dev/null @@ -1,16 +0,0 @@ -import is from '@sindresorhus/is' -import type { MergeDeep, UnknownRecord } from 'type-fest' - -export function mergeLocaleParts(first: First, next: Next) { - const output: UnknownRecord = { ...first, ...next } - for (const [key, value] of Object.entries(first)) { - const now = output[key] - if (is.object(value) && is.object(now)) { - output[key] = mergeLocaleParts(value as UnknownRecord, now as UnknownRecord) - } else if (is.array(value) && is.array(now)) { - output[key] = now - } - } - - return output as MergeDeep -} diff --git a/src/tests/core/attachments.test.ts b/src/tests/core/attachments.test.ts deleted file mode 100644 index 0a6d4bb..0000000 --- a/src/tests/core/attachments.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/// - -import { Buffer } from 'node:buffer' -import { beforeAll, expect, test } from 'vitest' -import { Attachment } from '@/attachments' - -let text: string -let data: Uint8Array -let buffer: Buffer -let blob: Blob -let file: File - -beforeAll(() => { - const textEncoder = new TextEncoder() - text = 'This is a test' - data = textEncoder.encode(text) - buffer = Buffer.from(data) - blob = new Blob([data], { type: 'text/plain' }) - file = new File([data], 'test', { type: 'text/plain' }) -}) - -test('creating an Attachment instance', () => { - const attachment = new Attachment('test', 'text/plain', data) - expect(attachment.name).toStrictEqual('test') - expect(attachment.type).toStrictEqual('text/plain') - expect(attachment.buffer).toStrictEqual(data.buffer) -}) - -test('creating an Attachment from a File', async () => { - const attachment = await Attachment.fromFile(file) - expect(attachment.name).toStrictEqual('test') - expect(attachment.type).toStrictEqual('text/plain') - expect(attachment.buffer).toStrictEqual(data.buffer) -}) - -test('creating an Attachment from a PouchDB Buffer attachment', async () => { - const dbAttachment = { content_type: 'text/plain', data: buffer } satisfies PouchDB.Core.FullAttachment - const attachment = await Attachment.fromPouchAttachment('test', dbAttachment) - expect(attachment.name).toStrictEqual('test') - expect(attachment.type).toStrictEqual('text/plain') - expect(attachment.buffer).toStrictEqual(data.buffer) -}) - -test('creating an Attachment from a PouchDB Blob attachment', async () => { - const dbAttachment = { content_type: 'text/plain', data: blob } satisfies PouchDB.Core.FullAttachment - const attachment = await Attachment.fromPouchAttachment('test', dbAttachment) - expect(attachment.name).toStrictEqual('test') - expect(attachment.type).toStrictEqual('text/plain') - expect(attachment.buffer).toStrictEqual(data.buffer) -}) - -test('creating an Attachment from a PouchDB text attachment', async () => { - const dbAttachment = { content_type: 'text/plain', data: text } satisfies PouchDB.Core.FullAttachment - const attachment = await Attachment.fromPouchAttachment('test', dbAttachment) - expect(attachment.name).toStrictEqual('test') - expect(attachment.type).toStrictEqual('text/plain') - expect(attachment.buffer).toStrictEqual(data.buffer) -}) diff --git a/src/tests/core/basics.test.ts b/src/tests/core/basics.test.ts deleted file mode 100644 index 59ebd05..0000000 --- a/src/tests/core/basics.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { asMicrotask, isNotNullish, toArray, toObjectPath, withResolvers } from '@/basics' - -test('isNotNullish', () => { - expect(isNotNullish(undefined)).toBe(false) - expect(isNotNullish(null)).toBe(false) - expect(isNotNullish(false)).toBe(true) - expect(isNotNullish('')).toBe(true) - expect(isNotNullish(Number.NaN)).toBe(true) -}) - -test('toArray', () => { - expect(toArray(undefined)).toStrictEqual([]) - expect(toArray(null)).toStrictEqual([]) - expect(toArray(5)).toStrictEqual([5]) - expect(toArray('test')).toStrictEqual(['test']) - expect(toArray([undefined])).toStrictEqual([undefined]) - expect(toArray([null])).toStrictEqual([null]) - expect(toArray([5])).toStrictEqual([5]) - expect(toArray(['test'])).toStrictEqual(['test']) -}) - -describe('withResolvers', () => { - test('resolve', async () => { - const { promise, resolve } = withResolvers<5>() - setTimeout(() => { - resolve(5) - }, 10) - await expect(promise).resolves.toStrictEqual(5) - }) - - test('reject', async () => { - const { promise, reject } = withResolvers() - setTimeout(() => { - reject(new Error('test')) - }, 10) - await expect(promise).rejects.toThrow('test') - }) -}) - -describe('asMicrotask', () => { - describe('synchronous', () => { - test('resolve', async () => { - await expect(asMicrotask(() => 5)).resolves.toBe(5) - }) - - test('reject', async () => { - await expect( - asMicrotask(() => { - throw new Error('test') - }) - ).rejects.toThrow('test') - }) - }) - - describe('asynchronous', () => { - test('resolve', async () => { - await expect(asMicrotask(async () => await Promise.resolve(5))).resolves.toBe(5) - }) - - test('reject', async () => { - await expect(asMicrotask(async () => await Promise.reject(new Error('test')))).rejects.toThrow('test') - }) - }) -}) - -test('toObjectPath', () => { - expect(toObjectPath(['settings'])).toBe('settings') - expect(toObjectPath(['documents', 5])).toBe('documents[5]') - expect(toObjectPath(['node', 'parent'])).toBe('node.parent') - expect(toObjectPath(['nodes', 5, 'parent'])).toBe('nodes[5].parent') - expect(toObjectPath([5, 'status'])).toBe('[5].status') - expect(toObjectPath([5])).toBe('[5]') -}) diff --git a/src/tests/core/error-handling.test.ts b/src/tests/core/error-handling.test.ts deleted file mode 100644 index 388968d..0000000 --- a/src/tests/core/error-handling.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { assert, describe, expect, test } from 'vitest' -import { z } from 'zod' -import { getMessage, getZodMessage, isNodeError, raiseError, toError } from '@/error-handling' - -describe('getZodMessage', () => { - test('failed scalar parsing', () => { - const badBasicData = 'text' - const badResult = z.literal('test').safeParse(badBasicData) - assert(!badResult.success, 'Expected `!badResult.success`, got `badResult.success`') - const zodError = badResult.error - - expect(getZodMessage(zodError)).toBe(zodError.errors[0]?.message) - }) - - test('failed object parsing', () => { - const badBasicData = { type: 'text' } - const badResult = z.object({ type: z.literal('test') }).safeParse(badBasicData) - assert(!badResult.success, 'Expected `!badResult.success`, got `badResult.success`') - const zodError = badResult.error - - expect(getZodMessage(zodError)).toBe(`type: ${zodError.errors[0]?.message}`) - }) -}) - -describe('getMessage', () => { - test('Error', () => { - expect(getMessage(new Error('test'))).toBe('test') - }) - - test('nullish', () => { - expect(getMessage(undefined)).toBe('BadError: undefined') - expect(getMessage(null)).toBe('BadError: null') - }) - - test('string', () => { - expect(getMessage('test')).toBe('test') - }) - - test('number', () => { - expect(getMessage(88)).toBe('88') - }) - - test('Date', () => { - expect(getMessage(new Date())).toBe('BadError: [object Date]') - }) - - test('{ message: number }', () => { - expect(getMessage({ message: 'test' })).toBe('test') - }) - - test('{ message: string }', () => { - expect(getMessage({ message: 88 })).toBe('88') - }) -}) - -test('toError', () => { - const error = new Error('test') - expect(toError('test')).toStrictEqual(error) - expect(toError(error)).toBe(error) - - const typeError = new TypeError('test') - expect(toError(typeError)).toBe(typeError) -}) - -test('isNodeError', () => { - expect(isNodeError(new Error('test'))).toBe(false) -}) - -test('raiseError', () => { - expect(() => raiseError(() => new ReferenceError('test'))).toThrowError(new ReferenceError('test')) - expect(() => raiseError(() => new TypeError('test'))).toThrowError(new TypeError('test')) -}) diff --git a/src/tests/core/location.test.ts b/src/tests/core/location.test.ts deleted file mode 100644 index ebeeece..0000000 --- a/src/tests/core/location.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest' -import type { PortEntry } from '../../main/services/ports' -import { isIpOrValidPort, isValidLocation } from '@/location' - -let ports: PortEntry[] -beforeAll(() => { - ports = [ - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS0', - pnpId: undefined, - productId: '8087', - serialNumber: '1', - title: 'Mock Serial Port', - vendorId: '8086' - } - ] -}) - -describe('isIpOrValidPort', () => { - test('port', () => { - expect(isIpOrValidPort('port:/dev/ttyS0', ports)).toBe(true) - expect(isIpOrValidPort('port:/dev/ttyS5', ports)).toBe(false) - expect(isIpOrValidPort('port:', ports)).toBe(false) - expect(isIpOrValidPort('port', ports)).toBe(false) - }) - test('ip', () => { - expect(isIpOrValidPort('ip:example.com', ports)).toBe(true) - expect(isIpOrValidPort('ip:example', ports)).toBe(true) - expect(isIpOrValidPort('ip:127.5.0.111', ports)).toBe(true) - expect(isIpOrValidPort('ip:', ports)).toBe(false) - expect(isIpOrValidPort('ip', ports)).toBe(false) - }) - test('random', () => { - expect(isIpOrValidPort('file:', ports)).toBe(false) - expect(isIpOrValidPort('', ports)).toBe(false) - }) -}) - -describe('isValidLocation', () => { - test('port', () => { - expect(isValidLocation('port:/dev/ttyS0', ports)).toBe(true) - expect(isValidLocation('port:/dev/ttyS5', ports)).toBe(false) - expect(isValidLocation('port:', ports)).toBe(false) - expect(isValidLocation('port', ports)).toBe(false) - }) - test('ip', () => { - expect(isValidLocation('ip:example.com', ports)).toBe(true) - expect(isValidLocation('ip:example', ports)).toBe(true) - expect(isValidLocation('ip:127.5.0.111', ports)).toBe(true) - expect(isValidLocation('ip:127.5.0.4111', ports)).toBe(true) // Host not IP. - expect(isValidLocation('ip:[2561:1900:4545:0003:0200:F8FF:FE21:67CF]', ports)).toBe(true) - expect(isValidLocation('ip:[2260:F3A4:32CB:715D:5D11:D837:FC76:12FC]', ports)).toBe(true) - expect(isValidLocation('ip:[FE80::2045:FAEB:33AF:8374]', ports)).toBe(true) - expect(isValidLocation('ip:[::2045:FAEB:33AF:8374]', ports)).toBe(true) - expect(isValidLocation('ip:[FE80:2045:FAEB:33AF::]', ports)).toBe(true) - expect(isValidLocation('ip:[::11.22.33.44]', ports)).toBe(true) - expect(isValidLocation('ip:[F:F:F:F:F:F:192.168.0.1]', ports)).toBe(true) - expect(isValidLocation('ip:[2001:db8::123.123.123.123]', ports)).toBe(true) - expect(isValidLocation('ip:[2001:db8::123.123.123.123]:55', ports)).toBe(true) - expect(isValidLocation('ip:[2001:db8::123.123.123.123]:ab', ports)).toBe(false) - expect(isValidLocation('ip:[::]:59', ports)).toBe(true) - expect(isValidLocation('ip:[]:59', ports)).toBe(false) - expect(isValidLocation('ip://', ports)).toBe(false) - expect(isValidLocation('ip:', ports)).toBe(false) - }) - test('random', () => { - expect(isValidLocation('file:', ports)).toBe(false) - expect(isValidLocation('', ports)).toBe(false) - }) -}) diff --git a/src/tests/core/superjson.test.ts b/src/tests/core/superjson.test.ts deleted file mode 100644 index fe5dfc7..0000000 --- a/src/tests/core/superjson.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest' -import type { SuperJSONResult } from 'superjson' -import { Attachment } from '@/attachments' -import { useWebJson, useIpcJson } from '@/rpc/transformer' - -describe('Web JSON', () => { - let superjson: ReturnType - beforeAll(() => { - superjson = useWebJson() - }) - - describe('attachments', () => { - test('serialize', () => { - const payload = { file: new Attachment('test.txt', 'text/plain', Buffer.from('Hello World')) } - expect(superjson.serialize(payload)).toStrictEqual({ - json: { file: { name: 'test.txt', type: 'text/plain', data: 'SGVsbG8gV29ybGQ=' } }, - meta: { values: { file: [['custom', 'Attachment']] } } - }) - }) - - test('deserialize', () => { - const payload = { - json: { file: { name: 'test.txt', type: 'text/plain', data: 'SGVsbG8gV29ybGQ=' } }, - meta: { values: { file: [['custom', 'Attachment']] } } - } satisfies SuperJSONResult - expect(superjson.deserialize(payload)).toStrictEqual({ - file: new Attachment('test.txt', 'text/plain', Buffer.from('Hello World')) - }) - }) - }) - - describe('functions', () => { - test('serialize', () => { - const payload = { hello: () => undefined } - expect(() => superjson.serialize(payload)).toThrow(new TypeError('Functions may not be serialized')) - }) - - test('deserialize', () => { - const payload = { - json: { file: null }, - meta: { values: { file: [['custom', 'Function']] } } - } satisfies SuperJSONResult - expect(() => superjson.deserialize(payload)).toThrow(new TypeError('Functions may not be serialized')) - }) - }) -}) - -describe('IPC JSON', () => { - let superjson: ReturnType - beforeAll(() => { - superjson = useIpcJson() - }) - - describe('attachments', () => { - test('serialize', () => { - const buffer = new Uint8Array(Buffer.from('Hello World').buffer) - const payload = { file: new Attachment('test.txt', 'text/plain', buffer) } - expect(superjson.serialize(payload)).toStrictEqual({ - json: { file: { name: 'test.txt', type: 'text/plain', data: buffer } }, - meta: { values: { file: [['custom', 'Attachment']] } } - }) - }) - - test('deserialize', () => { - const buffer = new Uint8Array(Buffer.from('Hello World').buffer) - const payload = { - json: { file: { name: 'test.txt', type: 'text/plain', data: buffer as never } }, - meta: { values: { file: [['custom', 'Attachment']] } } - } satisfies SuperJSONResult - expect(superjson.deserialize(payload)).toStrictEqual({ - file: new Attachment('test.txt', 'text/plain', buffer) - }) - }) - }) - - describe('functions', () => { - test('serialize', () => { - const payload = { hello: () => undefined } - expect(() => superjson.serialize(payload)).toThrow(new TypeError('Functions may not be serialized')) - }) - - test('deserialize', () => { - const payload = { - json: { file: null }, - meta: { values: { file: [['custom', 'Function']] } } - } satisfies SuperJSONResult - expect(() => superjson.deserialize(payload)).toThrow(new TypeError('Functions may not be serialized')) - }) - }) -}) diff --git a/src/tests/main/dao/devices.test.ts b/src/tests/main/dao/devices.test.ts deleted file mode 100644 index bc629ca..0000000 --- a/src/tests/main/dao/devices.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import useDevicesDatabase from '../../../main/dao/devices' -import useTiesDatabase from '../../../main/dao/ties' -import { seedDatabase } from '../../seeds/database.seed' -import type { NewDevice } from '../../../main/dao/devices' -import { Attachment } from '@/attachments' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('devices')) - vi.mock('electron-log', mock.electronLogModule) -}) - -let database: Awaited> -let devicesDao: ReturnType -let tiesDao: ReturnType - -beforeEach(async () => { - database = await seedDatabase() - devicesDao = useDevicesDatabase() - tiesDao = useTiesDatabase() -}) - -test('compacting', async () => { - await expect(devicesDao.compact()).resolves.toBeUndefined() -}) - -describe('query', () => { - test('all', async () => { - await expect(devicesDao.all()).resolves.toStrictEqual(database.devices) - }) - - test('single', async () => { - await Promise.all( - database.devices.map(async (item) => { - await expect(devicesDao.get(item._id)).resolves.toStrictEqual(item) - }) - ) - }) -}) - -const kUuidPattern = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u -const kRevPattern = /^[0-9]-[0-9a-f]{32}$/u - -test('adding', async () => { - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - const attachment = await Attachment.fromFile(file) - const raw = { title: 'Extron', driverId: database.extronSis.guid, path: 'ip:192.168.10.2' } satisfies NewDevice - const doc = await devicesDao.add(raw, attachment) - - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc).toStrictEqual({ - ...raw, - _id: doc._id, - _rev: doc._rev, - _attachments: [attachment] - }) -}) - -test('updating', async () => { - const device = database.devices[0] - const changes = { title: 'Extron RGBHVA 128pro' } - const updated = await devicesDao.update({ _id: device._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...device, - ...changes, - _id: device._id, - _rev: updated._rev, - _attachments: device._attachments - }) -}) - -test('removing', async () => { - const device = database.devices[1] - await expect(devicesDao.remove(device._id)).resolves.toBeUndefined() - await expect(devicesDao.get(device._id)).rejects.toThrow('missing') - await expect(tiesDao.forSource(device._id)).resolves.toHaveLength(0) -}) - -test('clearing', async () => { - await expect(devicesDao.all()).resolves.toHaveLength(database.devices.length) - await expect(tiesDao.all()).resolves.toHaveLength(database.ties.length) - await expect(devicesDao.clear()).resolves.toBeUndefined() - await Promise.all( - database.devices.map(async (doc) => { - await expect(devicesDao.get(doc._id)).rejects.toThrow('missing') - }) - ) - await expect(devicesDao.all()).resolves.toHaveLength(0) - await expect(tiesDao.all()).resolves.toHaveLength(0) -}) diff --git a/src/tests/main/dao/sources.test.ts b/src/tests/main/dao/sources.test.ts deleted file mode 100644 index 2958396..0000000 --- a/src/tests/main/dao/sources.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import useSourcesDatabase from '../../../main/dao/sources' -import useTiesDatabase from '../../../main/dao/ties' -import { seedDatabase } from '../../seeds/database.seed' -import type { NewSource } from '../../../main/dao/sources' -import { Attachment } from '@/attachments' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('sources')) - vi.mock('electron-log', mock.electronLogModule) -}) - -let database: Awaited> -let sourcesDao: ReturnType -let tiesDao: ReturnType - -beforeEach(async () => { - database = await seedDatabase() - sourcesDao = useSourcesDatabase() - tiesDao = useTiesDatabase() -}) - -test('compacting', async () => { - await expect(sourcesDao.compact()).resolves.toBeUndefined() -}) - -describe('query', () => { - test('all', async () => { - await expect(sourcesDao.all()).resolves.toStrictEqual(database.sources) - }) - - test('single', async () => { - await Promise.all( - database.sources.map(async (item) => { - await expect(sourcesDao.get(item._id)).resolves.toStrictEqual(item) - }) - ) - }) -}) - -const kUuidPattern = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u -const kRevPattern = /^[0-9]-[0-9a-f]{32}$/u - -test('adding', async () => { - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - const attachment = await Attachment.fromFile(file) - const raw = { order: 3, title: 'NeoGeo', image: file.name } satisfies NewSource - const doc = await sourcesDao.add(raw, attachment) - - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc).toStrictEqual({ - ...raw, - _id: doc._id, - _rev: doc._rev, - _attachments: [attachment] - }) -}) - -test('updating', async () => { - const source = database.sources[0] - const changes = { title: '3D0' } - const updated = await sourcesDao.update({ _id: source._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...source, - ...changes, - _id: source._id, - _rev: updated._rev, - _attachments: source._attachments - }) -}) - -test('removing', async () => { - const source = database.sources[1] - await expect(sourcesDao.remove(source._id)).resolves.toBeUndefined() - await expect(sourcesDao.get(source._id)).rejects.toThrow('missing') - await expect(tiesDao.forSource(source._id)).resolves.toHaveLength(0) -}) - -test('clearing', async () => { - await expect(sourcesDao.all()).resolves.toHaveLength(database.sources.length) - await expect(tiesDao.all()).resolves.toHaveLength(database.ties.length) - await expect(sourcesDao.clear()).resolves.toBeUndefined() - await Promise.all( - database.sources.map(async (doc) => { - await expect(sourcesDao.get(doc._id)).rejects.toThrow('missing') - }) - ) - await expect(sourcesDao.all()).resolves.toHaveLength(0) - await expect(tiesDao.all()).resolves.toHaveLength(0) -}) - -describe('utilities', () => { - describe('getNextOrderValue', () => { - test('with documents', async () => { - await expect(sourcesDao.getNextOrderValue()).resolves.toBe(3) - }) - - test('without documents', async () => { - await sourcesDao.clear() - await expect(sourcesDao.getNextOrderValue()).resolves.toBe(0) - }) - }) -}) diff --git a/src/tests/main/dao/storage.test.ts b/src/tests/main/dao/storage.test.ts deleted file mode 100644 index 5f24510..0000000 --- a/src/tests/main/dao/storage.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { beforeAll, expect, test, vi } from 'vitest' -import type useUserStore from '../../../main/dao/storage' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -let storage: ReturnType -beforeAll(async () => { - vi.mock('electron', mock.electronModule('storage')) - vi.mock('electron-log', mock.electronLogModule) - storage = (await import('../../../main/dao/storage')).default() -}) - -test('general ', async () => { - // Setting items. - await storage.setItem('test-1', 'hello world 1') - await storage.setItem('test-2', 'hello world 2') - await storage.setItem('test-3', 'hello world 3') - - // Getting items. - await expect(storage.getItem('test-1')).resolves.toBe('hello world 1') - await expect(storage.getItem('test-2')).resolves.toBe('hello world 2') - await expect(storage.getItem('test-3')).resolves.toBe('hello world 3') - - // Removing items. - await expect(storage.removeItem('test-3')).resolves.toBeUndefined() - - // Checking remove, getting non-existent items. - await expect(storage.getItem('test-3')).resolves.toBeNull() - - // Clearing storage. - await expect(storage.clear()).resolves.toBeUndefined() - - // Checking clear, getting non-existent items. - await expect(storage.getItem('test-1')).resolves.toBeNull() - await expect(storage.getItem('test-2')).resolves.toBeNull() - - // Removing non-existent items. - await expect(storage.removeItem('test-1')).resolves.toBeUndefined() - await expect(storage.removeItem('test-2')).resolves.toBeUndefined() -}) diff --git a/src/tests/main/dao/ties.test.ts b/src/tests/main/dao/ties.test.ts deleted file mode 100644 index cf4bf74..0000000 --- a/src/tests/main/dao/ties.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import useTiesDatabase from '../../../main/dao/ties' -import { seedDatabase } from '../../seeds/database.seed' -import type { NewTie } from '../../../main/dao/ties' -import { Attachment } from '@/attachments' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('ties')) - vi.mock('electron-log', mock.electronLogModule) -}) - -let database: Awaited> -let tiesDao: ReturnType - -beforeEach(async () => { - database = await seedDatabase() - tiesDao = useTiesDatabase() -}) - -test('compacting', async () => { - await expect(tiesDao.compact()).resolves.toBeUndefined() -}) - -describe('query', () => { - test('all', async () => { - await expect(tiesDao.all()).resolves.toStrictEqual(database.ties) - }) - - test('single', async () => { - await Promise.all( - database.ties.map(async (item) => { - await expect(tiesDao.get(item._id)).resolves.toStrictEqual(item) - }) - ) - }) -}) - -const kUuidPattern = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u -const kRevPattern = /^[0-9]-[0-9a-f]{32}$/u - -test('adding', async () => { - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - const attachment = await Attachment.fromFile(file) - const raw = { - sourceId: database.sources[2]._id, - deviceId: database.devices[1]._id, - inputChannel: 3, - outputChannels: { video: 6 } - } satisfies NewTie - const doc = await tiesDao.add(raw, attachment) - - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc).toStrictEqual({ - ...raw, - _id: doc._id, - _rev: doc._rev, - _attachments: [attachment] - }) -}) - -test('updating', async () => { - const tie = database.ties[0] - const changes = { inputChannel: 4 } - const updated = await tiesDao.update({ _id: tie._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...tie, - ...changes, - _id: tie._id, - _rev: updated._rev, - _attachments: tie._attachments - }) -}) - -test('removing', async () => { - const tie = database.ties[1] - await expect(tiesDao.remove(tie._id)).resolves.toBeUndefined() - await expect(tiesDao.get(tie._id)).rejects.toThrow('missing') - await expect(tiesDao.forSource(tie._id)).resolves.toHaveLength(0) -}) - -test('clearing', async () => { - await expect(tiesDao.all()).resolves.toHaveLength(database.ties.length) - await expect(tiesDao.clear()).resolves.toBeUndefined() - await Promise.all( - database.ties.map(async (doc) => { - await expect(tiesDao.get(doc._id)).rejects.toThrow('missing') - }) - ) - await expect(tiesDao.all()).resolves.toHaveLength(0) -}) diff --git a/src/tests/main/drivers/extron/sis.test.ts b/src/tests/main/drivers/extron/sis.test.ts deleted file mode 100644 index 406a6f0..0000000 --- a/src/tests/main/drivers/extron/sis.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '4C8F2838-C91D-431E-84DD-3666D14A6E2C' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/extron/sis') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const video = 2 - const audio = 3 - - const toResp = (n: number) => String(n).padStart(2, '0') - - const command = `${input}*${video}%\r\n${input}*${audio}$\r\n` - const response = `Out${toResp(video)} In${toResp(input)} Vid\r\nOut${toResp(audio)} In${toResp(input)} Aud\r\n` - - context.stream.withSequence().on(Buffer.from(command, 'ascii'), () => Buffer.from(response)) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, video, audio)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/shinybow/v2.test.ts b/src/tests/main/drivers/shinybow/v2.test.ts deleted file mode 100644 index f74ff59..0000000 --- a/src/tests/main/drivers/shinybow/v2.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '75FB7ED2-EE3A-46D5-B11F-7D8C3C208E7C' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/shinybow/v2') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - const command = `POWER 01;\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - const command = `POWER 00;\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const output = 20 - const toResp = (n: number) => String(n).padStart(2, '0') - const command = `OUTPUT${toResp(output)} ${toResp(input)};\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, output, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/shinybow/v3.test.ts b/src/tests/main/drivers/shinybow/v3.test.ts deleted file mode 100644 index d6d9fa1..0000000 --- a/src/tests/main/drivers/shinybow/v3.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = 'BBED08A1-C749-4733-8F2E-96C9B56C0C41' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/shinybow/v3') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - const command = `POWER 001;\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - const command = `POWER 000;\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const output = 202 - const toResp = (n: number) => String(n).padStart(3, '0') - const command = `OUTPUT${toResp(output)} ${toResp(input)};\r\n` - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, output, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/sony/rs485.test.ts b/src/tests/main/drivers/sony/rs485.test.ts deleted file mode 100644 index 7926d74..0000000 --- a/src/tests/main/drivers/sony/rs485.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/sony/rs485') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - const command = Buffer.of(0x02, 4, 0xc0, 0xc0, 0x29, 0x3e, 0x15) - // Packet type: Command == 0x02 - // Packet contents length: 4 - // Command packet: - // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Command: PowerOn == 0x293e (in BE) - // Checksum: 0x15 - - context.stream.withSequence().on(command, () => Buffer.from('Good')) - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - const commnad = Buffer.of(0x02, 4, 0xc0, 0xc0, 0x2a, 0x3e, 0x14) - // Packet type: Command == 0x02 - // Packet contents length: 4 - // Command packet: - // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Command: PowerOff == 0x2a3e (in BE) - // Checksum: 0x14 - - context.stream.withSequence().on(commnad, () => Buffer.from('Good')) - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const command = Buffer.of(0x02, 6, 0xc0, 0xc0, 0x21, 0x00, input, 0x01, 0x57) - // Packet type: Command == 0x02 - // Packet contents length: 6 - // Command packet: - // Destination: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Source: All monitors == 0xc0 OR Address == 0; => 0xc0 - // Command: Set Channel == 0x2100 (in BE) - // Channel: 1 - // Unknown: Always 0x01 - // Checksum: 0x57 - - context.stream.withSequence().on(command, () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesla-smart/kvm.test.ts b/src/tests/main/drivers/tesla-smart/kvm.test.ts deleted file mode 100644 index 6cd80c8..0000000 --- a/src/tests/main/drivers/tesla-smart/kvm.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '91D5BC95-A8E2-4F58-BCAC-A77BA1054D61' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesla-smart/kvm') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const command = Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee) - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesla-smart/matrix.test.ts b/src/tests/main/drivers/tesla-smart/matrix.test.ts deleted file mode 100644 index 3e9737d..0000000 --- a/src/tests/main/drivers/tesla-smart/matrix.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '671824ED-0BC4-43A6-85CC-4877890A7722' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesla-smart/matrix') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const output = 2 - - const toResp = (n: number) => String(n).padStart(2, '0') - const command = `MT00SW${toResp(input)}${toResp(output)}NT\r\n` - - context.stream.withSequence().on(Buffer.from(command, 'ascii'), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, output, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesla-smart/sdi.test.ts b/src/tests/main/drivers/tesla-smart/sdi.test.ts deleted file mode 100644 index b2e8f5a..0000000 --- a/src/tests/main/drivers/tesla-smart/sdi.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = 'DDB13CBC-ABFC-405E-9EA6-4A999F9A16BD' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesla-smart/sdi') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const command = Buffer.of(0xaa, 0xcc, 0x01, input) - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesmart/kvm.test.ts b/src/tests/main/drivers/tesmart/kvm.test.ts deleted file mode 100644 index 9a96be1..0000000 --- a/src/tests/main/drivers/tesmart/kvm.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '2B4EDB8E-D2D6-4809-BA18-D5B1785DA028' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesmart/kvm') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const command = Buffer.of(0xaa, 0xbb, 0x03, 0x01, input, 0xee) - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesmart/matrix.test.ts b/src/tests/main/drivers/tesmart/matrix.test.ts deleted file mode 100644 index b8dc52f..0000000 --- a/src/tests/main/drivers/tesmart/matrix.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '01B8884C-1D7D-4451-883D-3C8F18E17B14' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesmart/matrix') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const output = 20 - - const toResp = (n: number) => String(n).padStart(2, '0') - const command = `MT00SW${toResp(input)}${toResp(output)}NT\r\n` - - context.stream.withSequence().on(Buffer.from(command, 'ascii'), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, output, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/drivers/tesmart/sdi.test.ts b/src/tests/main/drivers/tesmart/sdi.test.ts deleted file mode 100644 index 2e6217a..0000000 --- a/src/tests/main/drivers/tesmart/sdi.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { beforeAll, beforeEach, expect, test, vi } from 'vitest' -import type useDriversFn from '../../../../main/services/drivers' -import type { MockStreamContext } from '../../../support/stream' - -const mock = await vi.hoisted(async () => await import('../../../support/mock')) -const port = await vi.hoisted(async () => await import('../../../support/serial')) -const stream = await vi.hoisted(async () => await import('../../../support/stream')) - -let context: MockStreamContext -beforeAll(() => { - vi.mock('electron-log') - vi.mock('serialport', port.serialPortModule) - - // HACK: Force it, since we will stream in beforeEach - context = {} as MockStreamContext - vi.doMock('../../../../main/services/stream', stream.commandStreamModule(context)) -}) - -let drivers: ReturnType -beforeEach(async () => { - mock.console() - context.stream = new stream.MockCommandStream() - drivers = (await import('../../../../main/services/drivers')).default() -}) - -const kDriverGuid = '8C524E65-83EF-4AEF-B0DA-29C4582AA4A0' - -test('available', async () => { - const { default: driver } = await import('../../../../main/drivers/tesmart/sdi') - - await expect(drivers.registered()).resolves.toContainEqual(driver) - await expect(drivers.allInfo()).resolves.toContainEqual(driver.metadata) - await expect(drivers.get(kDriverGuid)).resolves.toStrictEqual(driver) - const { capabilities, enabled, experimental, guid, kind } = driver.metadata - expect(driver.getInfo('en')).toStrictEqual({ - ...driver.metadata.localized.en, - capabilities, - enabled, - experimental, - guid, - kind - }) -}) - -test('power on', async () => { - context.stream.withSequence() - - await expect(drivers.powerOn(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('power off', async () => { - context.stream.withSequence() - - await expect(drivers.powerOff(kDriverGuid, 'port:/dev/ttyS0')).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) - -test('activate tie', async () => { - const input = 1 - const command = Buffer.of(0xaa, 0xcc, 0x01, input) - - context.stream.withSequence().on(Buffer.from(command), () => Buffer.from('Good')) - - await expect(drivers.activate(kDriverGuid, 'port:/dev/ttyS0', input, 0, 0)).resolves.toBeUndefined() - context.stream.sequence.expectDone() -}) diff --git a/src/tests/main/services/app.test.ts b/src/tests/main/services/app.test.ts deleted file mode 100644 index 9551422..0000000 --- a/src/tests/main/services/app.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeEach(() => { - vi.mock('electron', mock.electronModule()) - vi.mock('electron-log', mock.electronLogModule) -}) - -test('ready', async () => { - const { default: useAppInfo } = await import('../../../main/services/app') - const appInfo = useAppInfo() - - expect(appInfo).toStrictEqual({ - name: 'BridgeCmdr==mock==', - version: '2.0.0==mock==' - }) -}) diff --git a/src/tests/main/services/boot.test.ts b/src/tests/main/services/boot.test.ts deleted file mode 100644 index eba3756..0000000 --- a/src/tests/main/services/boot.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { omit } from 'radash' -import { beforeAll, expect, test, vi } from 'vitest' -import { z } from 'zod' -import { Source } from '../../../main/dao/sources' -import useZod from '../../support/vitestZod' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('boot')) - vi.mock('electron-log', mock.electronLogModule) - // Disable memoize caching. - vi.mock('radash', async (original) => ({ - ...(await original()), - memo: (x: unknown) => x - })) - - useZod() -}) - -test('migration', async () => { - const { seedForBoot } = await import('../../seeds/boot.seed') - const { sources } = await seedForBoot() - - const { default: useBootOperations } = await import('../../../main/services/boot') - - const boot = useBootOperations() - await boot() - - const { default: useSourcesDatabase } = await import('../../../main/dao/sources') - - const sourceDao = useSourcesDatabase() - - const booted = { - sources: await sourceDao.all() - } - - expect(booted.sources).toMatchSchema(z.array(Source)) - for (const source of booted.sources) { - expect(Number.isSafeInteger(source.order), 'Expected source.order to be an integer').toBe(true) - } - - expect(booted.sources).toMatchObject(sources.map((source) => omit(source, ['order', '_rev']))) - - await sourceDao.close() -}) diff --git a/src/tests/main/services/database.test.ts b/src/tests/main/services/database.test.ts deleted file mode 100644 index cff90f2..0000000 --- a/src/tests/main/services/database.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { map, memo, omit } from 'radash' -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import { z } from 'zod' -import { Database } from '../../../main/services/database' -import { Attachment } from '@/attachments' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) -const port = await vi.hoisted(async () => await import('../../support/serial')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('database')) - vi.mock('serialport', port.serialPortModule) -}) - -const kUuidPattern = /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u -const kRevPattern = /^[0-9]-[0-9a-f]{32}$/u -const Schema = z.object({ - x: z.number().finite(), - y: z.number().finite(), - z: z.number().finite() -}) - -let database: Database -beforeEach(async () => { - const indices = [[['x']], { r: ['y', 'z'] }] - const useDatabase = memo(() => new Database('db', Schema, ...indices)) - database = useDatabase() - - await database.clear() -}) - -test('test creation', () => { - expect(database).toBeTypeOf('object') - expect(database).not.toBeNull() -}) - -test('compacting', async () => { - await expect(database.compact()).resolves.toBeUndefined() -}) - -describe('query', () => { - let rawDocs: Awaited>[] - const rawInputs = [ - { x: 1, y: 2, z: 3 }, - { x: 2, y: 3, z: 4 }, - { x: 3, y: 4, z: 5 }, - { x: 4, y: 5, z: 6 } - ] - - beforeEach(async () => { - rawDocs = await map(rawInputs, async (input) => await database.add(input)) - }) - - test('all documents', async () => { - const docs = await database.all() - for (const doc of docs) { - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc._attachments).toBeInstanceOf(Array) - expect(doc._attachments.length).toBe(0) - expect(rawDocs).toContainEqual(doc) - } - }) - - test('single documents', async () => { - for (const rawDoc of rawDocs) { - // eslint-disable-next-line no-await-in-loop -- Should be serialized. - const doc = await database.get(rawDoc._id) - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc._attachments).toBeInstanceOf(Array) - expect(doc._attachments.length).toBe(0) - expect(rawDocs).toContainEqual(doc) - } - }) -}) - -describe('adding document', () => { - test('just document', async () => { - const raw = { x: 3, y: 4, z: 5 } - const doc = await database.add(raw) - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc._attachments).toStrictEqual([]) - expect(doc).toStrictEqual({ - ...raw, - _id: doc._id, - _rev: doc._rev, - _attachments: doc._attachments - }) - }) - - test('with attachments', async () => { - const raw = { x: 3, y: 4, z: 5 } - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - const attachment = await Attachment.fromFile(file) - const doc = await database.add(raw, attachment) - expect(doc._id).toMatch(kUuidPattern) - expect(doc._rev).toMatch(kRevPattern) - expect(doc).toStrictEqual({ - ...raw, - _id: doc._id, - _rev: doc._rev, - _attachments: [attachment] - }) - }) -}) - -describe('updating document', () => { - let raw: { x: number; y: number; z: number } - let attachment: Attachment - let doc: Awaited> - beforeEach(async () => { - raw = { x: 3, y: 4, z: 5 } - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - attachment = await Attachment.fromFile(file) - doc = await database.add(raw) - }) - - test('just document', async () => { - const changes = { y: 14 } - const updated = await database.update({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) - - describe('with attachments', () => { - test('as parameter', async () => { - const changes = { y: 14 } - const updated = await database.update({ _id: doc._id, ...changes }, attachment) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing on update payload', async () => { - const attached = await database.update({ _id: doc._id, y: 11 }, attachment) - - const changes = { ...attached, y: 14 } - const updated = await database.update({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing in database', async () => { - await expect(database.update({ _id: doc._id, y: 11 }, attachment)).resolves.toBeTruthy() - - const changes = { ...doc, y: 14 } - const updated = await database.update({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - }) - - test('strips unknown fields', async () => { - const changes = { y: 14, a: 5 } - const updated = await database.update({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...omit(changes, ['a']), - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) -}) - -describe('upserting document', () => { - let raw: { x: number; y: number; z: number } - let attachment: Attachment - let doc: Awaited> - beforeEach(async () => { - raw = { x: 3, y: 4, z: 5 } - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - attachment = await Attachment.fromFile(file) - doc = await database.add(raw) - }) - - test('new document', async () => { - const id = randomUUID() - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.upsert({ _id: id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: id.toUpperCase(), - _rev: updated._rev, - _attachments: [] - }) - }) - - describe('existing document', () => { - test('just document', async () => { - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.upsert({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) - - describe('with attachments', () => { - test('as parameter', async () => { - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.upsert({ _id: doc._id, ...changes }, attachment) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing on update payload', async () => { - const attached = await database.update({ _id: doc._id, y: 11 }, attachment) - - const changes = { _id: doc._id, x: 13, y: 14, z: 15, _attachments: attached._attachments } - const updated = await database.upsert({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing in database', async () => { - await expect(database.update({ _id: doc._id, y: 11 }, attachment)).resolves.toBeTruthy() - - const changes = { _id: doc._id, x: 13, y: 14, z: 15 } - const updated = await database.upsert({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - }) - - test('strips unknown fields', async () => { - const changes = { x: 13, y: 14, z: 15, a: 5 } - const updated = await database.upsert({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...omit(changes, ['a']), - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) - }) -}) - -describe('replacing document', () => { - let raw: { x: number; y: number; z: number } - let attachment: Attachment - let doc: Awaited> - beforeEach(async () => { - raw = { x: 3, y: 4, z: 5 } - const file = new File([Buffer.from('hello')], 'hello.txt', { type: 'text/plain' }) - attachment = await Attachment.fromFile(file) - doc = await database.add(raw) - }) - - test('new document', async () => { - const id = randomUUID() - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.replace({ _id: id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: id.toUpperCase(), - _rev: updated._rev, - _attachments: [] - }) - }) - - describe('existing document', () => { - test('just document', async () => { - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.replace({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) - - describe('with attachments', () => { - test('as parameter', async () => { - const changes = { x: 13, y: 14, z: 15 } - const updated = await database.replace({ _id: doc._id, ...changes }, attachment) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing on update payload', async () => { - const attached = await database.update({ _id: doc._id, y: 11 }, attachment) - - const changes = { _id: doc._id, x: 13, y: 14, z: 15, _attachments: attached._attachments } - const updated = await database.replace({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [attachment] - }) - }) - - test('existing in database, not transferred', async () => { - await expect(database.update({ _id: doc._id, y: 11 }, attachment)).resolves.toBeTruthy() - - const changes = { _id: doc._id, x: 13, y: 14, z: 15 } - const updated = await database.replace({ ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...changes, - _id: doc._id, - _rev: updated._rev, - _attachments: [] - }) - }) - }) - - test('strips unknown fields', async () => { - const changes = { x: 13, y: 14, z: 15, a: 5 } - const updated = await database.replace({ _id: doc._id, ...changes }) - expect(updated._rev).toMatch(kRevPattern) - expect(updated).toStrictEqual({ - ...raw, - ...omit(changes, ['a']), - _id: doc._id, - _rev: updated._rev, - _attachments: doc._attachments - }) - }) - }) -}) - -test('removing document', async () => { - const raw = { x: 3, y: 4, z: 5 } - const doc = await database.add(raw) - await expect(database.remove(doc._id)).resolves.toBeUndefined() - await expect(database.get(doc._id)).rejects.toThrow('missing') -}) - -test('clearing database', async () => { - const inputs = [ - { x: 1, y: 2, z: 3 }, - { x: 2, y: 3, z: 4 }, - { x: 3, y: 4, z: 5 }, - { x: 4, y: 5, z: 6 } - ] - - const docs = await map(inputs, async (input) => await database.add(input)) - - await expect(database.clear()).resolves.toBeUndefined() - await Promise.all( - docs.map(async (doc) => { - await expect(database.get(doc._id)).rejects.toThrow('missing') - }) - ) -}) diff --git a/src/tests/main/services/desktop.test.ts b/src/tests/main/services/desktop.test.ts deleted file mode 100644 index f308a7e..0000000 --- a/src/tests/main/services/desktop.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from 'vitest' -import { readyEntry } from '../../../main/services/desktop' -import type { DesktopEntryFile } from '../../../main/services/desktop' - -test('readyEntry', () => { - const entry: DesktopEntryFile = { - 'Desktop Entry': { - Type: 'Application', - Name: 'BridgeCmdr', - Exec: 'test path', - NoDisplay: true, - Terminal: false, - Categories: ['utilities', 'accessories'] - } - } - - expect(readyEntry(entry)).toStrictEqual({ - 'Desktop Entry': { - Type: 'Application', - Name: 'BridgeCmdr', - Exec: 'test path', - NoDisplay: true, - Terminal: false, - Categories: 'utilities;accessories' - } - }) -}) diff --git a/src/tests/main/services/drivers.test.ts b/src/tests/main/services/drivers.test.ts deleted file mode 100644 index 6bcddce..0000000 --- a/src/tests/main/services/drivers.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import useDrivers, { - defineDriver, - kDeviceCanDecoupleAudioOutput, - kDeviceSupportsMultipleOutputs -} from '../../../main/services/drivers' -import type { Driver, DriverInformation } from '../../../main/services/drivers' -import type { UUID } from 'node:crypto' -import type { Mock } from 'vitest' - -let badDriverId: UUID -let testDriverId: UUID -let information: DriverInformation -let driver: Driver -let activateSpy: Mock -let powerOffSpy: Mock -let powerOnSpy: Mock - -beforeAll(() => { - badDriverId = '292709CD-5BB9-44A6-BEF2-B3980276E064' - testDriverId = '4AACE202-FB07-49C8-B4FE-20C4C6C73788' - information = { - enabled: true, - experimental: false, - kind: 'switch', - guid: testDriverId, - localized: { - en: { - title: 'Basic test driver', - company: 'BridgeCmdr contributors', - provider: 'BridgeCmdr contributors' - } - }, - capabilities: kDeviceSupportsMultipleOutputs | kDeviceCanDecoupleAudioOutput - } - activateSpy = vi.fn() - powerOffSpy = vi.fn() - powerOnSpy = vi.fn() - driver = defineDriver({ - ...information, - setup: () => ({ - activate: activateSpy, - powerOff: powerOffSpy, - powerOn: powerOnSpy - }) - }) -}) - -beforeEach(() => { - activateSpy.mockResolvedValue(undefined).mockName('driver.activate') - powerOffSpy.mockResolvedValue(undefined).mockName('driver.powerOff') - powerOnSpy.mockResolvedValue(undefined).mockName('driver.powerOn') -}) - -describe('defining a driver', () => { - test('new driver', () => { - expect(driver.metadata).toStrictEqual(information) - }) - - test('existing driver', () => { - const duplicate = defineDriver({ - ...information, - setup: () => ({ - activate: vi.fn().mockResolvedValue(undefined).mockName('driver.activate'), - powerOff: vi.fn().mockResolvedValue(undefined).mockName('driver.powerOff'), - powerOn: vi.fn().mockResolvedValue(undefined).mockName('driver.powerOn') - }) - }) - - expect(driver).toBe(duplicate) - }) - - test('getting driver', async () => { - const drivers = useDrivers() - await expect(drivers.get(testDriverId)).resolves.toBe(driver) - await expect(drivers.get(badDriverId)).resolves.toBe(null) - }) -}) - -describe('calling drivers', () => { - test('existing driver', async () => { - const drivers = useDrivers() - await expect(drivers.activate(testDriverId, 'ip:uri', 1, 2, 3)).resolves.toBeUndefined() - expect(activateSpy).toHaveBeenCalledWith('ip:uri', 1, 2, 3) - await expect(drivers.powerOff(testDriverId, 'ip:uri')).resolves.toBeUndefined() - expect(powerOffSpy).toHaveBeenCalledWith('ip:uri') - await expect(drivers.powerOn(testDriverId, 'ip:uri')).resolves.toBeUndefined() - expect(powerOnSpy).toHaveBeenCalledWith('ip:uri') - }) - - test('bad location', async () => { - const drivers = useDrivers() - await expect(drivers.activate(testDriverId, 'badfood', 1, 2, 3)).rejects.toThrow( - '"badfood" is not a valid location' - ) - expect(activateSpy).not.toHaveBeenCalled() - await expect(drivers.powerOff(testDriverId, 'badfood')).rejects.toThrow('"badfood" is not a valid location') - expect(powerOffSpy).not.toHaveBeenCalled() - await expect(drivers.powerOn(testDriverId, 'badfood')).rejects.toThrow('"badfood" is not a valid location') - expect(powerOnSpy).not.toHaveBeenCalled() - }) - - test('non-existing driver', async () => { - const drivers = useDrivers() - await expect(drivers.activate(badDriverId, 'ip:uri', 1, 2, 3)).rejects.toThrow(`No such driver: "${badDriverId}"`) - expect(activateSpy).not.toHaveBeenCalled() - await expect(drivers.powerOff(badDriverId, 'ip:uri')).rejects.toThrow(`No such driver: "${badDriverId}"`) - expect(powerOffSpy).not.toHaveBeenCalled() - await expect(drivers.powerOn(badDriverId, 'ip:uri')).rejects.toThrow(`No such driver: "${badDriverId}"`) - expect(powerOnSpy).not.toHaveBeenCalled() - }) -}) diff --git a/src/tests/main/services/level.test.ts b/src/tests/main/services/level.test.ts deleted file mode 100644 index 6036df9..0000000 --- a/src/tests/main/services/level.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test, expect, vi, beforeEach } from 'vitest' -import { withResolvers } from '@/basics' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeEach(() => { - vi.mock('electron', mock.electronModule('level')) - vi.mock('electron-log', mock.electronLogModule) -}) - -test('basics', async () => { - const { useLevelDb } = await import('../../../main/services/level') - - const { levelup } = useLevelDb() - const connectSpy = vi.fn(levelup).mockName('levelup') - - const db = await connectSpy('test') - expect(connectSpy).toHaveResolved() - - await expect(db.put('test', 'test')).resolves.toBeUndefined() - await expect(db.get('test')).resolves.toStrictEqual(Buffer.from('test')) - await expect(db.close()).resolves.toBeUndefined() -}) - -test('app exit', async () => { - const { app } = await import('electron') - const { useLevelDb } = await import('../../../main/services/level') - - const { leveldown } = useLevelDb() - const db = leveldown('test') - - const { resolve, promise } = withResolvers() - const closeSpy = vi.spyOn(db, 'close').mockName('db.close') - app.on('will-quit', resolve) - - app.emit('will-quit') - - await promise - expect(closeSpy).toHaveBeenCalledOnce() -}) diff --git a/src/tests/main/services/migration.test.ts b/src/tests/main/services/migration.test.ts deleted file mode 100644 index 19354ae..0000000 --- a/src/tests/main/services/migration.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { omit } from 'radash' -import { beforeAll, expect, test, vi } from 'vitest' -import { z } from 'zod' -import { Device } from '../../../main/dao/devices' -import { Source } from '../../../main/dao/sources' -import { Tie } from '../../../main/dao/ties' -import useZod from '../../support/vitestZod' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -beforeAll(() => { - vi.mock('electron', mock.electronModule('migration')) - vi.mock('electron-log', mock.electronLogModule) - // Disable memoize caching. - vi.mock('radash', async (original) => ({ - ...(await original()), - memo: (x: unknown) => x - })) - - useZod() -}) - -test('migration', async () => { - const { seedForMigration } = await import('../../seeds/migration.seed') - const { switches, sources, ties } = await seedForMigration() - - const { default: useMigrations } = await import('../../../main/services/migration') - - const migrate = useMigrations() - await migrate() - - const { default: useDevicesDatabase } = await import('../../../main/dao/devices') - const { default: useSourcesDatabase } = await import('../../../main/dao/sources') - const { default: useTiesDatabase } = await import('../../../main/dao/ties') - - const deviceDao = useDevicesDatabase() - const sourceDao = useSourcesDatabase() - const tieDao = useTiesDatabase() - - const migrated = { - devices: await deviceDao.all(), - sources: await sourceDao.all(), - ties: await tieDao.all() - } - - expect(migrated.devices).toMatchSchema(z.array(Device)) - expect(migrated.sources).toMatchSchema(z.array(Source)) - expect(migrated.ties).toMatchSchema(z.array(Tie)) - - expect(migrated.devices).toMatchObject(switches.map((device) => omit(device, ['_rev']))) - expect(migrated.sources).toMatchObject(sources.map((source) => omit(source, ['_rev']))) - expect(migrated.ties).toMatchObject( - ties.map(({ switchId, ...tie }) => omit({ ...tie, deviceId: switchId }, ['_rev'])) - ) - - await deviceDao.close() - await sourceDao.close() - await tieDao.close() -}) - -test('rerun migration', async () => { - vi.resetModules() - - const { default: useMigrations } = await import('../../../main/services/migration') - - const migrate = useMigrations() - await expect(migrate()).resolves.toBeUndefined() -}) diff --git a/src/tests/main/services/ports.test.ts b/src/tests/main/services/ports.test.ts deleted file mode 100644 index b1ef265..0000000 --- a/src/tests/main/services/ports.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest' - -beforeEach(() => { - // Need to reset the port service state to check some duplicate paths. - vi.resetModules() -}) - -test('no ports', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([]) - const { default: useSerialPorts } = await import('../../../main/services/ports') - const ports = await useSerialPorts().listPorts() - expect(ports).toStrictEqual([]) -}) - -test('list simple ports', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS0', - pnpId: undefined, - productId: '8087', - serialNumber: '1', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS1', - pnpId: undefined, - serialNumber: '2', - productId: '8087', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - pnpId: undefined, - serialNumber: '3', - path: '/dev/ttyS2', - productId: '8087', - vendorId: '8086' - } - ]) - - const { default: useSerialPorts } = await import('../../../main/services/ports') - const ports = await useSerialPorts().listPorts() - expect(ports).toStrictEqual([ - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS0', - pnpId: undefined, - productId: '8087', - serialNumber: '1', - title: 'Mock Serial Port', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS1', - pnpId: undefined, - serialNumber: '2', - productId: '8087', - title: 'Mock Serial Port', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - pnpId: undefined, - serialNumber: '3', - path: '/dev/ttyS2', - productId: '8087', - title: 'Mock Serial Port', - vendorId: '8086' - } - ]) -}) - -test('valid ports', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS0', - pnpId: undefined, - productId: '8087', - serialNumber: '1', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - path: '/dev/ttyS1', - pnpId: undefined, - serialNumber: '2', - productId: '8087', - vendorId: '8086' - }, - { - locationId: undefined, - manufacturer: 'Mock Serial Port', - pnpId: undefined, - serialNumber: '3', - path: '/dev/ttyS2', - productId: '8087', - vendorId: '8086' - } - ]) - - const { default: useSerialPorts } = await import('../../../main/services/ports') - await expect(useSerialPorts().isValidPort('/dev/ttyS0')).resolves.toBe(true) - await expect(useSerialPorts().isValidPort('/dev/ttyS1')).resolves.toBe(true) - await expect(useSerialPorts().isValidPort('/dev/ttyS2')).resolves.toBe(true) - await expect(useSerialPorts().isValidPort('/dev/ttyS3')).resolves.toBe(false) -}) - -describe('parsing PnP ID', () => { - test('USB PnP ID ports', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB0' - }, - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-if00', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB1' - }, - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB2' - }, - // repeat - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB3' - } - ]) - - const { default: useSerialPorts } = await import('../../../main/services/ports') - const ports = await useSerialPorts().listPorts() - expect(ports).toStrictEqual([ - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB0', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI FT232R USB UART XXXXXXXX', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB1', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-if00', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI FT232R USB UART XXXXXXXX', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB2', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI FT232R USB UART XXXXXXXX', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB3', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI FT232R USB UART XXXXXXXX', - vendorId: '0403' - } - ]) - }) - - test('Unknown PnP ID ports', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - // Missing port and interface. - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB0' - }, - // Missing bus. - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB1' - }, - // Missing bus, port, and interface. - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB2' - }, - // Missing a lot. - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'if00-port0-port1', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB3' - }, - // Missing bus; repeat - { - manufacturer: 'FTDI', - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB4' - } - ]) - - const { default: useSerialPorts } = await import('../../../main/services/ports') - const ports = await useSerialPorts().listPorts() - expect(ports).toStrictEqual([ - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB0', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB1', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB2', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB3', - pnpId: 'if00-port0-port1', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: 'FTDI', - path: '/dev/ttyUSB4', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: 'FTDI', - vendorId: '0403' - } - ]) - }) - - test('Unknown PnP ID ports; no manufacturer', async () => { - const { SerialPort } = await import('serialport') - vi.spyOn(SerialPort, 'list').mockResolvedValue([ - // Missing port and interface. - { - manufacturer: undefined, - serialNumber: 'XXXXXXXX', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB0' - }, - // Missing bus. - { - manufacturer: undefined, - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB1' - }, - // Missing bus, port, and interface. - { - manufacturer: undefined, - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB2' - }, - // Missing a lot. - { - manufacturer: undefined, - serialNumber: 'XXXXXXXX', - pnpId: 'if00-port0-port1', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB3' - }, - // Missing bus; repeat - { - manufacturer: undefined, - serialNumber: 'XXXXXXXX', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - locationId: undefined, - vendorId: '0403', - productId: '6001', - path: '/dev/ttyUSB4' - } - ]) - - const { default: useSerialPorts } = await import('../../../main/services/ports') - const ports = await useSerialPorts().listPorts() - expect(ports).toStrictEqual([ - { - locationId: undefined, - manufacturer: undefined, - path: '/dev/ttyUSB0', - pnpId: 'usb-FTDI_FT232R_USB_UART_XXXXXXXX', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: '/dev/ttyUSB0', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: undefined, - path: '/dev/ttyUSB1', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: '/dev/ttyUSB1', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: undefined, - path: '/dev/ttyUSB2', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: '/dev/ttyUSB2', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: undefined, - path: '/dev/ttyUSB3', - pnpId: 'if00-port0-port1', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: '/dev/ttyUSB3', - vendorId: '0403' - }, - { - locationId: undefined, - manufacturer: undefined, - path: '/dev/ttyUSB4', - pnpId: 'FTDI_FT232R_USB_UART_XXXXXXXX-if00-port0', - productId: '6001', - serialNumber: 'XXXXXXXX', - title: '/dev/ttyUSB4', - vendorId: '0403' - } - ]) - }) -}) diff --git a/src/tests/main/services/protocols/sonyRs485.test.ts b/src/tests/main/services/protocols/sonyRs485.test.ts deleted file mode 100644 index 7807902..0000000 --- a/src/tests/main/services/protocols/sonyRs485.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest' -import { - createAddress, - createCommand, - createPacket, - kAddressAll, - kAddressGroup, - kAddressMonitor, - kCommandPacket, - kPowerOn, - PacketError, - SonyDriverError -} from '../../../../main/services/protocols/sonyRs485' -import type { Address } from '../../../../main/services/protocols/sonyRs485' - -describe('errors', () => { - test('SonyDriverError', () => { - expect(new SonyDriverError().message).toBe('[Sony Driver]: Unknown error') - }) - test('PacketError', () => { - expect(new PacketError().message).toBe('[Sony Driver]: Unknown package error') - }) -}) - -describe('createPackage', () => { - test('good package', () => { - expect(createPacket(kCommandPacket, Buffer.alloc(5))).toStrictEqual( - Buffer.from([0x02, 0x05, 0x0, 0x0, 0x0, 0x0, 0x0, 0xfb]) - ) - }) - test('empty package', () => { - expect(() => createPacket(kCommandPacket, Buffer.alloc(0))).toThrow( - new PacketError('Attempting to send empty packet') - ) - }) - test('oversized package', () => { - expect(() => createPacket(kCommandPacket, Buffer.alloc(256))).toThrow( - new PacketError('Packet is too large at 256B') - ) - }) -}) - -test('createAddress', () => { - expect(createAddress(kAddressAll, 5)).toBe(0xc5) - expect(createAddress(kAddressGroup, 5)).toBe(0x85) - expect(createAddress(kAddressMonitor, 5)).toBe(0x05) -}) - -describe('createCommand', () => { - let address: Address - beforeAll(() => { - address = createAddress(kAddressAll, 0xf) - }) - test('no arguments', () => { - expect(createCommand(address, address, kPowerOn)).toStrictEqual( - Buffer.from([0x02, 0x04, 0xcf, 0xcf, 0x29, 0x3e, 0xf7]) - ) - }) - test('one argument', () => { - expect(createCommand(address, address, kPowerOn, 4)).toStrictEqual( - Buffer.from([0x02, 0x05, 0xcf, 0xcf, 0x29, 0x3e, 0x04, 0xf2]) - ) - }) - test('two arguments', () => { - expect(createCommand(address, address, kPowerOn, 3, 2)).toStrictEqual( - Buffer.from([0x02, 0x06, 0xcf, 0xcf, 0x29, 0x3e, 0x03, 0x02, 0xf0]) - ) - }) -}) diff --git a/src/tests/main/services/startup.test.ts b/src/tests/main/services/startup.test.ts deleted file mode 100644 index e57fc5a..0000000 --- a/src/tests/main/services/startup.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import type { Mock } from 'vitest' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -let isEnabled: boolean | undefined -let xdgConfig: string | undefined -let iniFile: string | undefined -let statSpy: Mock<(path: string) => Promise<{ isFile: () => boolean }>> -let mkdirSpy: Mock -let readSpy: Mock -let writeSpy: Mock -let unlinkSpy: Mock -beforeAll(() => { - vi.mock('electron', mock.electronModule()) - vi.mock('electron-log', mock.electronLogModule) - vi.mock('node:os', () => ({ - homedir: () => '/home/user' - })) - // Disable memoize caching. - vi.mock('radash', async (original) => ({ - ...(await original()), - memo: (x: unknown) => x - })) - vi.doMock('node:fs/promises', () => { - statSpy = vi - .fn(async (path) => - (isEnabled ?? false) && path === '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop' - ? await Promise.resolve({ - isFile() { - return true - } - }) - : await Promise.reject(new Error('ENOENT')) - ) - .mockName('fs.stat') - mkdirSpy = vi.fn().mockResolvedValue(undefined).mockName('fs.mkdir') - readSpy = vi - .fn(async () => (iniFile ? await Promise.resolve(iniFile) : await Promise.reject(new Error('ENOENT')))) - .mockName('fs.readFile') - writeSpy = vi.fn().mockResolvedValue(undefined).mockName('fs.writeFile') - unlinkSpy = vi.fn().mockResolvedValue(undefined).mockName('fs.unlink') - return { - stat: statSpy, - mkdir: mkdirSpy, - readFile: readSpy, - writeFile: writeSpy, - unlink: unlinkSpy - } - }) - vi.doMock('xdg-basedir', () => ({ - get xdgConfig() { - return xdgConfig ?? null - } - })) -}) - -beforeEach(() => { - isEnabled = false - xdgConfig = undefined - iniFile = undefined -}) - -describe('checking enabled', () => { - test('not enabled', async () => { - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkEnabled()).resolves.toBe(false) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - }) - test('XDG configuration path defined', async () => { - isEnabled = true - xdgConfig = '/home/user/.config' - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkEnabled()).resolves.toBe(true) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - }) - test('XDG configuration path not defined', async () => { - isEnabled = true - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkEnabled()).resolves.toBe(true) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - }) -}) - -describe('enable', () => { - test('APPIMAGE defined', async () => { - vi.stubEnv('APPIMAGE', '/home/user/apps/BridgeCmdr') - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.enable()).resolves.toBe(undefined) - expect(mkdirSpy).toBeCalledWith('/home/user/.config/autostart', { recursive: true }) - expect(writeSpy).toBeCalledWith( - '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/home/user/apps/BridgeCmdr\nNoDisplay=true\nTerminal=false\n', - { encoding: 'utf-8', mode: 0o644, flag: 'w' } - ) - }) - test('APPIMAGE not defined', async () => { - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.enable()).resolves.toBe(undefined) - expect(mkdirSpy).toBeCalledWith('/home/user/.config/autostart', { recursive: true }) - expect(writeSpy).toBeCalledWith( - '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/usr/bin/BridgeCmdr\nNoDisplay=true\nTerminal=false\n', - { encoding: 'utf-8', mode: 0o644, flag: 'w' } - ) - }) -}) - -test('disable', async () => { - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.disable()).resolves.toBe(undefined) - expect(unlinkSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') -}) - -describe('checkUp', () => { - test('not enabled', async () => { - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkUp()).resolves.toBe(undefined) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - expect(readSpy).not.toBeCalled() - expect(mkdirSpy).not.toBeCalled() - expect(writeSpy).not.toBeCalled() - }) - test('enabled; right path', async () => { - isEnabled = true - iniFile = - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/usr/bin/BridgeCmdr\nNoDisplay=true\nTerminal=false\n' - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkUp()).resolves.toBe(undefined) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - expect(readSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', { - encoding: 'utf-8' - }) - expect(mkdirSpy).not.toBeCalled() - expect(writeSpy).not.toBeCalled() - }) - test('enabled; file disappears', async () => { - isEnabled = true - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkUp()).resolves.toBe(undefined) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - expect(readSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', { - encoding: 'utf-8' - }) - expect(mkdirSpy).toBeCalledWith('/home/user/.config/autostart', { recursive: true }) - expect(writeSpy).toBeCalledWith( - '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/usr/bin/BridgeCmdr\nNoDisplay=true\nTerminal=false\n', - { encoding: 'utf-8', mode: 0o644, flag: 'w' } - ) - }) - test('enabled; bad entry', async () => { - isEnabled = true - iniFile = '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nNoDisplay=true\nTerminal=false\n' - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkUp()).resolves.toBe(undefined) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - expect(readSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', { - encoding: 'utf-8' - }) - expect(mkdirSpy).toBeCalledWith('/home/user/.config/autostart', { recursive: true }) - expect(writeSpy).toBeCalledWith( - '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/usr/bin/BridgeCmdr\nNoDisplay=true\nTerminal=false\n', - { encoding: 'utf-8', mode: 0o644, flag: 'w' } - ) - }) - test('enabled; wrong path', async () => { - isEnabled = true - iniFile = - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/home/user/apps/BridgeCmdr\nNoDisplay=true\nTerminal=false\n' - const { default: useStartup } = await import('../../../main/services/startup') - const startup = useStartup() - await expect(startup.checkUp()).resolves.toBe(undefined) - expect(statSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop') - expect(readSpy).toBeCalledWith('/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', { - encoding: 'utf-8' - }) - expect(mkdirSpy).toBeCalledWith('/home/user/.config/autostart', { recursive: true }) - expect(writeSpy).toBeCalledWith( - '/home/user/.config/autostart/org.sleepingcats.BridgeCmdr.desktop', - '[Desktop Entry]\nType=Application\nName=BridgeCmdr\nExec=/usr/bin/BridgeCmdr\nNoDisplay=true\nTerminal=false\n', - { encoding: 'utf-8', mode: 0o644, flag: 'w' } - ) - }) -}) diff --git a/src/tests/main/services/system.test.ts b/src/tests/main/services/system.test.ts deleted file mode 100644 index 62f8476..0000000 --- a/src/tests/main/services/system.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import type { Mock } from 'vitest' - -let execaSpy: Mock - -let stdout = '' -let stderr = '' -let error = null as null | Error - -beforeAll(() => { - vi.mock('electron-log') - - execaSpy = vi.fn() - vi.doMock('execa', () => ({ - execa: execaSpy - })) -}) - -beforeEach(() => { - stdout = '' - stderr = '' - error = null - - execaSpy.mockImplementation(async () => - error == null ? await Promise.resolve({ stdout, stderr, exitCode: stderr ? 1 : 0 }) : await Promise.reject(error) - ) -}) - -describe('powerOff', () => { - test('success', async () => { - stdout = 'shutting down' - const system = (await import('../../../main/services/system')).default() - - await expect(system.powerOff()).resolves.toBeUndefined() - expect(execaSpy).toBeCalledWith('dbus-send', [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - 'boolean:false' - ]) - - execaSpy.mockClear() - await expect(system.powerOff(false)).resolves.toBeUndefined() - expect(execaSpy).toBeCalledWith('dbus-send', [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - 'boolean:false' - ]) - - execaSpy.mockClear() - await expect(system.powerOff(true)).resolves.toBeUndefined() - expect(execaSpy).toBeCalledWith('dbus-send', [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - 'boolean:true' - ]) - }) - - test('execa failed', async () => { - error = new Error('ENOENT: dbus-send not found') - const system = (await import('../../../main/services/system')).default() - - await expect(system.powerOff()).rejects.toThrow(error) - expect(execaSpy).toBeCalledWith('dbus-send', [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - 'boolean:false' - ]) - }) - - test('command failed', async () => { - stderr = 'Permission denied' - const system = (await import('../../../main/services/system')).default() - - await expect(system.powerOff()).rejects.toThrow(stderr) - expect(execaSpy).toBeCalledWith('dbus-send', [ - '--system', - '--print-reply', - '--dest=org.freedesktop.login1', - '/org/freedesktop/login1', - 'org.freedesktop.login1.Manager.PowerOff', - 'boolean:false' - ]) - }) -}) diff --git a/src/tests/main/services/user.test.ts b/src/tests/main/services/user.test.ts deleted file mode 100644 index a7f5883..0000000 --- a/src/tests/main/services/user.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest' - -const mock = await vi.hoisted(async () => await import('../../support/mock')) - -describe('correct setup', () => { - beforeEach(() => { - vi.mock('electron', mock.electronModule()) - vi.mock('electron-log', mock.electronLogModule) - }) - - test('ready', async () => { - const os = await import('node:os') - const { default: useUserInfo } = await import('../../../main/services/user') - const userInfo = useUserInfo() - - expect(userInfo).toStrictEqual({ - name: os.userInfo().username, - locale: 'en==mock==' - }) - }) -}) diff --git a/src/tests/main/utilities.test.ts b/src/tests/main/utilities.test.ts deleted file mode 100644 index 2b7e363..0000000 --- a/src/tests/main/utilities.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest' -import type { Mock } from 'vitest' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let errorSpy: Mock<(...args: any[]) => any> -beforeEach(() => { - vi.mock('electron-log', () => { - errorSpy = vi.fn().mockReturnValue(undefined).mockName('Logger.error') - - return { - default: { - error: errorSpy - } - } - }) -}) - -test('logError', async () => { - const { logError } = await import('../../main/utilities') - const error = new TypeError('Hello') - logError(error) - expect(errorSpy).toHaveBeenCalledWith(error) -}) diff --git a/src/tests/seeds/boot.seed.ts b/src/tests/seeds/boot.seed.ts deleted file mode 100644 index 8a006db..0000000 --- a/src/tests/seeds/boot.seed.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { alphabetical, map } from 'radash' -import useSourcesDatabase from '../../main/dao/sources' -import type { Source } from '../../main/dao/sources' -import { Attachment } from '@/attachments' - -export async function seedForBoot() { - const sourcesDao = useSourcesDatabase() - - await sourcesDao.clear() - - const file = new File([Buffer.from('test')], 'test.txt', { type: 'text/plain' }) - const image = await Attachment.fromFile(file) - - const sources = (await map( - [ - { order: 0.5, title: 'NES', image: image.name }, - { order: 1, title: 'SNES', image: image.name }, - { order: 2.25, title: 'N64', image: image.name } - ], - async (doc) => await sourcesDao.add(doc, image) - )) as [Source, Source, Source] - - return { - // DAOs - sourcesDao, - // Added documents - sources: alphabetical(sources, (item) => item._id) as typeof sources - } -} diff --git a/src/tests/seeds/database.seed.ts b/src/tests/seeds/database.seed.ts deleted file mode 100644 index e8f6e3a..0000000 --- a/src/tests/seeds/database.seed.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { alphabetical, map } from 'radash' -import useDevicesDatabase from '../../main/dao/devices' -import useSourcesDatabase from '../../main/dao/sources' -import useTiesDatabase from '../../main/dao/ties' -import useDrivers from '../../main/services/drivers' -import type { Device } from '../../main/dao/devices' -import type { Source } from '../../main/dao/sources' -import type { Tie } from '../../main/dao/ties' -import { Attachment } from '@/attachments' -import { raiseError } from '@/error-handling' - -export async function seedDatabase() { - const devicesDao = useDevicesDatabase() - const sourcesDao = useSourcesDatabase() - const tiesDao = useTiesDatabase() - - await devicesDao.clear() - await sourcesDao.clear() - await tiesDao.clear() - - const drivers = await useDrivers().registered() - const extronSis = - drivers.find((driver) => driver.guid === '4C8F2838-C91D-431E-84DD-3666D14A6E2C') ?? - raiseError(() => new ReferenceError('Extron SIS driver not registered')) - const sonyRemote = - drivers.find((driver) => driver.guid === '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0') ?? - raiseError(() => new ReferenceError('Sony RS485 driver not registered')) - - const devices = (await map( - [ - { title: 'Extron RGBHVA 128plus', driverId: extronSis.guid, path: 'port:/dev/ttys0' }, - { title: 'Extron HDMI 84pro', driverId: extronSis.guid, path: 'ip:192.168.10.2' }, - { title: 'Sony BVM24D', driverId: sonyRemote.guid, path: 'port:/dev/ttys1' } - ], - async (doc) => await devicesDao.add(doc) - )) as [Device, Device, Device] - - const file = new File([Buffer.from('test')], 'test.txt', { type: 'text/plain' }) - const image = await Attachment.fromFile(file) - - const sources = (await map( - [ - { order: 0, title: 'NES', image: image.name }, - { order: 1, title: 'SNES', image: image.name }, - { order: 2, title: 'N64', image: image.name } - ], - async (doc) => await sourcesDao.add(doc, image) - )) as [Source, Source, Source] - - const ties = (await map( - [ - // NES - { sourceId: sources[0]._id, deviceId: devices[0]._id, inputChannel: 1, outputChannels: { video: 1 } }, - { sourceId: sources[0]._id, deviceId: devices[2]._id, inputChannel: 1, outputChannels: {} }, - // SNES (HDMI FGPA clone) - { sourceId: sources[1]._id, deviceId: devices[1]._id, inputChannel: 1, outputChannels: { video: 1 } }, - { sourceId: sources[1]._id, deviceId: devices[2]._id, inputChannel: 2, outputChannels: {} }, - // N64 - { sourceId: sources[2]._id, deviceId: devices[0]._id, inputChannel: 2, outputChannels: { video: 1 } }, - { sourceId: sources[2]._id, deviceId: devices[2]._id, inputChannel: 1, outputChannels: {} } - ], - async (doc) => await tiesDao.add(doc) - )) as [Tie, Tie, Tie, Tie, Tie, Tie] - - return { - // DAOs - devicesDao, - sourcesDao, - tiesDao, - // Drivers - extronSis, - sonyRemote, - // Added documents - devices: alphabetical(devices, (item) => item._id) as typeof devices, - sources: alphabetical(sources, (item) => item._id) as typeof sources, - ties: alphabetical(ties, (item) => item._id) as typeof ties - } -} diff --git a/src/tests/seeds/migration.seed.ts b/src/tests/seeds/migration.seed.ts deleted file mode 100644 index 504993c..0000000 --- a/src/tests/seeds/migration.seed.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { alphabetical, map } from 'radash' -import { z } from 'zod' -import { Database } from '../../main/services/database' -import useDrivers from '../../main/services/drivers' -import { useLevelDb } from '../../main/services/level' -import type { inferDocumentOf } from '../../main/services/database' -import { Attachment } from '@/attachments' -import { raiseError } from '@/error-handling' - -export async function seedForMigration() { - // - // Migration database must be clean. - // - - const migrationDb = await useLevelDb().levelup('_migrations') - await migrationDb.clear() - await migrationDb.close() - - // - // Original, v1, models. - // - - const DocumentId = z - .string() - .uuid() - .transform((value) => value.toUpperCase()) - - type Switch = inferDocumentOf - const SwitchModel = z.object({ - driverId: DocumentId, - title: z.string().min(1), - path: z.string().min(1) - }) - - type Source = inferDocumentOf - const SourceModel = z.object({ - title: z.string().min(1), - image: z.string().min(1).nullable() - }) - - type Tie = inferDocumentOf - const TieModel = z.object({ - sourceId: DocumentId, - switchId: DocumentId, - inputChannel: z.number().int().nonnegative(), - outputChannels: z.object({ - video: z.number().int().nonnegative().optional(), - audio: z.number().int().nonnegative().optional() - }) - }) - - // - // Original, v1 databases. - // - // These should be closed once seeded. - - const switchesDb = new Database('switches', SwitchModel) - const sourcesDb = new Database('sources', SourceModel) - const tiesDb = new Database('ties', TieModel) - - await switchesDb.clear() - await sourcesDb.clear() - await tiesDb.clear() - - // - // Intermediate databases. - // - // When other databases that could exist that become - // intermediaries to the current. - // - // These should be closed once cleared. - - // - // Current databases - // - // These should be closed once cleaned. - - const UnknownModel = z.object({}) - - const devicesDb = new Database('devices', UnknownModel) - - await devicesDb.clear() - await devicesDb.close() - - const drivers = await useDrivers().registered() - const extronSis = - drivers.find((driver) => driver.guid === '4C8F2838-C91D-431E-84DD-3666D14A6E2C') ?? - raiseError(() => new ReferenceError('Extron SIS driver not registered')) - const sonyRemote = - drivers.find((driver) => driver.guid === '8626D6D3-C211-4D21-B5CC-F5E3B50D9FF0') ?? - raiseError(() => new ReferenceError('Sony RS485 driver not registered')) - - const switches = (await map( - [ - { title: 'Extron RGBHVA 128plus', driverId: extronSis.guid, path: 'port:/dev/ttys0' }, - { title: 'Extron HDMI 84pro', driverId: extronSis.guid, path: 'ip:192.168.10.2' }, - { title: 'Sony BVM24D', driverId: sonyRemote.guid, path: 'port:/dev/ttys1' } - ], - async (doc) => await switchesDb.add(doc) - )) as [Switch, Switch, Switch] - - const file = new File([Buffer.from('test')], 'test.txt', { type: 'text/plain' }) - const image = await Attachment.fromFile(file) - - const sources = (await map( - [ - { title: 'NES', image: image.name }, - { title: 'SNES', image: image.name }, - { title: 'N64', image: image.name } - ], - async (doc) => await sourcesDb.add(doc, image) - )) as [Source, Source, Source] - - const ties = (await map( - [ - // NES - { sourceId: sources[0]._id, switchId: switches[0]._id, inputChannel: 1, outputChannels: { video: 1 } }, - { sourceId: sources[0]._id, switchId: switches[2]._id, inputChannel: 1, outputChannels: {} }, - // SNES (HDMI FGPA clone) - { sourceId: sources[1]._id, switchId: switches[1]._id, inputChannel: 1, outputChannels: { video: 1 } }, - { sourceId: sources[1]._id, switchId: switches[2]._id, inputChannel: 2, outputChannels: {} }, - // N64 - { sourceId: sources[2]._id, switchId: switches[0]._id, inputChannel: 2, outputChannels: { video: 1 } }, - { sourceId: sources[2]._id, switchId: switches[2]._id, inputChannel: 1, outputChannels: {} } - ], - async (doc) => await tiesDb.add(doc) - )) as [Tie, Tie, Tie, Tie, Tie, Tie] - - await switchesDb.close() - await sourcesDb.close() - await tiesDb.close() - - return { - // Drivers - extronSis, - sonyRemote, - // Added documents - switches: alphabetical(switches, (item) => item._id) as typeof switches, - sources: alphabetical(sources, (item) => item._id) as typeof sources, - ties: alphabetical(ties, (item) => item._id) as typeof ties - } -} diff --git a/src/tests/support/mock.ts b/src/tests/support/mock.ts deleted file mode 100644 index 61b5bf1..0000000 --- a/src/tests/support/mock.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { EventEmitter } from 'node:events' -import fs from 'node:fs/promises' -import { join } from 'node:path' -import { vi } from 'vitest' -import type { MainLogger } from 'electron-log' - -export function electronModule(test = 'test') { - return async function electronMockFactory() { - interface AppEvents { - 'will-quit': [] - } - - const exePath = join('/usr', 'bin', 'BridgeCmdr') - - // Make sure the userData path exists. - const userDataPath = join('logs', test) - await fs.mkdir(userDataPath, { recursive: true }) - - class App extends EventEmitter { - getName() { - return 'BridgeCmdr==mock==' - } - - getVersion() { - return '2.0.0==mock==' - } - - getLocale() { - return 'en==mock==' - } - - getPath(...args: Parameters) { - const [name] = args - switch (name) { - case 'exe': - return exePath - case 'userData': - default: - return userDataPath - } - } - } - - return { - app: new App() - } - } -} - -export function electronLogModule() { - const initialize = vi.fn().mockReturnValue() - - const error = vi.fn().mockReturnValue() - const warn = vi.fn().mockReturnValue() - const info = vi.fn().mockReturnValue() - const log = vi.fn().mockReturnValue() - const debug = vi.fn().mockReturnValue() - - return { - default: { - initialize, - error, - warn, - info, - log, - debug - } - } -} - -export function console() { - const error = vi.spyOn(globalThis.console, 'error').mockReturnValue() - const warn = vi.spyOn(globalThis.console, 'warn').mockReturnValue() - const info = vi.spyOn(globalThis.console, 'info').mockReturnValue() - const log = vi.spyOn(globalThis.console, 'log').mockReturnValue() - const debug = vi.spyOn(globalThis.console, 'debug').mockReturnValue() - - return { - error, - warn, - info, - log, - debug - } -} diff --git a/src/tests/support/serial.ts b/src/tests/support/serial.ts deleted file mode 100644 index b5a31b8..0000000 --- a/src/tests/support/serial.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports -- Needed for module mocking. */ - -export async function serialPortModule(original: () => Promise) { - const serialport = await original() - const { SerialPortMock } = serialport - - const kHardwareInfo = { - manufacturer: 'Mock Serial Port', - vendorId: '8086', - productId: '8087' - } - - /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- Issue with serialport module. */ - SerialPortMock.binding.reset() - SerialPortMock.binding.createPort('/dev/ttyS0', { echo: true, record: true, ...kHardwareInfo }) - SerialPortMock.binding.createPort('/dev/ttyS1', { echo: true, record: true, ...kHardwareInfo }) - SerialPortMock.binding.createPort('/dev/ttyS2', { echo: true, record: true, ...kHardwareInfo }) - /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ - - return { - ...serialport, - SerialPort: serialport.SerialPortMock - } -} diff --git a/src/tests/support/stream.ts b/src/tests/support/stream.ts deleted file mode 100644 index 34c2294..0000000 --- a/src/tests/support/stream.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { EventEmitter } from 'node:events' -import { expect } from 'vitest' -import type { CommandStream } from '../../main/services/stream' -import { raiseError } from '@/error-handling' - -interface StreamListeners { - data: [chunk: unknown] - error: [error: Error] -} - -interface StreamEvents { - write: [data: Buffer | string] - close: [] -} - -type CommandDefinition = [data: Buffer, response: () => Buffer] - -class CommandSequence { - definition = new Array() - position = 0 - mock - - constructor(mock: MockCommandStream) { - this.mock = mock - } - - expectDone() { - expect(this.position).toStrictEqual(this.definition.length) - } - - on(...definition: CommandDefinition) { - this.definition.push(definition) - return this - } - - track(data: Buffer): Buffer | null { - const command = this.definition[this.position] - expect(command).not.toBeNull() - if (command == null) { - throw new SyntaxError(`Unexpected command after end of sequence ${String(data)}`) - } - - this.position += 1 - const [cmd, sender] = command - expect(data).toStrictEqual(cmd) - - return sender() - } -} - -export class MockCommandStream implements CommandStream { - /** Allows emitting to listeners. */ - readonly listeners = new EventEmitter() - /** Allows listening to calls. */ - readonly events = new EventEmitter() - /** Stores received data. */ - received = new Array() - /** Indicates the stream was closed. */ - closed = false - /** Allows following a known sequnce of commands. */ - #sequence = null as null | CommandSequence - - get sequence() { - return this.#sequence ?? raiseError(() => new SyntaxError('Sequence not started')) - } - - withSequence() { - if (this.#sequence != null) return this.#sequence - this.#sequence = new CommandSequence(this) - return this.#sequence - } - - async write(data: Buffer | string) { - if (typeof data === 'string') { - data = Buffer.from(data, 'ascii') - } - - this.events.emit('write', data) - this.received.push(data) - if (this.#sequence == null) { - return - } - - const response = this.#sequence.track(data) - if (response != null) { - this.listeners.emit('data', response) - } - - await Promise.resolve() - } - - once(event: 'data', listener: (chunk: unknown) => void): void - once(event: 'error', listener: (error: Error) => void): void - once(event: string | symbol, listener: (...args: any[]) => void) { - this.listeners.once(event, listener as never) - } - - on(event: 'data', listener: (chunk: unknown) => void): () => void - on(event: 'error', listener: (error: Error) => void): () => void - on(event: string | symbol, listener: (...args: any[]) => void) { - const off = () => { - this.listeners.off(event, listener as never) - } - - this.listeners.on(event, listener as never) - return off - } - - async close() { - this.events.emit('close') - this.closed = true - await Promise.resolve() - } -} - -export interface MockStreamContext { - stream: MockCommandStream -} - -export function commandStreamModule(context: MockStreamContext) { - return function $commandStreamModule() { - return { - createCommandStream: async () => await Promise.resolve(context.stream) - } - } -} diff --git a/src/tests/support/vitestZod.ts b/src/tests/support/vitestZod.ts deleted file mode 100644 index 6a0ec4c..0000000 --- a/src/tests/support/vitestZod.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect } from 'vitest' -import type { z } from 'zod' -import { getZodMessage } from '@/error-handling' - -export default function useZod() { - expect.extend({ - toMatchSchema(received: unknown, expected: z.ZodTypeAny) { - const { isNot, utils } = this - - const result = expected.safeParse(received) - const message = result.success ? '' : `; did not because ${getZodMessage(result.error)}` - return { - pass: result.success, - message: () => - isNot - ? `${utils.stringify(received)} should not follow the schema` - : `${utils.stringify(received)} should follow the schema${message}`, - actual: received - } - } - }) -} - -interface ZodMatcher { - toMatchSchema: (schema: Schema) => R -} - -declare module 'vitest' { - /* eslint-disable @typescript-eslint/no-empty-object-type */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - interface Assertion extends ZodMatcher {} - interface AsymmetricMatchersContaining extends ZodMatcher {} - /* eslint-enable @typescript-eslint/no-empty-object-type */ -} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts deleted file mode 100644 index d63b196..0000000 --- a/src/vite-env.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -/// -/// -/// diff --git a/tsconfig.base.json b/tsconfig.base.json deleted file mode 100644 index c9945ba..0000000 --- a/tsconfig.base.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": ["@tsconfig/strictest"], - "compilerOptions": { - "target": "ESNext", - "inlineSourceMap": true, - "useDefineForClassFields": true, - "resolveJsonModule": true, - "noEmit": true, - "importHelpers": true, - "noEmitHelpers": true, - "allowSyntheticDefaultImports": true, - "allowJs": true, - "paths": { - "@/*": ["./src/core/*"] - } - } -} diff --git a/tsconfig.config.json b/tsconfig.config.json deleted file mode 100644 index b735d92..0000000 --- a/tsconfig.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": ["@tsconfig/node20", "./tsconfig.base.json"], - "include": ["electron.vite.config.ts", "vite.config.ts", ".eslintrc.cjs", "prettier.config.cjs"], - "compilerOptions": { - "types": ["node"], - "module": "ESNext", - "moduleResolution": "Bundler" - } -} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index b0aaffb..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.web.json" }, - { "path": "./tsconfig.test.json" } - ] -} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index 22505f4..0000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": ["@tsconfig/node20", "./tsconfig.base.json"], - "include": ["src/core/**/*", "src/main/**/*", "src/preload/**/*"], - "compilerOptions": { - "types": ["node", "electron-vite/node"], - "module": "ESNext", - "moduleResolution": "Bundler" - } -} diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index a8a3b64..0000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": ["@tsconfig/node20", "./tsconfig.base.json"], - "include": ["src/tests/**/*"], - "compilerOptions": { - "types": ["node", "electron-vite/node"], - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2023", "DOM"] - } -} diff --git a/tsconfig.web.json b/tsconfig.web.json deleted file mode 100644 index 45a92b1..0000000 --- a/tsconfig.web.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": ["@vue/tsconfig/tsconfig.dom.json", "./tsconfig.base.json"], - "include": ["src/core/**/*", "src/preload/*.d.ts", "src/renderer/**/*", "src/renderer/**/*.vue", "src/vite-env.d.ts"], - "compilerOptions": { - "lib": ["ES2023", "DOM"] - } -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 71aaa43..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { fileURLToPath, URL } from 'node:url' -import { externalizeDepsPlugin } from 'electron-vite' -import tsconfigPaths from 'vite-tsconfig-paths' -import { coverageConfigDefaults, defineConfig } from 'vitest/config' - -export default defineConfig({ - plugins: [ - externalizeDepsPlugin(), - tsconfigPaths({ - projects: [fileURLToPath(new URL('./tsconfig.base.json', import.meta.url))], - loose: true - }) - ], - esbuild: { - target: ['node20'] - }, - test: { - restoreMocks: true, - unstubEnvs: true, - unstubGlobals: true, - coverage: { - reportOnFailure: true, - exclude: [ - // Ignore the output directories. - 'dist/**', - 'out/**', - // No real tests can be run on the main process start-up. - 'src/main/index.ts', - 'src/main/main.ts', - // No real tests can be run on the preload process. - 'src/preload/index.ts', - // No real tests can be run on the render process start-up. - 'src/renderer/index.ts', - 'src/renderer/main.ts', - // Not going to automatically test tRPC code, since it is a copy of the WebSocket server. - 'src/core/rpc/ipc.ts', - 'src/main/routes/**/*.ts', - 'src/main/routes/**/*.ts', - 'src/main/services/rpc/**/*.ts', - // Not going to test the core URL module till it's used. - 'src/core/url.ts', - // Not going to test or cover the UI right now. - // TODO: There are some parts that do need coverage: - // - Import and export. - 'src/renderer/services/**/*.ts', - 'src/renderer/**/*.ts', - 'src/renderer/**/*.tsx', - 'src/renderer/**/*.vue', - // No real need to test the support and seeding framework. - 'src/tests/support/**/*.ts', - 'src/tests/seeds/**/*.ts', - // The configurations don't need any testing or coverage. - 'electron.vite.config.ts', - ...coverageConfigDefaults.exclude - ] - } - } -}) diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 2fcf8d4..0000000 --- a/yarn.lock +++ /dev/null @@ -1,7237 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"7zip-bin@~5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.2.0.tgz#7a03314684dd6572b7dfa89e68ce31d60286854d" - integrity sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A== - -"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" - integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.24" - -"@antfu/utils@^0.7.10": - version "0.7.10" - resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" - integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.26.2": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" - integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== - dependencies: - "@babel/helper-validator-identifier" "^7.25.9" - js-tokens "^4.0.0" - picocolors "^1.0.0" - -"@babel/compat-data@^7.26.8": - version "7.26.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" - integrity sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ== - -"@babel/core@^7.23.0", "@babel/core@^7.24.7", "@babel/core@^7.26.7": - version "7.26.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.10.tgz#5c876f83c8c4dcb233ee4b670c0606f2ac3000f9" - integrity sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.26.10" - "@babel/helper-compilation-targets" "^7.26.5" - "@babel/helper-module-transforms" "^7.26.0" - "@babel/helpers" "^7.26.10" - "@babel/parser" "^7.26.10" - "@babel/template" "^7.26.9" - "@babel/traverse" "^7.26.10" - "@babel/types" "^7.26.10" - convert-source-map "^2.0.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.3" - semver "^6.3.1" - -"@babel/generator@^7.26.10", "@babel/generator@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.0.tgz#764382b5392e5b9aff93cadb190d0745866cbc2c" - integrity sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw== - dependencies: - "@babel/parser" "^7.27.0" - "@babel/types" "^7.27.0" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - jsesc "^3.0.2" - -"@babel/helper-annotate-as-pure@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" - integrity sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g== - dependencies: - "@babel/types" "^7.25.9" - -"@babel/helper-compilation-targets@^7.26.5": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz#de0c753b1cd1d9ab55d473c5a5cf7170f0a81880" - integrity sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA== - dependencies: - "@babel/compat-data" "^7.26.8" - "@babel/helper-validator-option" "^7.25.9" - browserslist "^4.24.0" - lru-cache "^5.1.1" - semver "^6.3.1" - -"@babel/helper-create-class-features-plugin@^7.25.9", "@babel/helper-create-class-features-plugin@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz#518fad6a307c6a96f44af14912b2c20abe9bfc30" - integrity sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/helper-replace-supers" "^7.26.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/traverse" "^7.27.0" - semver "^6.3.1" - -"@babel/helper-member-expression-to-functions@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3" - integrity sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-module-imports@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" - integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-module-transforms@^7.26.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" - integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== - dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" - -"@babel/helper-optimise-call-expression@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz#3324ae50bae7e2ab3c33f60c9a877b6a0146b54e" - integrity sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ== - dependencies: - "@babel/types" "^7.25.9" - -"@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.26.5": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35" - integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg== - -"@babel/helper-replace-supers@^7.26.5": - version "7.26.5" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz#6cb04e82ae291dae8e72335dfe438b0725f14c8d" - integrity sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/traverse" "^7.26.5" - -"@babel/helper-skip-transparent-expression-wrappers@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz#0b2e1b62d560d6b1954893fd2b705dc17c91f0c9" - integrity sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/helper-string-parser@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" - integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== - -"@babel/helper-validator-identifier@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" - integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== - -"@babel/helper-validator-option@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" - integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== - -"@babel/helpers@^7.26.10": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.0.tgz#53d156098defa8243eab0f32fa17589075a1b808" - integrity sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg== - dependencies: - "@babel/template" "^7.27.0" - "@babel/types" "^7.27.0" - -"@babel/parser@^7.24.6", "@babel/parser@^7.25.3", "@babel/parser@^7.25.4", "@babel/parser@^7.26.10", "@babel/parser@^7.26.9", "@babel/parser@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.0.tgz#3d7d6ee268e41d2600091cbd4e145ffee85a44ec" - integrity sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg== - dependencies: - "@babel/types" "^7.27.0" - -"@babel/plugin-proposal-decorators@^7.23.0": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.25.9.tgz#8680707f943d1a3da2cd66b948179920f097e254" - integrity sha512-smkNLL/O1ezy9Nhy4CNosc4Va+1wo5w4gzSZeLe6y6dM4mmHfYOCPolXQPHQxonZCF+ZyebxN9vqOolkYrSn5g== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/plugin-syntax-decorators" "^7.25.9" - -"@babel/plugin-syntax-decorators@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.25.9.tgz#986b4ca8b7b5df3f67cee889cedeffc2e2bf14b3" - integrity sha512-ryzI0McXUPJnRCvMo4lumIKZUzhYUO/ScI+Mz4YVaTLt04DHNSjEUjKVvbzQjZFLuod/cYEc07mJWhzl6v4DPg== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-syntax-import-attributes@^7.22.5": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz#3b1412847699eea739b4f2602c74ce36f6b0b0f7" - integrity sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-syntax-import-meta@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-jsx@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz#a34313a178ea56f1951599b929c1ceacee719290" - integrity sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-syntax-typescript@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz#67dda2b74da43727cf21d46cf9afef23f4365399" - integrity sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-transform-arrow-functions@^7.24.7": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz#7821d4410bee5daaadbb4cdd9a6649704e176845" - integrity sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg== - dependencies: - "@babel/helper-plugin-utils" "^7.25.9" - -"@babel/plugin-transform-typescript@^7.22.15", "@babel/plugin-transform-typescript@^7.26.7": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz#a29fd3481da85601c7e34091296e9746d2cccba8" - integrity sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-create-class-features-plugin" "^7.27.0" - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/plugin-syntax-typescript" "^7.25.9" - -"@babel/template@^7.26.9", "@babel/template@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.0.tgz#b253e5406cc1df1c57dcd18f11760c2dbf40c0b4" - integrity sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/parser" "^7.27.0" - "@babel/types" "^7.27.0" - -"@babel/traverse@^7.25.9", "@babel/traverse@^7.26.10", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.9", "@babel/traverse@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.0.tgz#11d7e644779e166c0442f9a07274d02cd91d4a70" - integrity sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.27.0" - "@babel/parser" "^7.27.0" - "@babel/template" "^7.27.0" - "@babel/types" "^7.27.0" - debug "^4.3.1" - globals "^11.1.0" - -"@babel/types@^7.25.4", "@babel/types@^7.25.9", "@babel/types@^7.26.10", "@babel/types@^7.26.9", "@babel/types@^7.27.0": - version "7.27.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.0.tgz#ef9acb6b06c3173f6632d993ecb6d4ae470b4559" - integrity sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg== - dependencies: - "@babel/helper-string-parser" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - -"@bcoe/v8-coverage@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" - integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== - -"@bufbuild/protobuf@^2.0.0": - version "2.2.5" - resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.2.5.tgz#8e82c0af292113b4a89f8b658c71c4636c8d2e36" - integrity sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ== - -"@develar/schema-utils@~2.6.5": - version "2.6.5" - resolved "https://registry.yarnpkg.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz#3ece22c5838402419a6e0425f85742b961d9b6c6" - integrity sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig== - dependencies: - ajv "^6.12.0" - ajv-keywords "^3.4.1" - -"@electron-toolkit/utils@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@electron-toolkit/utils/-/utils-3.0.0.tgz#74626893d93025eacba086d497b615cf927d42c4" - integrity sha512-GaXHDhiT7KCvMJjXdp/QqpYinq69T/Pdl49Z1XLf8mKGf63dnsODMWyrmIjEQ0z/vG7dO8qF3fvmI6Eb2lUNZA== - -"@electron/asar@^3.2.1": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.4.0.tgz#eaa352791cf3c64aede6220433bf80f448129760" - integrity sha512-8ZAmXjsQ17wJxdv4755hZ1Xiw85dwETlWYQwl+imww18CaEK4bxPvAotJEfIZGbRMrNEJOTMyuVQD+yDY03N5Q== - dependencies: - commander "^5.0.0" - glob "^7.1.6" - minimatch "^3.0.4" - -"@electron/get@^2.0.0": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.3.tgz#fba552683d387aebd9f3fcadbcafc8e12ee4f960" - integrity sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ== - dependencies: - debug "^4.1.1" - env-paths "^2.2.0" - fs-extra "^8.1.0" - got "^11.8.5" - progress "^2.0.3" - semver "^6.2.0" - sumchecker "^3.0.1" - optionalDependencies: - global-agent "^3.0.0" - -"@electron/notarize@2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@electron/notarize/-/notarize-2.2.1.tgz#d0aa6bc43cba830c41bfd840b85dbe0e273f59fe" - integrity sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg== - dependencies: - debug "^4.1.1" - fs-extra "^9.0.1" - promise-retry "^2.0.1" - -"@electron/osx-sign@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@electron/osx-sign/-/osx-sign-1.0.5.tgz#0af7149f2fce44d1a8215660fd25a9fb610454d8" - integrity sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww== - dependencies: - compare-version "^0.1.2" - debug "^4.3.4" - fs-extra "^10.0.0" - isbinaryfile "^4.0.8" - minimist "^1.2.6" - plist "^3.0.5" - -"@electron/universal@1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@electron/universal/-/universal-1.5.1.tgz#f338bc5bcefef88573cf0ab1d5920fac10d06ee5" - integrity sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw== - dependencies: - "@electron/asar" "^3.2.1" - "@malept/cross-spawn-promise" "^1.1.0" - debug "^4.3.1" - dir-compare "^3.0.0" - fs-extra "^9.0.1" - minimatch "^3.0.4" - plist "^3.0.4" - -"@emnapi/core@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.0.tgz#8844b02d799198158ac1fea21ae2bc81b881da9a" - integrity sha512-H+N/FqT07NmLmt6OFFtDfwe8PNygprzBikrEMyQfgqSmT0vzE515Pz7R8izwB9q/zsH/MA64AKoul3sA6/CzVg== - dependencies: - "@emnapi/wasi-threads" "1.0.1" - tslib "^2.4.0" - -"@emnapi/runtime@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.0.tgz#8f509bf1059a5551c8fe829a1c4e91db35fdfbee" - integrity sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz#d7ae71fd2166b1c916c6cd2d0df2ef565a2e1a5b" - integrity sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw== - dependencies: - tslib "^2.4.0" - -"@es-joy/jsdoccomment@~0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz#e5ec1eda837c802eca67d3b29e577197f14ba1db" - integrity sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q== - dependencies: - comment-parser "1.4.1" - esquery "^1.6.0" - jsdoc-type-pratt-parser "~4.1.0" - -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== - -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== - -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== - -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== - -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== - -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== - -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== - -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== - -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== - -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== - -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== - -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== - -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== - -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== - -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== - -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== - -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== - -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== - -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== - -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== - -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== - -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== - -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== - -"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0", "@eslint-community/eslint-utils@^4.5.0": - version "4.5.1" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz#b0fc7e06d0c94f801537fd4237edc2706d3b8e4c" - integrity sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w== - dependencies: - eslint-visitor-keys "^3.4.3" - -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.6.1": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== - -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@eslint/eslintrc@^3.2.0": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" - integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^10.0.1" - globals "^14.0.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@eslint/js@8.57.1", "@eslint/js@^8.57.1": - version "8.57.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" - integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== - -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== - dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" - -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== - -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== - -"@intlify/bundle-utils@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@intlify/bundle-utils/-/bundle-utils-9.0.0.tgz#c0e2b41502a2a7d5e2f47a97f0a42b0bdc9bfc62" - integrity sha512-19dunbgM4wuCvi2xSai2PKhXkcKGjlbJhNWm9BCQWkUYcPmXwzptNWOE0O7OSrhNlEDxwpkHsJzZ/vLbCkpElw== - dependencies: - "@intlify/message-compiler" next - "@intlify/shared" next - acorn "^8.8.2" - escodegen "^2.1.0" - estree-walker "^2.0.2" - jsonc-eslint-parser "^2.3.0" - mlly "^1.2.0" - source-map-js "^1.0.1" - yaml-eslint-parser "^1.2.2" - -"@intlify/core-base@10.0.6", "@intlify/core-base@^10.0.6": - version "10.0.6" - resolved "https://registry.yarnpkg.com/@intlify/core-base/-/core-base-10.0.6.tgz#4b98b5f3fe5f4614e92f1857749d6e4e3d4d8190" - integrity sha512-/NINGvy7t8qSCyyuqMIPmHS6CBQjqPIPVOps0Rb7xWrwwkwHJKtahiFnW1HC4iQVhzoYwEW6Js0923zTScLDiA== - dependencies: - "@intlify/message-compiler" "10.0.6" - "@intlify/shared" "10.0.6" - -"@intlify/message-compiler@10.0.6": - version "10.0.6" - resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-10.0.6.tgz#899e6aacf95138fa675cfabc5b2d1c8864930b4d" - integrity sha512-QcUYprK+e4X2lU6eJDxLuf/mUtCuVPj2RFBoFRlJJxK3wskBejzlRvh1Q0lQCi9tDOnD4iUK1ftcGylE3X3idA== - dependencies: - "@intlify/shared" "10.0.6" - source-map-js "^1.0.2" - -"@intlify/message-compiler@next": - version "12.0.0-alpha.2" - resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-12.0.0-alpha.2.tgz#0c08e2682c1e8a2ac191eff7a06e8a2363768c59" - integrity sha512-PD9C+oQbb7BF52hec0+vLnScaFkvnfX+R7zSbODYuRo/E2niAtGmHd0wPvEMsDhf9Z9b8f/qyDsVeZnD/ya9Ug== - dependencies: - "@intlify/shared" "12.0.0-alpha.2" - source-map-js "^1.0.2" - -"@intlify/shared@10.0.6", "@intlify/shared@^10.0.0": - version "10.0.6" - resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-10.0.6.tgz#70dd93911b15b08194ba1e505fbc85545ff975e7" - integrity sha512-2xqwm05YPpo7TM//+v0bzS0FWiTzsjpSMnWdt7ZXs5/ZfQIedSuBXIrskd8HZ7c/cZzo1G9ALHTksnv/74vk/Q== - -"@intlify/shared@12.0.0-alpha.2", "@intlify/shared@next": - version "12.0.0-alpha.2" - resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-12.0.0-alpha.2.tgz#8e73bc0ecf52b8992aeec6fd781f74783ce68971" - integrity sha512-P2DULVX9nz3y8zKNqLw9Es1aAgQ1JGC+kgpx5q7yLmrnAKkPR5MybQWoEhxanefNJgUY5ehsgo+GKif59SrncA== - -"@intlify/shared@latest": - version "11.1.2" - resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-11.1.2.tgz#d780bc8eb4e3c3ef8a3e45c421f0f5ecf59651f2" - integrity sha512-dF2iMMy8P9uKVHV/20LA1ulFLL+MKSbfMiixSmn6fpwqzvix38OIc7ebgnFbBqElvghZCW9ACtzKTGKsTGTWGA== - -"@intlify/unplugin-vue-i18n@^5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@intlify/unplugin-vue-i18n/-/unplugin-vue-i18n-5.3.1.tgz#bf7193ad3fe885c585c7bc115b5b79a9d36e3475" - integrity sha512-76huP8TpMOtBMLsYYIMLNbqMPXJ7+Q6xcjP6495h/pmbOQ7sw/DB8E0OFvDFeIZ2571a4ylzJnz+KMuYbAs1xA== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@intlify/bundle-utils" "^9.0.0" - "@intlify/shared" latest - "@intlify/vue-i18n-extensions" "^7.0.0" - "@rollup/pluginutils" "^5.1.0" - "@typescript-eslint/scope-manager" "^8.13.0" - "@typescript-eslint/typescript-estree" "^8.13.0" - debug "^4.3.3" - fast-glob "^3.2.12" - js-yaml "^4.1.0" - json5 "^2.2.3" - pathe "^1.0.0" - picocolors "^1.0.0" - source-map-js "^1.0.2" - unplugin "^1.1.0" - vue "^3.4" - -"@intlify/vue-i18n-extensions@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@intlify/vue-i18n-extensions/-/vue-i18n-extensions-7.0.0.tgz#20cc25bea1e20b69a6a5b3e60a6ac2f62bc79db6" - integrity sha512-MtvfJnb4aklpCU5Q/dkWkBT/vGsp3qERiPIwtTq5lX4PCLHtUprAJZp8wQj5ZcwDaFCU7+yVMjYbeXpIf927cA== - dependencies: - "@babel/parser" "^7.24.6" - "@intlify/shared" "^10.0.0" - "@vue/compiler-dom" "^3.2.45" - vue-i18n "^10.0.0" - -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - -"@istanbuljs/schema@^0.1.2": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" - integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== - -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.8" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" - integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== - dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" - integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== - -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== - -"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@malept/cross-spawn-promise@^1.1.0": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz#504af200af6b98e198bce768bc1730c6936ae01d" - integrity sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ== - dependencies: - cross-spawn "^7.0.1" - -"@malept/flatpak-bundler@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz#e8a32c30a95d20c2b1bb635cc580981a06389858" - integrity sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q== - dependencies: - debug "^4.1.1" - fs-extra "^9.0.0" - lodash "^4.17.15" - tmp-promise "^3.0.2" - -"@mdi/js@^7.4.47": - version "7.4.47" - resolved "https://registry.yarnpkg.com/@mdi/js/-/js-7.4.47.tgz#7d8a4edc9631bffeed80d1ec784f9beae559a76a" - integrity sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ== - -"@mdi/svg@^7.4.47": - version "7.4.47" - resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-7.4.47.tgz#f8e5516aae129764a76d1bb2f27e55bee03e6e90" - integrity sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw== - -"@microsoft/tsdoc-config@0.17.1": - version "0.17.1" - resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz#e0f0b50628f4ad7fe121ca616beacfe6a25b9335" - integrity sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw== - dependencies: - "@microsoft/tsdoc" "0.15.1" - ajv "~8.12.0" - jju "~1.4.0" - resolve "~1.22.2" - -"@microsoft/tsdoc@0.15.1": - version "0.15.1" - resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz#d4f6937353bc4568292654efb0a0e0532adbcba2" - integrity sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw== - -"@napi-rs/wasm-runtime@^0.2.7": - version "0.2.8" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.8.tgz#642e8390ee78ed21d6b79c467aa610e249224ed6" - integrity sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg== - dependencies: - "@emnapi/core" "^1.4.0" - "@emnapi/runtime" "^1.4.0" - "@tybys/wasm-util" "^0.9.0" - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@nolyfill/is-core-module@1.0.39": - version "1.0.39" - resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" - integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== - -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== - -"@pkgr/core@^0.1.0": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.2.tgz#1cf95080bb7072fafaa3cb13b442fab4695c3893" - integrity sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ== - -"@pkgr/core@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.0.tgz#8dff61038cb5884789d8b323d9869e5363b976f7" - integrity sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ== - -"@polka/url@^1.0.0-next.24": - version "1.0.0-next.28" - resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" - integrity sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw== - -"@rollup/pluginutils@^5.1.0", "@rollup/pluginutils@^5.1.3": - version "5.1.4" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz#bb94f1f9eaaac944da237767cdfee6c5b2262d4a" - integrity sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ== - dependencies: - "@types/estree" "^1.0.0" - estree-walker "^2.0.2" - picomatch "^4.0.2" - -"@rollup/rollup-android-arm-eabi@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz#1d8cc5dd3d8ffe569d8f7f67a45c7909828a0f66" - integrity sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA== - -"@rollup/rollup-android-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz#9c136034d3d9ed29d0b138c74dd63c5744507fca" - integrity sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ== - -"@rollup/rollup-darwin-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz#830d07794d6a407c12b484b8cf71affd4d3800a6" - integrity sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q== - -"@rollup/rollup-darwin-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz#b26f0f47005c1fa5419a880f323ed509dc8d885c" - integrity sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ== - -"@rollup/rollup-freebsd-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz#2b60c81ac01ff7d1bc8df66aee7808b6690c6d19" - integrity sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ== - -"@rollup/rollup-freebsd-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz#4826af30f4d933d82221289068846c9629cc628c" - integrity sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz#a1f4f963d5dcc9e5575c7acf9911824806436bf7" - integrity sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g== - -"@rollup/rollup-linux-arm-musleabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz#e924b0a8b7c400089146f6278446e6b398b75a06" - integrity sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw== - -"@rollup/rollup-linux-arm64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz#cb43303274ec9a716f4440b01ab4e20c23aebe20" - integrity sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ== - -"@rollup/rollup-linux-arm64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz#531c92533ce3d167f2111bfcd2aa1a2041266987" - integrity sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA== - -"@rollup/rollup-linux-loongarch64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz#53403889755d0c37c92650aad016d5b06c1b061a" - integrity sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw== - -"@rollup/rollup-linux-powerpc64le-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz#f669f162e29094c819c509e99dbeced58fc708f9" - integrity sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ== - -"@rollup/rollup-linux-riscv64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz#4bab37353b11bcda5a74ca11b99dea929657fd5f" - integrity sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ== - -"@rollup/rollup-linux-riscv64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz#4d66be1ce3cfd40a7910eb34dddc7cbd4c2dd2a5" - integrity sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA== - -"@rollup/rollup-linux-s390x-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz#7181c329395ed53340a0c59678ad304a99627f6d" - integrity sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA== - -"@rollup/rollup-linux-x64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz#00825b3458094d5c27cb4ed66e88bfe9f1e65f90" - integrity sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA== - -"@rollup/rollup-linux-x64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz#81caac2a31b8754186f3acc142953a178fcd6fba" - integrity sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg== - -"@rollup/rollup-win32-arm64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz#3a3f421f5ce9bd99ed20ce1660cce7cee3e9f199" - integrity sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ== - -"@rollup/rollup-win32-ia32-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz#a44972d5cdd484dfd9cf3705a884bf0c2b7785a7" - integrity sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ== - -"@rollup/rollup-win32-x64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz#bfe0214e163f70c4fec1c8f7bb8ce266f4c05b7e" - integrity sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug== - -"@rtsao/scc@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" - integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== - -"@sec-ant/readable-stream@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" - integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== - -"@serialport/binding-mock@10.2.2": - version "10.2.2" - resolved "https://registry.yarnpkg.com/@serialport/binding-mock/-/binding-mock-10.2.2.tgz#d322a8116a97806addda13c62f50e73d16125874" - integrity sha512-HAFzGhk9OuFMpuor7aT5G1ChPgn5qSsklTFOTUX72Rl6p0xwcSVsRtG/xaGp6bxpN7fI9D/S8THLBWbBgS6ldw== - dependencies: - "@serialport/bindings-interface" "^1.2.1" - debug "^4.3.3" - -"@serialport/bindings-cpp@12.0.1": - version "12.0.1" - resolved "https://registry.yarnpkg.com/@serialport/bindings-cpp/-/bindings-cpp-12.0.1.tgz#b7588a8b3e124e7679622ce980a7d8528e9f36a3" - integrity sha512-r2XOwY2dDvbW7dKqSPIk2gzsr6M6Qpe9+/Ngs94fNaNlcTRCV02PfaoDmRgcubpNVVcLATlxSxPTIDw12dbKOg== - dependencies: - "@serialport/bindings-interface" "1.2.2" - "@serialport/parser-readline" "11.0.0" - debug "4.3.4" - node-addon-api "7.0.0" - node-gyp-build "4.6.0" - -"@serialport/bindings-interface@1.2.2", "@serialport/bindings-interface@^1.2.1": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@serialport/bindings-interface/-/bindings-interface-1.2.2.tgz#c4ae9c1c85e26b02293f62f37435478d90baa460" - integrity sha512-CJaUd5bLvtM9c5dmO9rPBHPXTa9R2UwpkJ0wdh9JCYcbrPWsKz+ErvR0hBLeo7NPeiFdjFO4sonRljiw4d2XiA== - -"@serialport/parser-byte-length@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-byte-length/-/parser-byte-length-12.0.0.tgz#18b1db5d1b3b9d8e1153eb2ab3975ac5445b844a" - integrity sha512-0ei0txFAj+s6FTiCJFBJ1T2hpKkX8Md0Pu6dqMrYoirjPskDLJRgZGLqoy3/lnU1bkvHpnJO+9oJ3PB9v8rNlg== - -"@serialport/parser-cctalk@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-cctalk/-/parser-cctalk-12.0.0.tgz#f5c573b1ad2a9eed377aea9d70d264b36ca5dd5d" - integrity sha512-0PfLzO9t2X5ufKuBO34DQKLXrCCqS9xz2D0pfuaLNeTkyGUBv426zxoMf3rsMRodDOZNbFblu3Ae84MOQXjnZw== - -"@serialport/parser-delimiter@11.0.0": - version "11.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-delimiter/-/parser-delimiter-11.0.0.tgz#e830c6bb49723d4446131277dc3243b502d09388" - integrity sha512-aZLJhlRTjSmEwllLG7S4J8s8ctRAS0cbvCpO87smLvl3e4BgzbVgF6Z6zaJd3Aji2uSiYgfedCdNc4L6W+1E2g== - -"@serialport/parser-delimiter@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-delimiter/-/parser-delimiter-12.0.0.tgz#43d3687f982829cc9b48ee0b21f2de80d0f19778" - integrity sha512-gu26tVt5lQoybhorLTPsH2j2LnX3AOP2x/34+DUSTNaUTzu2fBXw+isVjQJpUBFWu6aeQRZw5bJol5X9Gxjblw== - -"@serialport/parser-inter-byte-timeout@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-inter-byte-timeout/-/parser-inter-byte-timeout-12.0.0.tgz#1436f36fac92c950d290744e8ce56b2273a61d08" - integrity sha512-GnCh8K0NAESfhCuXAt+FfBRz1Cf9CzIgXfp7SdMgXwrtuUnCC/yuRTUFWRvuzhYKoAo1TL0hhUo77SFHUH1T/w== - -"@serialport/parser-packet-length@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-packet-length/-/parser-packet-length-12.0.0.tgz#3b5b8b47b6971c03dbc90ba61c0b8c5ec8bb0798" - integrity sha512-p1hiCRqvGHHLCN/8ZiPUY/G0zrxd7gtZs251n+cfNTn+87rwcdUeu9Dps3Aadx30/sOGGFL6brIRGK4l/t7MuQ== - -"@serialport/parser-readline@11.0.0": - version "11.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-readline/-/parser-readline-11.0.0.tgz#c2c8c88e163d2abf7c0ffddbc1845336444e3454" - integrity sha512-rRAivhRkT3YO28WjmmG4FQX6L+KMb5/ikhyylRfzWPw0nSXy97+u07peS9CbHqaNvJkMhH1locp2H36aGMOEIA== - dependencies: - "@serialport/parser-delimiter" "11.0.0" - -"@serialport/parser-readline@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-readline/-/parser-readline-12.0.0.tgz#50e992004d7a84d5a12e0b016adb9021d3a72fbb" - integrity sha512-O7cywCWC8PiOMvo/gglEBfAkLjp/SENEML46BXDykfKP5mTPM46XMaX1L0waWU6DXJpBgjaL7+yX6VriVPbN4w== - dependencies: - "@serialport/parser-delimiter" "12.0.0" - -"@serialport/parser-ready@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-ready/-/parser-ready-12.0.0.tgz#193495e10c5a663029bce074d4f84cad173aab82" - integrity sha512-ygDwj3O4SDpZlbrRUraoXIoIqb8sM7aMKryGjYTIF0JRnKeB1ys8+wIp0RFMdFbO62YriUDextHB5Um5cKFSWg== - -"@serialport/parser-regex@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-regex/-/parser-regex-12.0.0.tgz#ffbb2b113f3a50d7760fcdff5a4dd0f213ab8166" - integrity sha512-dCAVh4P/pZrLcPv9NJ2mvPRBg64L5jXuiRxIlyxxdZGH4WubwXVXY/kBTihQmiAMPxbT3yshSX8f2+feqWsxqA== - -"@serialport/parser-slip-encoder@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-slip-encoder/-/parser-slip-encoder-12.0.0.tgz#362099d4cd170afe8583f1fa607176fd4fc14f1d" - integrity sha512-0APxDGR9YvJXTRfY+uRGhzOhTpU5akSH183RUcwzN7QXh8/1jwFsFLCu0grmAUfi+fItCkR+Xr1TcNJLR13VNA== - -"@serialport/parser-spacepacket@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/parser-spacepacket/-/parser-spacepacket-12.0.0.tgz#347e34b0221f29eb252ebd341a0acfff920ad814" - integrity sha512-dozONxhPC/78pntuxpz/NOtVps8qIc/UZzdc/LuPvVsqCoJXiRxOg6ZtCP/W58iibJDKPZPAWPGYeZt9DJxI+Q== - -"@serialport/stream@12.0.0": - version "12.0.0" - resolved "https://registry.yarnpkg.com/@serialport/stream/-/stream-12.0.0.tgz#047f97f780d92ddfc04303cb625e0f7e5a01a2bf" - integrity sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q== - dependencies: - "@serialport/bindings-interface" "1.2.2" - debug "4.3.4" - -"@sindresorhus/is@^4.0.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" - integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== - -"@sindresorhus/is@^7.0.1": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.0.1.tgz#693cd0bfa7fdc71a3386b72088b660fb70851927" - integrity sha512-QWLl2P+rsCJeofkDNIT3WFmb6NrRud1SUYW8dIhXK/46XFV8Q/g7Bsvib0Askb0reRLe+WYPeeE+l5cH7SlkuQ== - -"@sindresorhus/merge-streams@^2.1.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" - integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== - -"@sindresorhus/merge-streams@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" - integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== - -"@sixxgate/lint@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sixxgate/lint/-/lint-4.0.0.tgz#d2b5c4b03ebcdafd8a5dbc559490852e40365332" - integrity sha512-frWPXpfb9FBhqTIY6oW1qY27g5zq1lGdeV5SS24tJg7G8A/JHbQKRapRw57g8KytuuFW1wnCIsp+J7nqJeYTPQ== - dependencies: - "@eslint/eslintrc" "^3.2.0" - "@eslint/js" "^8.57.1" - "@types/eslint" "^8.56.12" - chalk "^4.1.2" - debug "^4.4.0" - eslint "^8.57.1" - execa "^5.1.1" - is-interactive "^1.0.0" - lodash "^4.17.21" - pkg-dir "^5.0.0" - radash "^12.1.0" - read-pkg "^5.2.0" - tslib "^2.8.1" - type-fest "^4.33.0" - zod "^3.24.1" - -"@szmarczak/http-timer@^4.0.5": - version "4.0.6" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" - integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== - dependencies: - defer-to-connect "^2.0.0" - -"@tootallnate/once@2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" - integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== - -"@trpc/client@^10.45.2": - version "10.45.2" - resolved "https://registry.yarnpkg.com/@trpc/client/-/client-10.45.2.tgz#15f9ba81303bf3417083fc6bb742e4e86b49da90" - integrity sha512-ykALM5kYWTLn1zYuUOZ2cPWlVfrXhc18HzBDyRhoPYN0jey4iQHEFSEowfnhg1RvYnrAVjNBgHNeSAXjrDbGwg== - -"@trpc/server@^10.45.2": - version "10.45.2" - resolved "https://registry.yarnpkg.com/@trpc/server/-/server-10.45.2.tgz#5f2778c4810f93b5dc407146334f8da70a0b51fb" - integrity sha512-wOrSThNNE4HUnuhJG6PfDRp4L2009KDVxsd+2VYH8ro6o/7/jwYZ8Uu5j+VaW+mOmc8EHerHzGcdbGNQSAUPgg== - -"@tsconfig/node20@^20.1.5": - version "20.1.5" - resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.5.tgz#6e5dc3c90088865669c555a156252f75d1a84c0a" - integrity sha512-Vm8e3WxDTqMGPU4GATF9keQAIy1Drd7bPwlgzKJnZtoOsTm1tduUTbDjg0W5qERvGuxPI2h9RbMufH0YdfBylA== - -"@tsconfig/strictest@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@tsconfig/strictest/-/strictest-2.0.5.tgz#2cbc67f207ba87fdec2a84ad79b1708cf4edd93b" - integrity sha512-ec4tjL2Rr0pkZ5hww65c+EEPYwxOi4Ryv+0MtjeaSQRJyq322Q27eOQiFbuNgw2hpL4hB1/W/HBGk3VKS43osg== - -"@tybys/wasm-util@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355" - integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw== - dependencies: - tslib "^2.4.0" - -"@types/abstract-leveldown@*": - version "7.2.5" - resolved "https://registry.yarnpkg.com/@types/abstract-leveldown/-/abstract-leveldown-7.2.5.tgz#db2cf364c159fb1f12be6cd3549f56387eaf8d73" - integrity sha512-/2B0nQF4UdupuxeKTJA2+Rj1D+uDemo6P4kMwKCpbfpnzeVaWSELTsAw4Lxn3VJD6APtRrZOCuYo+4nHUQfTfg== - -"@types/cacheable-request@^6.0.1": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz#a430b3260466ca7b5ca5bfd735693b36e7a9d183" - integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== - dependencies: - "@types/http-cache-semantics" "*" - "@types/keyv" "^3.1.4" - "@types/node" "*" - "@types/responselike" "^1.0.0" - -"@types/debug@*", "@types/debug@^4.1.6": - version "4.1.12" - resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" - integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== - dependencies: - "@types/ms" "*" - -"@types/eslint@^8.56.12": - version "8.56.12" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.12.tgz#1657c814ffeba4d2f84c0d4ba0f44ca7ea1ca53a" - integrity sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*", "@types/estree@1.0.7", "@types/estree@^1.0.0": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== - -"@types/fs-extra@9.0.13", "@types/fs-extra@^9.0.11": - version "9.0.13" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45" - integrity sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA== - dependencies: - "@types/node" "*" - -"@types/http-cache-semantics@*": - version "4.0.4" - resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4" - integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== - -"@types/ini@^4.1.1": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@types/ini/-/ini-4.1.1.tgz#6984664a8cc74c3348f4049d0bf2b1ab2d061ca3" - integrity sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg== - -"@types/json-schema@*": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" - integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== - -"@types/json5@^0.0.29": - version "0.0.29" - resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" - integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== - -"@types/keyv@^3.1.4": - version "3.1.4" - resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" - integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== - dependencies: - "@types/node" "*" - -"@types/level-errors@*": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@types/level-errors/-/level-errors-3.0.2.tgz#f33ec813c50780b547463da9ad8acac89ee457d9" - integrity sha512-gyZHbcQ2X5hNXf/9KS2qGEmgDe9EN2WDM3rJ5Ele467C0nA1sLhtmv1bZiPMDYfAYCfPWft0uQIaTvXbASSTRA== - -"@types/leveldown@^4.0.6": - version "4.0.6" - resolved "https://registry.yarnpkg.com/@types/leveldown/-/leveldown-4.0.6.tgz#ecf4c2f929880ea8f52be1414196fac513e21dc7" - integrity sha512-AQ3vqW1j5aQ3U6A22e/k1WrZHHIewvGrBmq4zRxS9fRDpqTe3HoD+F11nEV+8zStfEbGHzzvk8dHF/4JEXmZnQ== - dependencies: - "@types/abstract-leveldown" "*" - "@types/node" "*" - -"@types/levelup@^5.1.5": - version "5.1.5" - resolved "https://registry.yarnpkg.com/@types/levelup/-/levelup-5.1.5.tgz#9891f4bcabb956b5531587e2b2238a139f34d0d9" - integrity sha512-Sm0jSj+LoncQ8BuZZJBjYitY5r9/V/Xd//vRjfgbQLWcQg2/iCm0HQqIOZ1KBE7QdNyAqMIG97mE3+t1GR0TIw== - dependencies: - "@types/abstract-leveldown" "*" - "@types/level-errors" "*" - "@types/node" "*" - -"@types/ms@*": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" - integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== - -"@types/node@*": - version "22.14.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.14.0.tgz#d3bfa3936fef0dbacd79ea3eb17d521c628bb47e" - integrity sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA== - dependencies: - undici-types "~6.21.0" - -"@types/node@^20.17.30", "@types/node@^20.9.0": - version "20.17.30" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.30.tgz#1d93f656d3b869dbef7b796568ac457606ba58d0" - integrity sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg== - dependencies: - undici-types "~6.19.2" - -"@types/normalize-package-data@^2.4.0": - version "2.4.4" - resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" - integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== - -"@types/plist@^3.0.1": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@types/plist/-/plist-3.0.5.tgz#9a0c49c0f9886c8c8696a7904dd703f6284036e0" - integrity sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA== - dependencies: - "@types/node" "*" - xmlbuilder ">=11.0.1" - -"@types/pouchdb-core@*", "@types/pouchdb-core@^7.0.15": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/pouchdb-core/-/pouchdb-core-7.0.15.tgz#02bb1b480c37b6d84b2e3c8babee716f1a101814" - integrity sha512-gq1Qbqn9nCaAKRRv6fRHZ4/ER+QYEwSXBZlDQcxwdbPrtZO8EhIn2Bct0AlguaSEdFcABfbaxxyQwFINkNQ9dQ== - dependencies: - "@types/debug" "*" - "@types/pouchdb-find" "*" - -"@types/pouchdb-find@*", "@types/pouchdb-find@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@types/pouchdb-find/-/pouchdb-find-7.3.3.tgz#599e388e2a1c4e57ee8aba0d5deca24d37b6978e" - integrity sha512-U7zXk67s9Ar+9Pwj5kSbuMnn8zif0AOOIPy4KRFeJ/S/Tk+mNS90soj+3OV21H8xyB7WTxjvS1JLablZC6C6ow== - dependencies: - "@types/pouchdb-core" "*" - -"@types/pouchdb-mapreduce@^6.1.10": - version "6.1.10" - resolved "https://registry.yarnpkg.com/@types/pouchdb-mapreduce/-/pouchdb-mapreduce-6.1.10.tgz#2668c32a79920dbaed4ac335fa0fc4b986a0cb38" - integrity sha512-AgYVqCnaA5D7cWkWyzZVuk0137N4yZsmIQTD/i3DmuMxYYoFrtWUoQu0tbA52SpTRGdL8ubQ7JFQXzA13fA6IQ== - dependencies: - "@types/pouchdb-core" "*" - -"@types/responselike@^1.0.0": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50" - integrity sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw== - dependencies: - "@types/node" "*" - -"@types/verror@^1.10.3": - version "1.10.11" - resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.11.tgz#d3d6b418978c8aa202d41e5bb3483227b6ecc1bb" - integrity sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg== - -"@types/web-bluetooth@^0.0.20": - version "0.0.20" - resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" - integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== - -"@types/ws@^8.5.14": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" - integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== - dependencies: - "@types/node" "*" - -"@types/yauzl@^2.9.1": - version "2.10.3" - resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" - integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== - dependencies: - "@types/node" "*" - -"@typescript-eslint/eslint-plugin@^7.1.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz#b16d3cf3ee76bf572fdf511e79c248bdec619ea3" - integrity sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/type-utils" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - graphemer "^1.4.0" - ignore "^5.3.1" - natural-compare "^1.4.0" - ts-api-utils "^1.3.0" - -"@typescript-eslint/eslint-plugin@^8.22.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz#151c4878700a5ad229ce6713d2674d58b626b3d9" - integrity sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.29.0" - "@typescript-eslint/type-utils" "8.29.0" - "@typescript-eslint/utils" "8.29.0" - "@typescript-eslint/visitor-keys" "8.29.0" - graphemer "^1.4.0" - ignore "^5.3.1" - natural-compare "^1.4.0" - ts-api-utils "^2.0.1" - -"@typescript-eslint/parser@^7.1.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.18.0.tgz#83928d0f1b7f4afa974098c64b5ce6f9051f96a0" - integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== - dependencies: - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - -"@typescript-eslint/parser@^8.22.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.29.0.tgz#b98841e0a8099728cb8583da92326fcb7f5be1d2" - integrity sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g== - dependencies: - "@typescript-eslint/scope-manager" "8.29.0" - "@typescript-eslint/types" "8.29.0" - "@typescript-eslint/typescript-estree" "8.29.0" - "@typescript-eslint/visitor-keys" "8.29.0" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz#c928e7a9fc2c0b3ed92ab3112c614d6bd9951c83" - integrity sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - -"@typescript-eslint/scope-manager@8.29.0", "@typescript-eslint/scope-manager@^8.13.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz#8fd9872823aef65ff71d3f6d1ec9316ace0b6bf3" - integrity sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw== - dependencies: - "@typescript-eslint/types" "8.29.0" - "@typescript-eslint/visitor-keys" "8.29.0" - -"@typescript-eslint/type-utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz#2165ffaee00b1fbbdd2d40aa85232dab6998f53b" - integrity sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA== - dependencies: - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - debug "^4.3.4" - ts-api-utils "^1.3.0" - -"@typescript-eslint/type-utils@8.29.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.29.0.tgz#98dcfd1193cb4e2b2d0294a8656ce5eb58c443a9" - integrity sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q== - dependencies: - "@typescript-eslint/typescript-estree" "8.29.0" - "@typescript-eslint/utils" "8.29.0" - debug "^4.3.4" - ts-api-utils "^2.0.1" - -"@typescript-eslint/types@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.18.0.tgz#b90a57ccdea71797ffffa0321e744f379ec838c9" - integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== - -"@typescript-eslint/types@8.29.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.29.0.tgz#65add70ab4ef66beaa42a5addf87dab2b05b1f33" - integrity sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg== - -"@typescript-eslint/typescript-estree@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz#b5868d486c51ce8f312309ba79bdb9f331b37931" - integrity sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^1.3.0" - -"@typescript-eslint/typescript-estree@8.29.0", "@typescript-eslint/typescript-estree@^8.13.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz#d201a4f115327ec90496307c9958262285065b00" - integrity sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow== - dependencies: - "@typescript-eslint/types" "8.29.0" - "@typescript-eslint/visitor-keys" "8.29.0" - debug "^4.3.4" - fast-glob "^3.3.2" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^2.0.1" - -"@typescript-eslint/utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.18.0.tgz#bca01cde77f95fc6a8d5b0dbcbfb3d6ca4be451f" - integrity sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - -"@typescript-eslint/utils@8.29.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.29.0.tgz#d6d22b19c8c4812a874f00341f686b45b9fe895f" - integrity sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "8.29.0" - "@typescript-eslint/types" "8.29.0" - "@typescript-eslint/typescript-estree" "8.29.0" - -"@typescript-eslint/visitor-keys@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz#0564629b6124d67607378d0f0332a0495b25e7d7" - integrity sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== - dependencies: - "@typescript-eslint/types" "7.18.0" - eslint-visitor-keys "^3.4.3" - -"@typescript-eslint/visitor-keys@8.29.0": - version "8.29.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz#2356336c9efdc3597ffcd2aa1ce95432852b743d" - integrity sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg== - dependencies: - "@typescript-eslint/types" "8.29.0" - eslint-visitor-keys "^4.2.0" - -"@ungap/structured-clone@^1.2.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" - integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== - -"@unrs/resolver-binding-darwin-arm64@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.3.3.tgz#394065916f98cdc1897cf7234adfdee395725fa8" - integrity sha512-EpRILdWr3/xDa/7MoyfO7JuBIJqpBMphtu4+80BK1bRfFcniVT74h3Z7q1+WOc92FuIAYatB1vn9TJR67sORGw== - -"@unrs/resolver-binding-darwin-x64@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.3.3.tgz#6a3c75ca984342261c7346db53293b0002e8cde1" - integrity sha512-ntj/g7lPyqwinMJWZ+DKHBse8HhVxswGTmNgFKJtdgGub3M3zp5BSZ3bvMP+kBT6dnYJLSVlDqdwOq1P8i0+/g== - -"@unrs/resolver-binding-freebsd-x64@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.3.3.tgz#6532b8d4fecaca6c4424791c82f7a27aac94fcd5" - integrity sha512-l6BT8f2CU821EW7U8hSUK8XPq4bmyTlt9Mn4ERrfjJNoCw0/JoHAh9amZZtV3cwC3bwwIat+GUnrcHTG9+qixw== - -"@unrs/resolver-binding-linux-arm-gnueabihf@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.3.3.tgz#69a8e430095fcf6a76f7350cc27b83464f8cbb91" - integrity sha512-8ScEc5a4y7oE2BonRvzJ+2GSkBaYWyh0/Ko4Q25e/ix6ANpJNhwEPZvCR6GVRmsQAYMIfQvYLdM6YEN+qRjnAQ== - -"@unrs/resolver-binding-linux-arm-musleabihf@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.3.3.tgz#e1fc8440e54929b1f0f6aff6f6e3e9e19ac4a73c" - integrity sha512-8qQ6l1VTzLNd3xb2IEXISOKwMGXDCzY/UNy/7SovFW2Sp0K3YbL7Ao7R18v6SQkLqQlhhqSBIFRk+u6+qu5R5A== - -"@unrs/resolver-binding-linux-arm64-gnu@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.3.3.tgz#1249e18b5fa1419addda637d62ef201ce9bcf5a4" - integrity sha512-v81R2wjqcWXJlQY23byqYHt9221h4anQ6wwN64oMD/WAE+FmxPHFZee5bhRkNVtzqO/q7wki33VFWlhiADwUeQ== - -"@unrs/resolver-binding-linux-arm64-musl@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.3.3.tgz#9af549ce9dde57b31c32a36cbe9eafa05f96befd" - integrity sha512-cAOx/j0u5coMg4oct/BwMzvWJdVciVauUvsd+GQB/1FZYKQZmqPy0EjJzJGbVzFc6gbnfEcSqvQE6gvbGf2N8Q== - -"@unrs/resolver-binding-linux-ppc64-gnu@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.3.3.tgz#45aab52319f3e3b2627038a80c0331b0793a4be3" - integrity sha512-mq2blqwErgDJD4gtFDlTX/HZ7lNP8YCHYFij2gkXPtMzrXxPW1hOtxL6xg4NWxvnj4bppppb0W3s/buvM55yfg== - -"@unrs/resolver-binding-linux-s390x-gnu@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.3.3.tgz#7d2fe5c43e291d42e66d74fce07d9cf0050b4241" - integrity sha512-u0VRzfFYysarYHnztj2k2xr+eu9rmgoTUUgCCIT37Nr+j0A05Xk2c3RY8Mh5+DhCl2aYibihnaAEJHeR0UOFIQ== - -"@unrs/resolver-binding-linux-x64-gnu@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.3.3.tgz#be54ff88c581610c42d8614475c0560f043d7ded" - integrity sha512-OrVo5ZsG29kBF0Ug95a2KidS16PqAMmQNozM6InbquOfW/udouk063e25JVLqIBhHLB2WyBnixOQ19tmeC/hIg== - -"@unrs/resolver-binding-linux-x64-musl@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.3.3.tgz#4efa7a1e4f7bf231098ed23df1e19174d360c24f" - integrity sha512-PYnmrwZ4HMp9SkrOhqPghY/aoL+Rtd4CQbr93GlrRTjK6kDzfMfgz3UH3jt6elrQAfupa1qyr1uXzeVmoEAxUA== - -"@unrs/resolver-binding-wasm32-wasi@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.3.3.tgz#6df454b4a9b28d47850bcb665d243f09101b782c" - integrity sha512-81AnQY6fShmktQw4hWDUIilsKSdvr/acdJ5azAreu2IWNlaJOKphJSsUVWE+yCk6kBMoQyG9ZHCb/krb5K0PEA== - dependencies: - "@napi-rs/wasm-runtime" "^0.2.7" - -"@unrs/resolver-binding-win32-arm64-msvc@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.3.3.tgz#fb19e118350e1392993a0a6565b427d38c1c1760" - integrity sha512-X/42BMNw7cW6xrB9syuP5RusRnWGoq+IqvJO8IDpp/BZg64J1uuIW6qA/1Cl13Y4LyLXbJVYbYNSKwR/FiHEng== - -"@unrs/resolver-binding-win32-ia32-msvc@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.3.3.tgz#23a9c4b5621bba2d472bc78fadde7273a8c4548d" - integrity sha512-EGNnNGQxMU5aTN7js3ETYvuw882zcO+dsVjs+DwO2j/fRVKth87C8e2GzxW1L3+iWAXMyJhvFBKRavk9Og1Z6A== - -"@unrs/resolver-binding-win32-x64-msvc@1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.3.3.tgz#eee226e5b4c4d91c862248afd24452c8698ed542" - integrity sha512-GraLbYqOJcmW1qY3osB+2YIiD62nVf2/bVLHZmrb4t/YSUwE03l7TwcDJl08T/Tm3SVhepX8RQkpzWbag/Sb4w== - -"@vitejs/plugin-vue-jsx@^4.1.2": - version "4.1.2" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-4.1.2.tgz#7ab813bcba9a514a2bdee5b0e17c30d6e420c748" - integrity sha512-4Rk0GdE0QCdsIkuMmWeg11gmM4x8UmTnZR/LWPm7QJ7+BsK4tq08udrN0isrrWqz5heFy9HLV/7bOLgFS8hUjA== - dependencies: - "@babel/core" "^7.26.7" - "@babel/plugin-transform-typescript" "^7.26.7" - "@vue/babel-plugin-jsx" "^1.2.5" - -"@vitejs/plugin-vue@^5.2.3": - version "5.2.3" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz#71a8fc82d4d2e425af304c35bf389506f674d89b" - integrity sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg== - -"@vitest/coverage-v8@^2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz#060bebfe3705c1023bdc220e17fdea4bd9e2b24d" - integrity sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ== - dependencies: - "@ampproject/remapping" "^2.3.0" - "@bcoe/v8-coverage" "^0.2.3" - debug "^4.3.7" - istanbul-lib-coverage "^3.2.2" - istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^5.0.6" - istanbul-reports "^3.1.7" - magic-string "^0.30.12" - magicast "^0.3.5" - std-env "^3.8.0" - test-exclude "^7.0.1" - tinyrainbow "^1.2.0" - -"@vitest/expect@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.1.9.tgz#b566ea20d58ea6578d8dc37040d6c1a47ebe5ff8" - integrity sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw== - dependencies: - "@vitest/spy" "2.1.9" - "@vitest/utils" "2.1.9" - chai "^5.1.2" - tinyrainbow "^1.2.0" - -"@vitest/mocker@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-2.1.9.tgz#36243b27351ca8f4d0bbc4ef91594ffd2dc25ef5" - integrity sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg== - dependencies: - "@vitest/spy" "2.1.9" - estree-walker "^3.0.3" - magic-string "^0.30.12" - -"@vitest/pretty-format@2.1.9", "@vitest/pretty-format@^2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz#434ff2f7611689f9ce70cd7d567eceb883653fdf" - integrity sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ== - dependencies: - tinyrainbow "^1.2.0" - -"@vitest/runner@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.1.9.tgz#cc18148d2d797fd1fd5908d1f1851d01459be2f6" - integrity sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g== - dependencies: - "@vitest/utils" "2.1.9" - pathe "^1.1.2" - -"@vitest/snapshot@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.1.9.tgz#24260b93f798afb102e2dcbd7e61c6dfa118df91" - integrity sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ== - dependencies: - "@vitest/pretty-format" "2.1.9" - magic-string "^0.30.12" - pathe "^1.1.2" - -"@vitest/spy@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.1.9.tgz#cb28538c5039d09818b8bfa8edb4043c94727c60" - integrity sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ== - dependencies: - tinyspy "^3.0.2" - -"@vitest/utils@2.1.9": - version "2.1.9" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.1.9.tgz#4f2486de8a54acf7ecbf2c5c24ad7994a680a6c1" - integrity sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ== - dependencies: - "@vitest/pretty-format" "2.1.9" - loupe "^3.1.2" - tinyrainbow "^1.2.0" - -"@volar/language-core@2.4.12", "@volar/language-core@~2.4.11": - version "2.4.12" - resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.12.tgz#98c8424f8d81a9cad1760a587b1c6db27d05f0cc" - integrity sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA== - dependencies: - "@volar/source-map" "2.4.12" - -"@volar/source-map@2.4.12": - version "2.4.12" - resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.12.tgz#7cc8c6b1b134a2215f06c91ad011d94eef81b0ed" - integrity sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw== - -"@volar/typescript@~2.4.11": - version "2.4.12" - resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.12.tgz#8c638c23cab89ab131cdcd2d6f2a51768caaa015" - integrity sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g== - dependencies: - "@volar/language-core" "2.4.12" - path-browserify "^1.0.1" - vscode-uri "^3.0.8" - -"@vue/babel-helper-vue-transform-on@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.4.0.tgz#616020488692a9c42a613280d62ed1b727045d95" - integrity sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw== - -"@vue/babel-plugin-jsx@^1.1.5", "@vue/babel-plugin-jsx@^1.2.5": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.4.0.tgz#c155c795ce980edf46aa6feceed93945a95ca658" - integrity sha512-9zAHmwgMWlaN6qRKdrg1uKsBKHvnUU+Py+MOCTuYZBoZsopa90Di10QRjB+YPnVss0BZbG/H5XFwJY1fTxJWhA== - dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/plugin-syntax-jsx" "^7.25.9" - "@babel/template" "^7.26.9" - "@babel/traverse" "^7.26.9" - "@babel/types" "^7.26.9" - "@vue/babel-helper-vue-transform-on" "1.4.0" - "@vue/babel-plugin-resolve-type" "1.4.0" - "@vue/shared" "^3.5.13" - -"@vue/babel-plugin-resolve-type@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.4.0.tgz#4d357a81fb0cc9cad0e8c81b118115bda2c51543" - integrity sha512-4xqDRRbQQEWHQyjlYSgZsWj44KfiF6D+ktCuXyZ8EnVDYV3pztmXJDf1HveAjUAXxAnR8daCQT51RneWWxtTyQ== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-plugin-utils" "^7.26.5" - "@babel/parser" "^7.26.9" - "@vue/compiler-sfc" "^3.5.13" - -"@vue/compiler-core@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05" - integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== - dependencies: - "@babel/parser" "^7.25.3" - "@vue/shared" "3.5.13" - entities "^4.5.0" - estree-walker "^2.0.2" - source-map-js "^1.2.0" - -"@vue/compiler-dom@3.5.13", "@vue/compiler-dom@^3.2.45", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@^3.5.0": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58" - integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== - dependencies: - "@vue/compiler-core" "3.5.13" - "@vue/shared" "3.5.13" - -"@vue/compiler-sfc@3.5.13", "@vue/compiler-sfc@^3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46" - integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== - dependencies: - "@babel/parser" "^7.25.3" - "@vue/compiler-core" "3.5.13" - "@vue/compiler-dom" "3.5.13" - "@vue/compiler-ssr" "3.5.13" - "@vue/shared" "3.5.13" - estree-walker "^2.0.2" - magic-string "^0.30.11" - postcss "^8.4.48" - source-map-js "^1.2.0" - -"@vue/compiler-ssr@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba" - integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== - dependencies: - "@vue/compiler-dom" "3.5.13" - "@vue/shared" "3.5.13" - -"@vue/compiler-vue2@^2.7.16": - version "2.7.16" - resolved "https://registry.yarnpkg.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz#2ba837cbd3f1b33c2bc865fbe1a3b53fb611e249" - integrity sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A== - dependencies: - de-indent "^1.0.2" - he "^1.2.0" - -"@vue/devtools-api@^6.5.0", "@vue/devtools-api@^6.6.3", "@vue/devtools-api@^6.6.4": - version "6.6.4" - resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" - integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== - -"@vue/devtools-core@^7.7.2": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@vue/devtools-core/-/devtools-core-7.7.2.tgz#fbba7714b4cecd4e4b80e1b68c249b013c6ea6a7" - integrity sha512-lexREWj1lKi91Tblr38ntSsy6CvI8ba7u+jmwh2yruib/ltLUcsIzEjCnrkh1yYGGIKXbAuYV2tOG10fGDB9OQ== - dependencies: - "@vue/devtools-kit" "^7.7.2" - "@vue/devtools-shared" "^7.7.2" - mitt "^3.0.1" - nanoid "^5.0.9" - pathe "^2.0.2" - vite-hot-client "^0.2.4" - -"@vue/devtools-kit@^7.7.2": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@vue/devtools-kit/-/devtools-kit-7.7.2.tgz#3315bd5b144f98c7b84c2f44270b445644ec8f10" - integrity sha512-CY0I1JH3Z8PECbn6k3TqM1Bk9ASWxeMtTCvZr7vb+CHi+X/QwQm5F1/fPagraamKMAHVfuuCbdcnNg1A4CYVWQ== - dependencies: - "@vue/devtools-shared" "^7.7.2" - birpc "^0.2.19" - hookable "^5.5.3" - mitt "^3.0.1" - perfect-debounce "^1.0.0" - speakingurl "^14.0.1" - superjson "^2.2.1" - -"@vue/devtools-shared@^7.7.2": - version "7.7.2" - resolved "https://registry.yarnpkg.com/@vue/devtools-shared/-/devtools-shared-7.7.2.tgz#b11b143820130a32d8ce5737e264d06ab6d62f40" - integrity sha512-uBFxnp8gwW2vD6FrJB8JZLUzVb6PNRG0B0jBnHsOH8uKyva2qINY8PTF5Te4QlTbMDqU5K6qtJDr6cNsKWhbOA== - dependencies: - rfdc "^1.4.1" - -"@vue/eslint-config-prettier@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@vue/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz#f63394f8f7759d92b6ef3f3e1d30ff6b0c0b97c1" - integrity sha512-z1ZIAAUS9pKzo/ANEfd2sO+v2IUalz7cM/cTLOZ7vRFOPk5/xuRKQteOu1DErFLAh/lYGXMVZ0IfYKlyInuDVg== - dependencies: - eslint-config-prettier "^9.0.0" - eslint-plugin-prettier "^5.0.0" - -"@vue/eslint-config-typescript@^13.0.0": - version "13.0.0" - resolved "https://registry.yarnpkg.com/@vue/eslint-config-typescript/-/eslint-config-typescript-13.0.0.tgz#f5f3d986ace34a10f403921d5044831b89a1b679" - integrity sha512-MHh9SncG/sfqjVqjcuFLOLD6Ed4dRAis4HNt0dXASeAuLqIAx4YMB1/m2o4pUKK1vCt8fUvYG8KKX2Ot3BVZTg== - dependencies: - "@typescript-eslint/eslint-plugin" "^7.1.1" - "@typescript-eslint/parser" "^7.1.1" - vue-eslint-parser "^9.3.1" - -"@vue/language-core@2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.2.0.tgz#e48c54584f889f78b120ce10a050dfb316c7fcdf" - integrity sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw== - dependencies: - "@volar/language-core" "~2.4.11" - "@vue/compiler-dom" "^3.5.0" - "@vue/compiler-vue2" "^2.7.16" - "@vue/shared" "^3.5.0" - alien-signals "^0.4.9" - minimatch "^9.0.3" - muggle-string "^0.4.1" - path-browserify "^1.0.1" - -"@vue/reactivity@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f" - integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== - dependencies: - "@vue/shared" "3.5.13" - -"@vue/runtime-core@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455" - integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== - dependencies: - "@vue/reactivity" "3.5.13" - "@vue/shared" "3.5.13" - -"@vue/runtime-dom@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215" - integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== - dependencies: - "@vue/reactivity" "3.5.13" - "@vue/runtime-core" "3.5.13" - "@vue/shared" "3.5.13" - csstype "^3.1.3" - -"@vue/server-renderer@3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7" - integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== - dependencies: - "@vue/compiler-ssr" "3.5.13" - "@vue/shared" "3.5.13" - -"@vue/shared@3.5.13", "@vue/shared@^3.5.0", "@vue/shared@^3.5.13": - version "3.5.13" - resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" - integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== - -"@vue/tsconfig@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.7.0.tgz#67044c847b7a137b8cbfd6b23104c36dbaf80d1d" - integrity sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg== - -"@vuelidate/core@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@vuelidate/core/-/core-2.0.3.tgz#40468c5ed15b72bde880a026b0699c2f0f1ecede" - integrity sha512-AN6l7KF7+mEfyWG0doT96z+47ljwPpZfi9/JrNMkOGLFv27XVZvKzRLXlmDPQjPl/wOB1GNnHuc54jlCLRNqGA== - dependencies: - vue-demi "^0.13.11" - -"@vuelidate/validators@^2.0.4": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@vuelidate/validators/-/validators-2.0.4.tgz#0a88a7b2b18f15fd9c384095593f369a6f7384e9" - integrity sha512-odTxtUZ2JpwwiQ10t0QWYJkkYrfd0SyFYhdHH44QQ1jDatlZgTh/KRzrWVmn/ib9Gq7H4hFD4e8ahoo5YlUlDw== - dependencies: - vue-demi "^0.13.11" - -"@vuetify/loader-shared@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@vuetify/loader-shared/-/loader-shared-2.1.0.tgz#29410dce04a78fa9cd40c4d9bc417b8d61ce5103" - integrity sha512-dNE6Ceym9ijFsmJKB7YGW0cxs7xbYV8+1LjU6jd4P14xOt/ji4Igtgzt0rJFbxu+ZhAzqz853lhB0z8V9Dy9cQ== - dependencies: - upath "^2.0.1" - -"@vueuse/core@^11.3.0": - version "11.3.0" - resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-11.3.0.tgz#bb0bd1f0edd5435d20694dbe51091cf548653a4d" - integrity sha512-7OC4Rl1f9G8IT6rUfi9JrKiXy4bfmHhZ5x2Ceojy0jnd3mHNEvV4JaRygH362ror6/NZ+Nl+n13LPzGiPN8cKA== - dependencies: - "@types/web-bluetooth" "^0.0.20" - "@vueuse/metadata" "11.3.0" - "@vueuse/shared" "11.3.0" - vue-demi ">=0.14.10" - -"@vueuse/metadata@11.3.0": - version "11.3.0" - resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.3.0.tgz#be7ac12e3016c0353a3667b372a73aeeee59194e" - integrity sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g== - -"@vueuse/shared@11.3.0", "@vueuse/shared@^11.3.0": - version "11.3.0" - resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-11.3.0.tgz#086a4f35bf5bcec5655a03b80eae582605a4b21d" - integrity sha512-P8gSSWQeucH5821ek2mn/ciCk+MS/zoRKqdQIM3bHq6p7GXDAJLmnRRKmF5F65sAVJIfzQlwR3aDzwCn10s8hA== - dependencies: - vue-demi ">=0.14.10" - -"@xmldom/xmldom@^0.8.8": - version "0.8.10" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" - integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw== - -"@zip.js/zip.js@^2.7.60": - version "2.7.60" - resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.60.tgz#0de96b93519cad804c82f96faebceda836cb24c0" - integrity sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA== - -abstract-leveldown@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-7.2.0.tgz#08d19d4e26fb5be426f7a57004851b39e1795a2e" - integrity sha512-DnhQwcFEaYsvYDnACLZhMmCWd3rkOeEvglpa4q5i/5Jlm3UIsWaxVzuXvDLFCSCWRO3yy2/+V/G7FusFgejnfQ== - dependencies: - buffer "^6.0.3" - catering "^2.0.0" - is-buffer "^2.0.5" - level-concat-iterator "^3.0.0" - level-supports "^2.0.1" - queue-microtask "^1.2.3" - -abstract-leveldown@~6.2.1: - version "6.2.3" - resolved "https://registry.yarnpkg.com/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz#036543d87e3710f2528e47040bc3261b77a9a8eb" - integrity sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ== - dependencies: - buffer "^5.5.0" - immediate "^3.2.3" - level-concat-iterator "~2.0.0" - level-supports "~1.0.0" - xtend "~4.0.0" - -acorn-jsx@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" - integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== - -acorn@^8.14.0, acorn@^8.5.0, acorn@^8.8.2, acorn@^8.9.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== - -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ajv-keywords@^3.4.1: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== - -ajv@^6.10.0, ajv@^6.12.0, ajv@^6.12.4: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@~8.12.0: - version "8.12.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" - integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -alien-signals@^0.4.9: - version "0.4.14" - resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-0.4.14.tgz#9ff8f72a272300a51692f54bd9bbbada78fbf539" - integrity sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q== - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-regex@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" - integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^6.1.0, ansi-styles@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" - integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== - -app-builder-bin@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-4.0.0.tgz#1df8e654bd1395e4a319d82545c98667d7eed2f0" - integrity sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA== - -app-builder-lib@24.13.3: - version "24.13.3" - resolved "https://registry.yarnpkg.com/app-builder-lib/-/app-builder-lib-24.13.3.tgz#36e47b65fecb8780bb73bff0fee4e0480c28274b" - integrity sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig== - dependencies: - "@develar/schema-utils" "~2.6.5" - "@electron/notarize" "2.2.1" - "@electron/osx-sign" "1.0.5" - "@electron/universal" "1.5.1" - "@malept/flatpak-bundler" "^0.4.0" - "@types/fs-extra" "9.0.13" - async-exit-hook "^2.0.1" - bluebird-lst "^1.0.9" - builder-util "24.13.1" - builder-util-runtime "9.2.4" - chromium-pickle-js "^0.2.0" - debug "^4.3.4" - ejs "^3.1.8" - electron-publish "24.13.1" - form-data "^4.0.0" - fs-extra "^10.1.0" - hosted-git-info "^4.1.0" - is-ci "^3.0.0" - isbinaryfile "^5.0.0" - js-yaml "^4.1.0" - lazy-val "^1.0.5" - minimatch "^5.1.1" - read-config-file "6.3.2" - sanitize-filename "^1.6.3" - semver "^7.3.8" - tar "^6.1.12" - temp-file "^3.4.0" - -are-docs-informative@^0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963" - integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig== - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" - integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== - dependencies: - call-bound "^1.0.3" - is-array-buffer "^3.0.5" - -array-includes@^3.1.8: - version "3.1.8" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" - integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.4" - is-string "^1.0.7" - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -array.prototype.findlastindex@^1.2.5: - version "1.2.6" - resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" - integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - define-properties "^1.2.1" - es-abstract "^1.23.9" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - es-shim-unscopables "^1.1.0" - -array.prototype.flat@^1.3.2: - version "1.3.3" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" - integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-shim-unscopables "^1.0.2" - -array.prototype.flatmap@^1.3.2: - version "1.3.3" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" - integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-shim-unscopables "^1.0.2" - -arraybuffer.prototype.slice@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" - integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== - dependencies: - array-buffer-byte-length "^1.0.1" - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - is-array-buffer "^3.0.4" - -assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== - -assert@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" - integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== - dependencies: - call-bind "^1.0.2" - is-nan "^1.3.2" - object-is "^1.1.5" - object.assign "^4.1.4" - util "^0.12.5" - -assertion-error@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" - integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== - -astral-regex@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" - integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== - -async-exit-hook@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" - integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw== - -async-function@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" - integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== - -async@^3.2.3: - version "3.2.6" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" - integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== - -asynckit@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" - integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -auto-bind@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-5.0.1.tgz#50d8e63ea5a1dddcb5e5e36451c1a8266ffbb2ae" - integrity sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== - -available-typed-arrays@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" - integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== - dependencies: - possible-typed-array-names "^1.0.0" - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base64-js@^1.3.1, base64-js@^1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== - -birpc@^0.2.19: - version "0.2.19" - resolved "https://registry.yarnpkg.com/birpc/-/birpc-0.2.19.tgz#cdd183a4a70ba103127d49765b4a71349da5a0ca" - integrity sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ== - -bluebird-lst@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/bluebird-lst/-/bluebird-lst-1.0.9.tgz#a64a0e4365658b9ab5fe875eb9dfb694189bb41c" - integrity sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw== - dependencies: - bluebird "^3.5.5" - -bluebird@^3.5.5: - version "3.7.2" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" - integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== - -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - -boolean@^3.0.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" - integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== - dependencies: - balanced-match "^1.0.0" - -braces@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" - integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - dependencies: - fill-range "^7.1.1" - -browserslist@^4.24.0: - version "4.24.4" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" - integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== - dependencies: - caniuse-lite "^1.0.30001688" - electron-to-chromium "^1.5.73" - node-releases "^2.0.19" - update-browserslist-db "^1.1.1" - -buffer-builder@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/buffer-builder/-/buffer-builder-0.2.0.tgz#3322cd307d8296dab1f604618593b261a3fade8f" - integrity sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg== - -buffer-crc32@~0.2.3: - version "0.2.13" - resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" - integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== - -buffer-equal@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.1.tgz#2f7651be5b1b3f057fcd6e7ee16cf34767077d90" - integrity sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg== - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -buffer@^5.1.0, buffer@^5.5.0, buffer@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - -buffer@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" - integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.2.1" - -bufferutil@^4.0.9: - version "4.0.9" - resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.9.tgz#6e81739ad48a95cad45a279588e13e95e24a800a" - integrity sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw== - dependencies: - node-gyp-build "^4.3.0" - -builder-util-runtime@9.2.4: - version "9.2.4" - resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz#13cd1763da621e53458739a1e63f7fcba673c42a" - integrity sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA== - dependencies: - debug "^4.3.4" - sax "^1.2.4" - -builder-util-runtime@9.3.1: - version "9.3.1" - resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz#0daedde0f6d381f2a00a50a407b166fe7dca1a67" - integrity sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ== - dependencies: - debug "^4.3.4" - sax "^1.2.4" - -builder-util@24.13.1: - version "24.13.1" - resolved "https://registry.yarnpkg.com/builder-util/-/builder-util-24.13.1.tgz#4a4c4f9466b016b85c6990a0ea15aa14edec6816" - integrity sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA== - dependencies: - "7zip-bin" "~5.2.0" - "@types/debug" "^4.1.6" - app-builder-bin "4.0.0" - bluebird-lst "^1.0.9" - builder-util-runtime "9.2.4" - chalk "^4.1.2" - cross-spawn "^7.0.3" - debug "^4.3.4" - fs-extra "^10.1.0" - http-proxy-agent "^5.0.0" - https-proxy-agent "^5.0.1" - is-ci "^3.0.0" - js-yaml "^4.1.0" - source-map-support "^0.5.19" - stat-mode "^1.0.0" - temp-file "^3.4.0" - -bundle-name@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" - integrity sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q== - dependencies: - run-applescript "^7.0.0" - -cac@^6.7.14: - version "6.7.14" - resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" - integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== - -cacheable-lookup@^5.0.3: - version "5.0.4" - resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" - integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== - -cacheable-request@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.4.tgz#7a33ebf08613178b403635be7b899d3e69bbe817" - integrity sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^4.0.0" - lowercase-keys "^2.0.0" - normalize-url "^6.0.1" - responselike "^2.0.0" - -call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" - integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - -call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" - integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== - dependencies: - call-bind-apply-helpers "^1.0.0" - es-define-property "^1.0.0" - get-intrinsic "^1.2.4" - set-function-length "^1.2.2" - -call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -caniuse-lite@^1.0.30001688: - version "1.0.30001707" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz#c5e104d199e6f4355a898fcd995a066c7eb9bf41" - integrity sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw== - -catering@^2.0.0, catering@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/catering/-/catering-2.1.1.tgz#66acba06ed5ee28d5286133982a927de9a04b510" - integrity sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w== - -chai@^5.1.2: - version "5.2.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05" - integrity sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw== - dependencies: - assertion-error "^2.0.1" - check-error "^2.1.1" - deep-eql "^5.0.1" - loupe "^3.1.0" - pathval "^2.0.0" - -chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -check-error@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" - integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== - -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -chromium-pickle-js@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz#04a106672c18b085ab774d983dfa3ea138f22205" - integrity sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw== - -ci-info@^3.2.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== - -clean-stack@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-5.2.0.tgz#c7a0c91939c7caace30a3bf254e8a8ac276d1189" - integrity sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ== - dependencies: - escape-string-regexp "5.0.0" - -cli-truncate@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" - integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== - dependencies: - slice-ansi "^3.0.0" - string-width "^4.2.0" - -cliui@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" - integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" - -clone-response@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" - integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== - dependencies: - mimic-response "^1.0.0" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -colorjs.io@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" - integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== - -combined-stream@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" - integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== - dependencies: - delayed-stream "~1.0.0" - -commander@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - -comment-parser@1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" - integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== - -compare-version@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/compare-version/-/compare-version-0.1.2.tgz#0162ec2d9351f5ddd59a9202cba935366a725080" - integrity sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -confbox@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" - integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== - -config-file-ts@^0.2.4: - version "0.2.6" - resolved "https://registry.yarnpkg.com/config-file-ts/-/config-file-ts-0.2.6.tgz#b424ff74612fb37f626d6528f08f92ddf5d22027" - integrity sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w== - dependencies: - glob "^10.3.10" - typescript "^5.3.3" - -convert-source-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" - integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== - -copy-anything@^3.0.2: - version "3.0.5" - resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" - integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== - dependencies: - is-what "^4.1.8" - -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - -crc@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== - dependencies: - buffer "^5.1.0" - -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" - integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -csstype@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" - integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== - -data-view-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" - integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-data-view "^1.0.2" - -data-view-byte-length@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" - integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-data-view "^1.0.2" - -data-view-byte-offset@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" - integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - is-data-view "^1.0.1" - -de-indent@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg== - -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" - integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== - dependencies: - ms "^2.1.3" - -debug@4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -decompress-response@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" - integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== - dependencies: - mimic-response "^3.1.0" - -deep-eql@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" - integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== - -deep-is@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" - integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== - -default-browser-id@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/default-browser-id/-/default-browser-id-5.0.0.tgz#a1d98bf960c15082d8a3fa69e83150ccccc3af26" - integrity sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA== - -default-browser@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/default-browser/-/default-browser-5.2.1.tgz#7b7ba61204ff3e425b556869ae6d3e9d9f1712cf" - integrity sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg== - dependencies: - bundle-name "^4.1.0" - default-browser-id "^5.0.0" - -defer-to-connect@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" - integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== - -deferred-leveldown@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/deferred-leveldown/-/deferred-leveldown-7.0.0.tgz#39802715fda6ec06d0159a8b28bd1c7e2b1cf0bf" - integrity sha512-QKN8NtuS3BC6m0B8vAnBls44tX1WXAFATUsJlruyAYbZpysWV3siH6o/i3g9DCHauzodksO60bdj5NazNbjCmg== - dependencies: - abstract-leveldown "^7.2.0" - inherits "^2.0.3" - -deferred-leveldown@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz#27a997ad95408b61161aa69bd489b86c71b78058" - integrity sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw== - dependencies: - abstract-leveldown "~6.2.1" - inherits "^2.0.3" - -define-data-property@^1.0.1, define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -define-lazy-prop@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" - integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== - -define-properties@^1.1.3, define-properties@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" - integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== - dependencies: - define-data-property "^1.0.1" - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -del-cli@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/del-cli/-/del-cli-6.0.0.tgz#7822d0ffd5b73449a506a586d839711485bfb119" - integrity sha512-9nitGV2W6KLFyya4qYt4+9AKQFL+c0Ehj5K7V7IwlxTc6RMCfQUGY9E9pLG6e8TQjtwXpuiWIGGZb3mfVxyZkw== - dependencies: - del "^8.0.0" - meow "^13.2.0" - -del@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/del/-/del-8.0.0.tgz#f333a5673cfeb72e46084031714a7c30515e80aa" - integrity sha512-R6ep6JJ+eOBZsBr9esiNN1gxFbZE4Q2cULkUSFumGYecAiS6qodDvcPx/sFuWHMNul7DWmrtoEOpYSm7o6tbSA== - dependencies: - globby "^14.0.2" - is-glob "^4.0.3" - is-path-cwd "^3.0.0" - is-path-inside "^4.0.0" - p-map "^7.0.2" - slash "^5.1.0" - -delayed-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" - integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== - -detect-node@^2.0.4: - version "2.1.0" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" - integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== - -dir-compare@^3.0.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/dir-compare/-/dir-compare-3.3.0.tgz#2c749f973b5c4b5d087f11edaae730db31788416" - integrity sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg== - dependencies: - buffer-equal "^1.0.0" - minimatch "^3.0.4" - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -dmg-builder@24.13.3: - version "24.13.3" - resolved "https://registry.yarnpkg.com/dmg-builder/-/dmg-builder-24.13.3.tgz#95d5b99c587c592f90d168a616d7ec55907c7e55" - integrity sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ== - dependencies: - app-builder-lib "24.13.3" - builder-util "24.13.1" - builder-util-runtime "9.2.4" - fs-extra "^10.1.0" - iconv-lite "^0.6.2" - js-yaml "^4.1.0" - optionalDependencies: - dmg-license "^1.0.11" - -dmg-license@^1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/dmg-license/-/dmg-license-1.0.11.tgz#7b3bc3745d1b52be7506b4ee80cb61df6e4cd79a" - integrity sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q== - dependencies: - "@types/plist" "^3.0.1" - "@types/verror" "^1.10.3" - ajv "^6.10.0" - crc "^3.8.0" - iconv-corefoundation "^1.1.7" - plist "^3.0.4" - smart-buffer "^4.0.2" - verror "^1.10.0" - -doctrine@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" - integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== - dependencies: - esutils "^2.0.2" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dotenv-expand@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" - integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== - -dotenv@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-9.0.2.tgz#dacc20160935a37dea6364aa1bef819fb9b6ab05" - integrity sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg== - -double-ended-queue@2.1.0-0: - version "2.1.0-0" - resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" - integrity sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ== - -dunder-proto@^1.0.0, dunder-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" - integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== - dependencies: - call-bind-apply-helpers "^1.0.1" - es-errors "^1.3.0" - gopd "^1.2.0" - -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - -ejs@^3.1.8: - version "3.1.10" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" - integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== - dependencies: - jake "^10.8.5" - -electron-builder@^24.13.3: - version "24.13.3" - resolved "https://registry.yarnpkg.com/electron-builder/-/electron-builder-24.13.3.tgz#c506dfebd36d9a50a83ee8aa32d803d83dbe4616" - integrity sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg== - dependencies: - app-builder-lib "24.13.3" - builder-util "24.13.1" - builder-util-runtime "9.2.4" - chalk "^4.1.2" - dmg-builder "24.13.3" - fs-extra "^10.1.0" - is-ci "^3.0.0" - lazy-val "^1.0.5" - read-config-file "6.3.2" - simple-update-notifier "2.0.0" - yargs "^17.6.2" - -electron-is-dev@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-3.0.1.tgz#1cbc79b1dd046787903acd357efdfab6549dc17a" - integrity sha512-8TjjAh8Ec51hUi3o4TaU0mD3GMTOESi866oRNavj9A3IQJ7pmv+MJVmdZBFGw4GFT36X7bkqnuDNYvkQgvyI8Q== - -electron-log@^5.3.0: - version "5.3.3" - resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-5.3.3.tgz#323f5e70b3658d683a0f51f26867dc077a823aa3" - integrity sha512-ZOnlgCVfhKC0Nef68L0wDhwhg8nh5QkpEOA+udjpBxcPfTHGgbZbfoCBS6hmAgVHTAWByHNPkHKpSbEOPGZcxA== - -electron-publish@24.13.1: - version "24.13.1" - resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-24.13.1.tgz#57289b2f7af18737dc2ad134668cdd4a1b574a0c" - integrity sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A== - dependencies: - "@types/fs-extra" "^9.0.11" - builder-util "24.13.1" - builder-util-runtime "9.2.4" - chalk "^4.1.2" - fs-extra "^10.1.0" - lazy-val "^1.0.5" - mime "^2.5.2" - -electron-to-chromium@^1.5.73: - version "1.5.130" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.130.tgz#19f5dd2cf166a8dd1350b08230a33faba0d8f4ec" - integrity sha512-Ou2u7L9j2XLZbhqzyX0jWDj6gA8D3jIfVzt4rikLf3cGBa0VdReuFimBKS9tQJA4+XpeCxj1NoWlfBXzbMa9IA== - -electron-unhandled@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/electron-unhandled/-/electron-unhandled-5.0.0.tgz#2e36b0c8970734b95e071086cb3e2652202c685e" - integrity sha512-spH7quQUrNWgTp1rJNa0sCPTPHwKywctnHLVbrQxpjxojQLhGxXHMdETBkag0No8x9Jwo/c6r2T/nfeoG+4Cxw== - dependencies: - clean-stack "^5.2.0" - electron-is-dev "^3.0.1" - ensure-error "^4.0.0" - lodash.debounce "^4.0.8" - serialize-error "^11.0.3" - -electron-updater@^6.4.1: - version "6.6.2" - resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-6.6.2.tgz#3e65e044f1a99b00d61e200e24de8e709c69ce99" - integrity sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw== - dependencies: - builder-util-runtime "9.3.1" - fs-extra "^10.1.0" - js-yaml "^4.1.0" - lazy-val "^1.0.5" - lodash.escaperegexp "^4.1.2" - lodash.isequal "^4.5.0" - semver "^7.6.3" - tiny-typed-emitter "^2.1.0" - -electron-vite@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/electron-vite/-/electron-vite-2.3.0.tgz#58de48f9423980d860d2648e59fdbf0f8cd74b8c" - integrity sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg== - dependencies: - "@babel/core" "^7.24.7" - "@babel/plugin-transform-arrow-functions" "^7.24.7" - cac "^6.7.14" - esbuild "^0.21.5" - magic-string "^0.30.10" - picocolors "^1.0.1" - -electron@^31.7.7: - version "31.7.7" - resolved "https://registry.yarnpkg.com/electron/-/electron-31.7.7.tgz#81d5d0eef818b40008cd290dc4fd30565ef7b225" - integrity sha512-HZtZg8EHsDGnswFt0QeV8If8B+et63uD6RJ7I4/xhcXqmTIbI08GoubX/wm+HdY0DwcuPe1/xsgqpmYvjdjRoA== - dependencies: - "@electron/get" "^2.0.0" - "@types/node" "^20.9.0" - extract-zip "^2.0.1" - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -enhanced-resolve@^5.17.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - -ensure-error@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/ensure-error/-/ensure-error-4.0.0.tgz#8c7aedefd5a1f37c6802ff0bbc3cee726583388c" - integrity sha512-7Xenn3+R6tp2UqAbH9Jqs6QCSABQok+1VAhaPaF0jjm3iuhVHCblfBh18nYtpm3K9/V4Jpxz1JIqFZyrjstBtw== - -entities@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" - integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== - -env-paths@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" - integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== - -err-code@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" - integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== - -errno@~0.1.1: - version "0.1.8" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" - integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== - dependencies: - prr "~1.0.1" - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -error-stack-parser-es@^0.1.5: - version "0.1.5" - resolved "https://registry.yarnpkg.com/error-stack-parser-es/-/error-stack-parser-es-0.1.5.tgz#15b50b67bea4b6ed6596976ee07c7867ae25bb1c" - integrity sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg== - -es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9: - version "1.23.9" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.9.tgz#5b45994b7de78dada5c1bebf1379646b32b9d606" - integrity sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA== - dependencies: - array-buffer-byte-length "^1.0.2" - arraybuffer.prototype.slice "^1.0.4" - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - call-bound "^1.0.3" - data-view-buffer "^1.0.2" - data-view-byte-length "^1.0.2" - data-view-byte-offset "^1.0.1" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-set-tostringtag "^2.1.0" - es-to-primitive "^1.3.0" - function.prototype.name "^1.1.8" - get-intrinsic "^1.2.7" - get-proto "^1.0.0" - get-symbol-description "^1.1.0" - globalthis "^1.0.4" - gopd "^1.2.0" - has-property-descriptors "^1.0.2" - has-proto "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - internal-slot "^1.1.0" - is-array-buffer "^3.0.5" - is-callable "^1.2.7" - is-data-view "^1.0.2" - is-regex "^1.2.1" - is-shared-array-buffer "^1.0.4" - is-string "^1.1.1" - is-typed-array "^1.1.15" - is-weakref "^1.1.0" - math-intrinsics "^1.1.0" - object-inspect "^1.13.3" - object-keys "^1.1.1" - object.assign "^4.1.7" - own-keys "^1.0.1" - regexp.prototype.flags "^1.5.3" - safe-array-concat "^1.1.3" - safe-push-apply "^1.0.0" - safe-regex-test "^1.1.0" - set-proto "^1.0.0" - string.prototype.trim "^1.2.10" - string.prototype.trimend "^1.0.9" - string.prototype.trimstart "^1.0.8" - typed-array-buffer "^1.0.3" - typed-array-byte-length "^1.0.3" - typed-array-byte-offset "^1.0.4" - typed-array-length "^1.0.7" - unbox-primitive "^1.1.0" - which-typed-array "^1.1.18" - -es-define-property@^1.0.0, es-define-property@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" - integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== - -es-errors@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" - integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== - -es-module-lexer@^1.5.3, es-module-lexer@^1.5.4: - version "1.6.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.6.0.tgz#da49f587fd9e68ee2404fe4e256c0c7d3a81be21" - integrity sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ== - -es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" - integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== - dependencies: - es-errors "^1.3.0" - -es-set-tostringtag@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" - integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== - dependencies: - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - -es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" - integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== - dependencies: - hasown "^2.0.2" - -es-to-primitive@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" - integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== - dependencies: - is-callable "^1.2.7" - is-date-object "^1.0.5" - is-symbol "^1.0.4" - -es6-error@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" - integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== - -esbuild@^0.21.3, esbuild@^0.21.5: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" - -escalade@^3.1.1, escalade@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" - integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== - -escape-string-regexp@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" - integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== - -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -escodegen@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" - integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== - dependencies: - esprima "^4.0.1" - estraverse "^5.2.0" - esutils "^2.0.2" - optionalDependencies: - source-map "~0.6.1" - -eslint-compat-utils@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz#7fc92b776d185a70c4070d03fd26fde3d59652e4" - integrity sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q== - dependencies: - semver "^7.5.4" - -eslint-config-prettier@^9.0.0, eslint-config-prettier@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" - integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== - -eslint-import-resolver-node@^0.3.9: - version "0.3.9" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" - integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g== - dependencies: - debug "^3.2.7" - is-core-module "^2.13.0" - resolve "^1.22.4" - -eslint-import-resolver-typescript@^3.7.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.0.tgz#5bca4c579e17174e95bf67526b424d07b46c352e" - integrity sha512-aV3/dVsT0/H9BtpNwbaqvl+0xGMRGzncLyhm793NFGvbwGGvzyAykqWZ8oZlZuGwuHkwJjhWJkG1cM3ynvd2pQ== - dependencies: - "@nolyfill/is-core-module" "1.0.39" - debug "^4.4.0" - get-tsconfig "^4.10.0" - is-bun-module "^2.0.0" - stable-hash "^0.0.5" - tinyglobby "^0.2.12" - unrs-resolver "^1.3.2" - -eslint-module-utils@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz#fe4cfb948d61f49203d7b08871982b65b9af0b0b" - integrity sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg== - dependencies: - debug "^3.2.7" - -eslint-plugin-es-x@^7.8.0: - version "7.8.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz#a207aa08da37a7923f2a9599e6d3eb73f3f92b74" - integrity sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ== - dependencies: - "@eslint-community/eslint-utils" "^4.1.2" - "@eslint-community/regexpp" "^4.11.0" - eslint-compat-utils "^0.5.1" - -eslint-plugin-import@^2.31.0: - version "2.31.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" - integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== - dependencies: - "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" - debug "^3.2.7" - doctrine "^2.1.0" - eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.12.0" - hasown "^2.0.2" - is-core-module "^2.15.1" - is-glob "^4.0.3" - minimatch "^3.1.2" - object.fromentries "^2.0.8" - object.groupby "^1.0.3" - object.values "^1.2.0" - semver "^6.3.1" - string.prototype.trimend "^1.0.8" - tsconfig-paths "^3.15.0" - -eslint-plugin-jsdoc@^50.6.9: - version "50.6.9" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.9.tgz#b4afc06110958b9c525456b6c4348bf14e21c298" - integrity sha512-7/nHu3FWD4QRG8tCVqcv+BfFtctUtEDWc29oeDXB4bwmDM2/r1ndl14AG/2DUntdqH7qmpvdemJKwb3R97/QEw== - dependencies: - "@es-joy/jsdoccomment" "~0.49.0" - are-docs-informative "^0.0.2" - comment-parser "1.4.1" - debug "^4.3.6" - escape-string-regexp "^4.0.0" - espree "^10.1.0" - esquery "^1.6.0" - parse-imports "^2.1.1" - semver "^7.6.3" - spdx-expression-parse "^4.0.0" - synckit "^0.9.1" - -eslint-plugin-n@^17.15.1: - version "17.17.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.17.0.tgz#6644433d395c2ecae0b2fe58018807e85d8e0724" - integrity sha512-2VvPK7Mo73z1rDFb6pTvkH6kFibAmnTubFq5l83vePxu0WiY1s0LOtj2WHb6Sa40R3w4mnh8GFYbHBQyMlotKw== - dependencies: - "@eslint-community/eslint-utils" "^4.5.0" - enhanced-resolve "^5.17.1" - eslint-plugin-es-x "^7.8.0" - get-tsconfig "^4.8.1" - globals "^15.11.0" - ignore "^5.3.2" - minimatch "^9.0.5" - semver "^7.6.3" - -eslint-plugin-prettier@^5.0.0, eslint-plugin-prettier@^5.2.6: - version "5.2.6" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz#be39e3bb23bb3eeb7e7df0927cdb46e4d7945096" - integrity sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ== - dependencies: - prettier-linter-helpers "^1.0.0" - synckit "^0.11.0" - -eslint-plugin-promise@^7.2.1: - version "7.2.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz#a0652195700aea40b926dc3c74b38e373377bfb0" - integrity sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - -eslint-plugin-tsdoc@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.4.0.tgz#b14104e96ceb8add2054bf39e0cfc12b9a9586e6" - integrity sha512-MT/8b4aKLdDClnS8mP3R/JNjg29i0Oyqd/0ym6NnQf+gfKbJJ4ZcSh2Bs1H0YiUMTBwww5JwXGTWot/RwyJ7aQ== - dependencies: - "@microsoft/tsdoc" "0.15.1" - "@microsoft/tsdoc-config" "0.17.1" - -eslint-plugin-vue@^9.32.0: - version "9.33.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz#de33eba8f78e1d172c59c8ec7fbfd60c6ca35c39" - integrity sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - globals "^13.24.0" - natural-compare "^1.4.0" - nth-check "^2.1.1" - postcss-selector-parser "^6.0.15" - semver "^7.6.3" - vue-eslint-parser "^9.4.3" - xml-name-validator "^4.0.0" - -eslint-scope@^7.1.1, eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== - dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" - -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" - integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== - -eslint-visitor-keys@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" - integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== - -eslint@^8.57.1: - version "8.57.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" - integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== - dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.57.1" - "@humanwhocodes/config-array" "^0.13.0" - "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" - ajv "^6.12.4" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.3.2" - doctrine "^3.0.0" - escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" - esquery "^1.4.2" - esutils "^2.0.2" - fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" - find-up "^5.0.0" - glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" - ignore "^5.2.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-yaml "^4.1.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" - natural-compare "^1.4.0" - optionator "^0.9.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" - -espree@^10.0.1, espree@^10.1.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" - integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== - dependencies: - acorn "^8.14.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.2.0" - -espree@^9.0.0, espree@^9.3.1, espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== - dependencies: - acorn "^8.9.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" - -esprima@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esquery@^1.4.0, esquery@^1.4.2, esquery@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== - dependencies: - estraverse "^5.1.0" - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^5.1.0, estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -estree-walker@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" - integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== - -estree-walker@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" - integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== - dependencies: - "@types/estree" "^1.0.0" - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -execa@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -execa@^9.5.1, execa@^9.5.2: - version "9.5.2" - resolved "https://registry.yarnpkg.com/execa/-/execa-9.5.2.tgz#a4551034ee0795e241025d2f987dab3f4242dff2" - integrity sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q== - dependencies: - "@sindresorhus/merge-streams" "^4.0.0" - cross-spawn "^7.0.3" - figures "^6.1.0" - get-stream "^9.0.0" - human-signals "^8.0.0" - is-plain-obj "^4.1.0" - is-stream "^4.0.1" - npm-run-path "^6.0.0" - pretty-ms "^9.0.0" - signal-exit "^4.1.0" - strip-final-newline "^4.0.0" - yoctocolors "^2.0.0" - -expect-type@^1.1.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.1.tgz#af76d8b357cf5fa76c41c09dafb79c549e75f71f" - integrity sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw== - -extract-zip@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" - integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== - dependencies: - debug "^4.1.1" - get-stream "^5.1.0" - yauzl "^2.10.0" - optionalDependencies: - "@types/yauzl" "^2.9.1" - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-diff@^1.1.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" - integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== - -fast-glob@^3.2.12, fast-glob@^3.2.9, fast-glob@^3.3.2, fast-glob@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" - integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.8" - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-levenshtein@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== - -fastq@^1.6.0: - version "1.19.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" - integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== - dependencies: - reusify "^1.0.4" - -fd-slicer@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" - integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g== - dependencies: - pend "~1.2.0" - -fdir@^6.4.3: - version "6.4.3" - resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72" - integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw== - -fetch-cookie@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz#01086b6b5b1c3e08f15ffd8647b02ca100377365" - integrity sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ== - dependencies: - set-cookie-parser "^2.4.8" - tough-cookie "^4.0.0" - -figures@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" - integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== - dependencies: - is-unicode-supported "^2.0.0" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== - dependencies: - flat-cache "^3.0.4" - -filelist@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" - integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== - dependencies: - minimatch "^5.0.1" - -fill-range@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" - integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - dependencies: - to-regex-range "^5.0.1" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flat-cache@^3.0.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" - integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== - dependencies: - flatted "^3.2.9" - keyv "^4.5.3" - rimraf "^3.0.2" - -flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== - -for-each@^0.3.3, for-each@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" - integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== - dependencies: - is-callable "^1.2.7" - -foreground-child@^3.1.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" - integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== - dependencies: - cross-spawn "^7.0.6" - signal-exit "^4.0.1" - -form-data@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" - integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.8" - es-set-tostringtag "^2.1.0" - mime-types "^2.1.12" - -fs-extra@^10.0.0, fs-extra@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" - integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-extra@^11.2.0: - version "11.3.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" - integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-extra@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - -fs-extra@^9.0.0, fs-extra@^9.0.1: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -function-bind@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" - integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== - -function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" - integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - functions-have-names "^1.2.3" - hasown "^2.0.2" - is-callable "^1.2.7" - -functions-have-names@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" - integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== - dependencies: - call-bind-apply-helpers "^1.0.2" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - function-bind "^1.1.2" - get-proto "^1.0.1" - gopd "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - math-intrinsics "^1.1.0" - -get-proto@^1.0.0, get-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" - integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== - dependencies: - dunder-proto "^1.0.1" - es-object-atoms "^1.0.0" - -get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -get-stream@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" - integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== - dependencies: - "@sec-ant/readable-stream" "^0.4.1" - is-stream "^4.0.1" - -get-symbol-description@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" - integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - -get-tsconfig@^4.10.0, get-tsconfig@^4.8.1: - version "4.10.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.0.tgz#403a682b373a823612475a4c2928c7326fc0f6bb" - integrity sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A== - dependencies: - resolve-pkg-maps "^1.0.0" - -glob-parent@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob@^10.3.10, glob@^10.4.1: - version "10.4.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" - integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^1.11.1" - -glob@^7.1.3, glob@^7.1.6: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-agent@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-agent/-/global-agent-3.0.0.tgz#ae7cd31bd3583b93c5a16437a1afe27cc33a1ab6" - integrity sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q== - dependencies: - boolean "^3.0.1" - es6-error "^4.1.1" - matcher "^3.0.0" - roarr "^2.15.3" - semver "^7.3.2" - serialize-error "^7.0.1" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.19.0, globals@^13.24.0: - version "13.24.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" - -globals@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" - integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== - -globals@^15.11.0: - version "15.15.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-15.15.0.tgz#7c4761299d41c32b075715a4ce1ede7897ff72a8" - integrity sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg== - -globalthis@^1.0.1, globalthis@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" - integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== - dependencies: - define-properties "^1.2.1" - gopd "^1.0.1" - -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -globby@^14.0.2: - version "14.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-14.1.0.tgz#138b78e77cf5a8d794e327b15dce80bf1fb0a73e" - integrity sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA== - dependencies: - "@sindresorhus/merge-streams" "^2.1.0" - fast-glob "^3.3.3" - ignore "^7.0.3" - path-type "^6.0.0" - slash "^5.1.0" - unicorn-magic "^0.3.0" - -globrex@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" - integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== - -gopd@^1.0.1, gopd@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" - integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== - -got@^11.8.5: - version "11.8.6" - resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" - integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== - dependencies: - "@sindresorhus/is" "^4.0.0" - "@szmarczak/http-timer" "^4.0.5" - "@types/cacheable-request" "^6.0.1" - "@types/responselike" "^1.0.0" - cacheable-lookup "^5.0.3" - cacheable-request "^7.0.2" - decompress-response "^6.0.0" - http2-wrapper "^1.0.0-beta.5.2" - lowercase-keys "^2.0.0" - p-cancelable "^2.0.0" - responselike "^2.0.0" - -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: - version "4.2.11" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" - integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== - -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - -has-bigints@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" - integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" - integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== - dependencies: - dunder-proto "^1.0.0" - -has-symbols@^1.0.3, has-symbols@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" - integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== - -has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" - -hasown@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" - integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== - dependencies: - function-bind "^1.1.2" - -he@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -hookable@^5.5.3: - version "5.5.3" - resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" - integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== - -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== - -hosted-git-info@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224" - integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA== - dependencies: - lru-cache "^6.0.0" - -html-escaper@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" - integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== - -http-cache-semantics@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== - -http-proxy-agent@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" - integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== - dependencies: - "@tootallnate/once" "2" - agent-base "6" - debug "4" - -http2-wrapper@^1.0.0-beta.5.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" - integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== - dependencies: - quick-lru "^5.1.1" - resolve-alpn "^1.0.0" - -https-proxy-agent@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -human-signals@^8.0.0: - version "8.0.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" - integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== - -husky@^9.1.7: - version "9.1.7" - resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" - integrity sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA== - -iconv-corefoundation@^1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz#31065e6ab2c9272154c8b0821151e2c88f1b002a" - integrity sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ== - dependencies: - cli-truncate "^2.1.0" - node-addon-api "^1.6.3" - -iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -ieee754@^1.1.13, ieee754@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" - integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== - -ignore@^5.2.0, ignore@^5.3.1, ignore@^5.3.2: - version "5.3.2" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" - integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== - -ignore@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.3.tgz#397ef9315dfe0595671eefe8b633fec6943ab733" - integrity sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA== - -immediate@^3.2.3: - version "3.3.0" - resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.3.0.tgz#1aef225517836bcdf7f2a2de2600c79ff0269266" - integrity sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q== - -immutable@^5.0.2: - version "5.1.1" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.1.tgz#d4cb552686f34b076b3dcf23c4384c04424d8354" - integrity sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg== - -import-fresh@^3.2.1: - version "3.3.1" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" - integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -ini@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-5.0.0.tgz#a7a4615339843d9a8ccc2d85c9d81cf93ffbc638" - integrity sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw== - -internal-slot@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" - integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== - dependencies: - es-errors "^1.3.0" - hasown "^2.0.2" - side-channel "^1.1.0" - -is-arguments@^1.0.4: - version "1.2.0" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b" - integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - -is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" - integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - get-intrinsic "^1.2.6" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-async-function@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" - integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== - dependencies: - async-function "^1.0.0" - call-bound "^1.0.3" - get-proto "^1.0.1" - has-tostringtag "^1.0.2" - safe-regex-test "^1.1.0" - -is-bigint@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" - integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== - dependencies: - has-bigints "^1.0.2" - -is-boolean-object@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" - integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - -is-buffer@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - -is-bun-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" - integrity sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ== - dependencies: - semver "^7.7.1" - -is-callable@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - -is-ci@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" - integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== - dependencies: - ci-info "^3.2.0" - -is-core-module@^2.13.0, is-core-module@^2.15.1, is-core-module@^2.16.0: - version "2.16.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" - integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== - dependencies: - hasown "^2.0.2" - -is-data-view@^1.0.1, is-data-view@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" - integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== - dependencies: - call-bound "^1.0.2" - get-intrinsic "^1.2.6" - is-typed-array "^1.1.13" - -is-date-object@^1.0.5, is-date-object@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" - integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - -is-docker@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" - integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-finalizationregistry@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" - integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== - dependencies: - call-bound "^1.0.3" - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-generator-function@^1.0.10, is-generator-function@^1.0.7: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" - integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== - dependencies: - call-bound "^1.0.3" - get-proto "^1.0.0" - has-tostringtag "^1.0.2" - safe-regex-test "^1.1.0" - -is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-inside-container@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" - integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== - dependencies: - is-docker "^3.0.0" - -is-interactive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" - integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== - -is-map@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" - integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== - -is-nan@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" - integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== - dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - -is-number-object@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" - integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-path-cwd@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-3.0.0.tgz#889b41e55c8588b1eb2a96a61d05740a674521c7" - integrity sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA== - -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-path-inside@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" - integrity sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA== - -is-plain-obj@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" - integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== - -is-regex@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" - integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== - dependencies: - call-bound "^1.0.2" - gopd "^1.2.0" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - -is-set@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" - integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== - -is-shared-array-buffer@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" - integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== - dependencies: - call-bound "^1.0.3" - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-stream@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" - integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== - -is-string@^1.0.7, is-string@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" - integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - -is-symbol@^1.0.4, is-symbol@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" - integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== - dependencies: - call-bound "^1.0.2" - has-symbols "^1.1.0" - safe-regex-test "^1.1.0" - -is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: - version "1.1.15" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" - integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== - dependencies: - which-typed-array "^1.1.16" - -is-unicode-supported@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" - integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== - -is-weakmap@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" - integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== - -is-weakref@^1.0.2, is-weakref@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" - integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== - dependencies: - call-bound "^1.0.3" - -is-weakset@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" - integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== - dependencies: - call-bound "^1.0.3" - get-intrinsic "^1.2.6" - -is-what@^4.1.8: - version "4.1.16" - resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" - integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== - -is-wsl@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2" - integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw== - dependencies: - is-inside-container "^1.0.0" - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - -isarray@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - -isbinaryfile@^4.0.8: - version "4.0.10" - resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" - integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== - -isbinaryfile@^5.0.0: - version "5.0.4" - resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.4.tgz#2a2edefa76cafa66613fe4c1ea52f7f031017bdf" - integrity sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isexe@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" - integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== - -istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" - integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== - -istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" - integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== - dependencies: - istanbul-lib-coverage "^3.0.0" - make-dir "^4.0.0" - supports-color "^7.1.0" - -istanbul-lib-source-maps@^5.0.6: - version "5.0.6" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" - integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== - dependencies: - "@jridgewell/trace-mapping" "^0.3.23" - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - -istanbul-reports@^3.1.7: - version "3.1.7" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" - integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== - dependencies: - html-escaper "^2.0.0" - istanbul-lib-report "^3.0.0" - -jackspeak@^3.1.2: - version "3.4.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" - integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== - dependencies: - "@isaacs/cliui" "^8.0.2" - optionalDependencies: - "@pkgjs/parseargs" "^0.11.0" - -jake@^10.8.5: - version "10.9.2" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" - integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== - dependencies: - async "^3.2.3" - chalk "^4.0.2" - filelist "^1.0.4" - minimatch "^3.1.2" - -jju@~1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" - integrity sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA== - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -jsdoc-type-pratt-parser@~4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" - integrity sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg== - -jsesc@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" - integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== - -json-buffer@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" - integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== - -json-parse-even-better-errors@^2.3.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-parse-even-better-errors@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz#d3f67bd5925e81d3e31aa466acc821c8375cec43" - integrity sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== - -json-stringify-safe@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== - -json5@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" - integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== - dependencies: - minimist "^1.2.0" - -json5@^2.2.0, json5@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsonc-eslint-parser@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz#74ded53f9d716e8d0671bd167bf5391f452d5461" - integrity sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg== - dependencies: - acorn "^8.5.0" - eslint-visitor-keys "^3.0.0" - espree "^9.0.0" - semver "^7.3.5" - -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -keyv@^4.0.0, keyv@^4.5.3: - version "4.5.4" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" - integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== - dependencies: - json-buffer "3.0.1" - -kolorist@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" - integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== - -lazy-val@^1.0.4, lazy-val@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.5.tgz#6cf3b9f5bc31cee7ee3e369c0832b7583dcd923d" - integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== - -level-codec@9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/level-codec/-/level-codec-9.0.2.tgz#fd60df8c64786a80d44e63423096ffead63d8cbc" - integrity sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ== - dependencies: - buffer "^5.6.0" - -level-concat-iterator@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/level-concat-iterator/-/level-concat-iterator-3.1.0.tgz#5235b1f744bc34847ed65a50548aa88d22e881cf" - integrity sha512-BWRCMHBxbIqPxJ8vHOvKUsaO0v1sLYZtjN3K2iZJsRBYtp+ONsY6Jfi6hy9K3+zolgQRryhIn2NRZjZnWJ9NmQ== - dependencies: - catering "^2.1.0" - -level-concat-iterator@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz#1d1009cf108340252cb38c51f9727311193e6263" - integrity sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw== - -level-errors@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/level-errors/-/level-errors-3.0.1.tgz#4bed48a33108cd83b0e39fdf9bbd84e96fbbef9f" - integrity sha512-tqTL2DxzPDzpwl0iV5+rBCv65HWbHp6eutluHNcVIftKZlQN//b6GEnZDM2CvGZvzGYMwyPtYppYnydBQd2SMQ== - -level-errors@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/level-errors/-/level-errors-2.0.1.tgz#2132a677bf4e679ce029f517c2f17432800c05c8" - integrity sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw== - dependencies: - errno "~0.1.1" - -level-iterator-stream@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/level-iterator-stream/-/level-iterator-stream-5.0.0.tgz#85b3438e1b4c54ce5aa8c0eb973cfb628117df9e" - integrity sha512-wnb1+o+CVFUDdiSMR/ZymE2prPs3cjVLlXuDeSq9Zb8o032XrabGEXcTCsBxprAtseO3qvFeGzh6406z9sOTRA== - dependencies: - inherits "^2.0.4" - readable-stream "^3.4.0" - -level-iterator-stream@~4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz#7ceba69b713b0d7e22fcc0d1f128ccdc8a24f79c" - integrity sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q== - dependencies: - inherits "^2.0.4" - readable-stream "^3.4.0" - xtend "^4.0.2" - -level-supports@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/level-supports/-/level-supports-2.1.0.tgz#9af908d853597ecd592293b2fad124375be79c5f" - integrity sha512-E486g1NCjW5cF78KGPrMDRBYzPuueMZ6VBXHT6gC7A8UYWGiM14fGgp+s/L1oFfDWSPV/+SFkYCmZ0SiESkRKA== - -level-supports@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/level-supports/-/level-supports-1.0.1.tgz#2f530a596834c7301622521988e2c36bb77d122d" - integrity sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg== - dependencies: - xtend "^4.0.2" - -leveldown@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/leveldown/-/leveldown-6.1.1.tgz#0f0e480fa88fd807abf94c33cb7e40966ea4b5ce" - integrity sha512-88c+E+Eizn4CkQOBHwqlCJaTNEjGpaEIikn1S+cINc5E9HEvJ77bqY4JY/HxT5u0caWqsc3P3DcFIKBI1vHt+A== - dependencies: - abstract-leveldown "^7.2.0" - napi-macros "~2.0.0" - node-gyp-build "^4.3.0" - -levelup@4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/levelup/-/levelup-4.4.0.tgz#f89da3a228c38deb49c48f88a70fb71f01cafed6" - integrity sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ== - dependencies: - deferred-leveldown "~5.3.0" - level-errors "~2.0.0" - level-iterator-stream "~4.0.0" - level-supports "~1.0.0" - xtend "~4.0.0" - -levelup@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/levelup/-/levelup-5.1.1.tgz#9f99699f414ac084a3f8a28fc262a1f49cd7a52c" - integrity sha512-0mFCcHcEebOwsQuk00WJwjLI6oCjbBuEYdh/RaRqhjnyVlzqf41T1NnDtCedumZ56qyIh8euLFDqV1KfzTAVhg== - dependencies: - catering "^2.0.0" - deferred-leveldown "^7.0.0" - level-errors "^3.0.1" - level-iterator-stream "^5.0.0" - level-supports "^2.0.1" - queue-microtask "^1.2.3" - -levn@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" - integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== - dependencies: - prelude-ls "^1.2.1" - type-check "~0.4.0" - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== - -lodash.escaperegexp@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" - integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== - -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== - -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - -lodash@^4.17.15, lodash@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== - -loupe@^3.1.0, loupe@^3.1.2: - version "3.1.3" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.3.tgz#042a8f7986d77f3d0f98ef7990a2b2fef18b0fd2" - integrity sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - -lru-cache@^10.2.0: - version "10.4.3" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" - integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== - -lru-cache@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" - integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== - dependencies: - yallist "^3.0.2" - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -ltgt@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/ltgt/-/ltgt-2.2.1.tgz#f35ca91c493f7b73da0e07495304f17b31f87ee5" - integrity sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA== - -magic-string@^0.30.10, magic-string@^0.30.11, magic-string@^0.30.12, magic-string@^0.30.4: - version "0.30.17" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" - integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" - -magicast@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" - integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== - dependencies: - "@babel/parser" "^7.25.4" - "@babel/types" "^7.25.4" - source-map-js "^1.2.0" - -make-dir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" - integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== - dependencies: - semver "^7.5.3" - -matcher@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca" - integrity sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng== - dependencies: - escape-string-regexp "^4.0.0" - -math-intrinsics@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" - integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== - -memorystream@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" - integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== - -meow@^13.2.0: - version "13.2.0" - resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f" - integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA== - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -micromatch@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" - integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== - dependencies: - braces "^3.0.3" - picomatch "^2.3.1" - -mime-db@1.52.0: - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@^2.5.2: - version "2.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" - integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== - -mime@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.6.tgz#ca83bec0bcf2a02353d0e02da99be05603d04839" - integrity sha512-4rGt7rvQHBbaSOF9POGkk1ocRP16Md1x36Xma8sz8h8/vfCUI2OtEIeCqe4Ofes853x4xDoPiFLIT47J5fI/7A== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -mimic-response@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - -mimic-response@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" - integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== - -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^5.0.1, minimatch@^5.1.1: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^9.0.0, minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== - dependencies: - brace-expansion "^2.0.1" - -minimist@^1.2.0, minimist@^1.2.6: - version "1.2.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" - integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== - -minipass@^3.0.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - -minizlib@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mitt@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" - integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== - -mkdirp@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -mlly@^1.2.0, mlly@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f" - integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== - dependencies: - acorn "^8.14.0" - pathe "^2.0.1" - pkg-types "^1.3.0" - ufo "^1.5.4" - -mrmime@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" - integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -muggle-string@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" - integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ== - -nanoid@^3.3.8: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== - -nanoid@^5.0.9: - version "5.1.5" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.5.tgz#f7597f9d9054eb4da9548cdd53ca70f1790e87de" - integrity sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw== - -napi-macros@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" - integrity sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== - -node-addon-api@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.0.0.tgz#8136add2f510997b3b94814f4af1cce0b0e3962e" - integrity sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA== - -node-addon-api@^1.6.3: - version "1.7.2" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.2.tgz#3df30b95720b53c24e59948b49532b662444f54d" - integrity sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg== - -node-fetch@2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" - integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== - dependencies: - whatwg-url "^5.0.0" - -node-gyp-build@4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" - integrity sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ== - -node-gyp-build@^4.3.0: - version "4.8.4" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" - integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== - -node-releases@^2.0.19: - version "2.0.19" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" - integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== - -normalize-package-data@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-url@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" - integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== - -npm-check-updates@^17.1.16: - version "17.1.16" - resolved "https://registry.yarnpkg.com/npm-check-updates/-/npm-check-updates-17.1.16.tgz#28f5debfb81b29bf152162de810beb526b638226" - integrity sha512-9nohkfjLRzLfsLVGbO34eXBejvrOOTuw5tvNammH73KEFG5XlFoi3G2TgjTExHtnrKWCbZ+mTT+dbNeSjASIPw== - -npm-normalize-package-bin@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz#df79e70cd0a113b77c02d1fe243c96b8e618acb1" - integrity sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w== - -npm-run-all2@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/npm-run-all2/-/npm-run-all2-7.0.2.tgz#26155c140b5e3f1155efd7f5d67212c8027b397c" - integrity sha512-7tXR+r9hzRNOPNTvXegM+QzCuMjzUIIq66VDunL6j60O4RrExx32XUhlrS7UK4VcdGw5/Wxzb3kfNcFix9JKDA== - dependencies: - ansi-styles "^6.2.1" - cross-spawn "^7.0.6" - memorystream "^0.3.1" - minimatch "^9.0.0" - pidtree "^0.6.0" - read-package-json-fast "^4.0.0" - shell-quote "^1.7.3" - which "^5.0.0" - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -npm-run-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-6.0.0.tgz#25cfdc4eae04976f3349c0b1afc089052c362537" - integrity sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA== - dependencies: - path-key "^4.0.0" - unicorn-magic "^0.3.0" - -nth-check@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - -object-inspect@^1.13.3: - version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" - integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - -object-is@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" - integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.4, object.assign@^4.1.7: - version "4.1.7" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" - integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - has-symbols "^1.1.0" - object-keys "^1.1.1" - -object.fromentries@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" - integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - -object.groupby@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" - integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - -object.values@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" - integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -open@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/open/-/open-10.1.0.tgz#a7795e6e5d519abe4286d9937bb24b51122598e1" - integrity sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw== - dependencies: - default-browser "^5.2.1" - define-lazy-prop "^3.0.0" - is-inside-container "^1.0.0" - is-wsl "^3.1.0" - -optionator@^0.9.3: - version "0.9.4" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" - integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== - dependencies: - deep-is "^0.1.3" - fast-levenshtein "^2.0.6" - levn "^0.4.1" - prelude-ls "^1.2.1" - type-check "^0.4.0" - word-wrap "^1.2.5" - -own-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" - integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== - dependencies: - get-intrinsic "^1.2.6" - object-keys "^1.1.1" - safe-push-apply "^1.0.0" - -p-cancelable@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" - integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-map@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-7.0.3.tgz#7ac210a2d36f81ec28b736134810f7ba4418cdb6" - integrity sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA== - -package-json-from-dist@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" - integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-imports@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/parse-imports/-/parse-imports-2.2.1.tgz#0a6e8b5316beb5c9905f50eb2bbb8c64a4805642" - integrity sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ== - dependencies: - es-module-lexer "^1.5.3" - slashes "^3.0.12" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -parse-ms@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" - integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== - -path-browserify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" - integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-key@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18" - integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-scurry@^1.11.1: - version "1.11.1" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" - integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== - dependencies: - lru-cache "^10.2.0" - minipass "^5.0.0 || ^6.0.2 || ^7.0.0" - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -path-type@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-6.0.0.tgz#2f1bb6791a91ce99194caede5d6c5920ed81eb51" - integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ== - -pathe@^1.0.0, pathe@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" - integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== - -pathe@^2.0.1, pathe@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" - integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== - -pathval@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" - integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== - -pend@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" - integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== - -perfect-debounce@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a" - integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== - -picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - -picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -picomatch@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" - integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== - -pidtree@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" - integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g== - -pinia@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.3.1.tgz#54c476675b72f5abcfafa24a7582531ea8c23d94" - integrity sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug== - dependencies: - "@vue/devtools-api" "^6.6.3" - vue-demi "^0.14.10" - -pkg-dir@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" - integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== - dependencies: - find-up "^5.0.0" - -pkg-types@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" - integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== - dependencies: - confbox "^0.1.8" - mlly "^1.7.4" - pathe "^2.0.1" - -plist@^3.0.4, plist@^3.0.5: - version "3.1.0" - resolved "https://registry.yarnpkg.com/plist/-/plist-3.1.0.tgz#797a516a93e62f5bde55e0b9cc9c967f860893c9" - integrity sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ== - dependencies: - "@xmldom/xmldom" "^0.8.8" - base64-js "^1.5.1" - xmlbuilder "^15.1.1" - -possible-typed-array-names@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" - integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== - -postcss-selector-parser@^6.0.15: - version "6.1.2" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" - integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss@^8.4.43, postcss@^8.4.48: - version "8.5.3" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== - dependencies: - nanoid "^3.3.8" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -pouchdb-abstract-mapreduce@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-abstract-mapreduce/-/pouchdb-abstract-mapreduce-9.0.0.tgz#d5f189a6f8980931835c41ea1f0b692368ce4686" - integrity sha512-SnTtqwAEiAa3uxKbc1J7LfiBViwEkKe2xkK92zxyTXPqWBvMnh4UU3GXxx7GrXTM4L9llsQ3lSjpbH4CNqG1Mw== - dependencies: - pouchdb-binary-utils "9.0.0" - pouchdb-collate "9.0.0" - pouchdb-errors "9.0.0" - pouchdb-fetch "9.0.0" - pouchdb-mapreduce-utils "9.0.0" - pouchdb-md5 "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-adapter-leveldb-core@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-adapter-leveldb-core/-/pouchdb-adapter-leveldb-core-9.0.0.tgz#cebb4fbae4ed55b946c051b1ea27c56a8b32327d" - integrity sha512-b3ZGPtVXyivGL5SK3AIDG7PrNsZdoDpGFkmTytDTtctkVhxOg71gnXXP+CrupENPqSNG/eGbKW4w+bbMpxy6aA== - dependencies: - double-ended-queue "2.1.0-0" - levelup "4.4.0" - pouchdb-adapter-utils "9.0.0" - pouchdb-binary-utils "9.0.0" - pouchdb-core "9.0.0" - pouchdb-errors "9.0.0" - pouchdb-json "9.0.0" - pouchdb-md5 "9.0.0" - pouchdb-merge "9.0.0" - pouchdb-utils "9.0.0" - sublevel-pouchdb "9.0.0" - through2 "3.0.2" - -pouchdb-adapter-utils@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-adapter-utils/-/pouchdb-adapter-utils-9.0.0.tgz#9e95eec33baae82380acde655e826a2f111c8aa5" - integrity sha512-hmbm4ey0HL0vtoY1tRTPIt2FfYjvMh3DWoGGSxXDTS73qTFQ+Fhhi5I0AnN9PcD2omfKQAVXiYks4kkMvlAHqA== - dependencies: - pouchdb-binary-utils "9.0.0" - pouchdb-errors "9.0.0" - pouchdb-md5 "9.0.0" - pouchdb-merge "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-binary-utils@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-binary-utils/-/pouchdb-binary-utils-9.0.0.tgz#eafed32c21e92ef4b253456f9e53c4cf2cfd99fd" - integrity sha512-2OMtgDZi82vqs+zNDE0YiYjOaWkYCUcZJZKK3WkRr+XYRu+2B7umJrnygJFhUwoGedBbHSrlQBLhdNV3F1AX1A== - -pouchdb-changes-filter@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-changes-filter/-/pouchdb-changes-filter-9.0.0.tgz#24016be7a968b5935a81be394dc370a062c7bd25" - integrity sha512-ig0fo0WLgIjAniFJ19Uw1Y+oxiypqC+Skhd8BCETRVXOhLBzueRwEQR4thffyo0UayYVqldJfSR5wHSDvEVk/A== - dependencies: - pouchdb-errors "9.0.0" - pouchdb-selector-core "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-collate@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-collate/-/pouchdb-collate-9.0.0.tgz#654f6766927ada60603ba25b6b2ae533564fa302" - integrity sha512-TrnEDNZEmIIl+W3xKUO8h+geqVLQ90oZe5ujPkl8myUzpREULWXWQBnV5EzPXVEKDBpJlb8T3I6oy/zdWGQpdA== - -pouchdb-core@9.0.0, pouchdb-core@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-core/-/pouchdb-core-9.0.0.tgz#6204851233624d9d62e17f06c47b0bf4ca4e99db" - integrity sha512-98SJgs8bqXhr4gMGuOTR8yVeLlMYy797zlOtdlvlXIxIicvocyA8ColhVVhdBXPNOGxT2HwReIMywdIVAgibpg== - dependencies: - pouchdb-changes-filter "9.0.0" - pouchdb-errors "9.0.0" - pouchdb-fetch "9.0.0" - pouchdb-merge "9.0.0" - pouchdb-utils "9.0.0" - uuid "8.3.2" - -pouchdb-errors@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-errors/-/pouchdb-errors-9.0.0.tgz#f84269ce3327abef9455c0a90a51c26d7dca20c6" - integrity sha512-961PSMLhW0UqqdJ566g+CdLZ5pkBJRd6l4WWpCDdD0USvE4xYfYGzv43w7nZZBw1k3Xdy092yqPge7yX/tfnyw== - -pouchdb-fetch@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-fetch/-/pouchdb-fetch-9.0.0.tgz#a2cf407c75c9fc68a1924b08c9b574d28e1be7dd" - integrity sha512-TbE3cUcAJQrwb9kr44tDP0X+NAbcqgjsTvcL30L4xzBNJeCPTIRjukYX80s154SHJUXBxcWRiPsMmNqpXsjfCA== - dependencies: - fetch-cookie "2.2.0" - node-fetch "2.6.9" - -pouchdb-find@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-find/-/pouchdb-find-9.0.0.tgz#3d1b80d2adc9f9fd86c2ad559cd0144e406cb539" - integrity sha512-vvVhq4eEOmSkwSRwf2NBYtdhURB7ryJ7sUI4WDN00GuLUj2g8jAXBJuZIryVgdYt/5S5cfn70iRL6Eow+LFhpA== - dependencies: - pouchdb-abstract-mapreduce "9.0.0" - pouchdb-collate "9.0.0" - pouchdb-errors "9.0.0" - pouchdb-fetch "9.0.0" - pouchdb-md5 "9.0.0" - pouchdb-selector-core "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-json@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-json/-/pouchdb-json-9.0.0.tgz#f0adcf73d0db5c175bfd4f2db7084254c0b01bb6" - integrity sha512-aI41mYVyI195GXuT1Ys7mLIB/Mvrz11ihoTP6km6hYqVgSuaUxuZcFUozlyTJiZXr7H5kdhNgclhlVnjir4JAA== - dependencies: - vuvuzela "1.0.3" - -pouchdb-mapreduce-utils@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-mapreduce-utils/-/pouchdb-mapreduce-utils-9.0.0.tgz#8a2edf30ca0fa24d095eabcfbe8ebb8f3f1160e3" - integrity sha512-Bjh8W6QXqp1j7MKmHhYYp5cYlcQsm5drD8Jd/F+ZlfNt18uiD2SQXWzGM5797+tiW/LszFGb8ttw0uHWjxufCQ== - dependencies: - pouchdb-utils "9.0.0" - -pouchdb-mapreduce@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-mapreduce/-/pouchdb-mapreduce-9.0.0.tgz#213acec29ef8c145a096d7dcf68b0e910770e98c" - integrity sha512-ZD8PleQ9atzQAzT2LZWsvooUVEfsen5QGv/SDfci20IleCaFW2A2q7OERrqY0YWKDCCNRsWhPWPmsFvZC9K8DQ== - dependencies: - pouchdb-abstract-mapreduce "9.0.0" - pouchdb-mapreduce-utils "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-md5@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-md5/-/pouchdb-md5-9.0.0.tgz#f67a2ba627309e65f8d1ce4d4baf6a5f29164617" - integrity sha512-58xUYBvW3/s+aH0j4uOhhN8yCk0LQ254cxBzI/gbKA9PrfwHpe4zrr0L/ia5ml3A30oH1f8aTnuVMwWDkFcuww== - dependencies: - pouchdb-binary-utils "9.0.0" - spark-md5 "3.0.2" - -pouchdb-merge@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-merge/-/pouchdb-merge-9.0.0.tgz#d56b12bee4f2c9a981cf11c5cd44774e5b12b4ac" - integrity sha512-Xh+TgOZCkGoZpI589btKf/cTiuQ5CsnPl9YpdW4h0cAPusniN6XNsR62F+/HbL9wirI6XTEPHUrk7MsQbk3S3A== - dependencies: - pouchdb-utils "9.0.0" - -pouchdb-selector-core@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-selector-core/-/pouchdb-selector-core-9.0.0.tgz#6fee1df82cd5ecdbd0a034b38e6c604557d2e22a" - integrity sha512-ZYHYsdoedwm8j5tYofz+3+uUSK8i+7tRCBb01T0OuqDQb17+w5mzjHF8Ppi160xdPUPaWCo1Un+nLWGJzkmA3g== - dependencies: - pouchdb-collate "9.0.0" - pouchdb-utils "9.0.0" - -pouchdb-utils@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/pouchdb-utils/-/pouchdb-utils-9.0.0.tgz#b68f3259add50163998201d1a6d16e6a35d5d57f" - integrity sha512-xWZE5c+nAslgmLC8JBZbky8AYgdz7pKtv7KTSi6CD2tuQD0WyNKib0YnhZndeE84dksTeZlqlg56RQHsHoB2LQ== - dependencies: - pouchdb-errors "9.0.0" - pouchdb-md5 "9.0.0" - uuid "8.3.2" - -prelude-ls@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" - integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== - -prettier-linter-helpers@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" - integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== - dependencies: - fast-diff "^1.1.2" - -prettier@^3.4.2: - version "3.5.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" - integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== - -pretty-ms@^9.0.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.2.0.tgz#e14c0aad6493b69ed63114442a84133d7e560ef0" - integrity sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg== - dependencies: - parse-ms "^4.0.0" - -progress@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -promise-retry@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" - integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== - dependencies: - err-code "^2.0.2" - retry "^0.12.0" - -prr@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" - integrity sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw== - -psl@^1.1.33: - version "1.15.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" - integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== - dependencies: - punycode "^2.3.1" - -pump@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8" - integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" - integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== - -querystringify@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" - integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== - -queue-microtask@^1.2.2, queue-microtask@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" - integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== - -radash@^12.1.0: - version "12.1.0" - resolved "https://registry.yarnpkg.com/radash/-/radash-12.1.0.tgz#a15a5d745bfe3c3a6428148a606000aefd238560" - integrity sha512-b0Zcf09AhqKS83btmUeYBS8tFK7XL2e3RvLmZcm0sTdF1/UUlHSsjXdCcWNxe7yfmAlPve5ym0DmKGtTzP6kVQ== - -read-config-file@6.3.2: - version "6.3.2" - resolved "https://registry.yarnpkg.com/read-config-file/-/read-config-file-6.3.2.tgz#556891aa6ffabced916ed57457cb192e61880411" - integrity sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q== - dependencies: - config-file-ts "^0.2.4" - dotenv "^9.0.2" - dotenv-expand "^5.1.0" - js-yaml "^4.1.0" - json5 "^2.2.0" - lazy-val "^1.0.4" - -read-package-json-fast@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz#8ccbc05740bb9f58264f400acc0b4b4eee8d1b39" - integrity sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg== - dependencies: - json-parse-even-better-errors "^4.0.0" - npm-normalize-package-bin "^4.0.0" - -read-pkg@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" - integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== - dependencies: - "@types/normalize-package-data" "^2.4.0" - normalize-package-data "^2.5.0" - parse-json "^5.0.0" - type-fest "^0.6.0" - -readable-stream@1.1.14: - version "1.1.14" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" - integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -"readable-stream@2 || 3", readable-stream@^3.4.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: - version "1.0.10" - resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" - integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.9" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.7" - get-proto "^1.0.1" - which-builtin-type "^1.2.1" - -regexp.prototype.flags@^1.5.3: - version "1.5.4" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" - integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-errors "^1.3.0" - get-proto "^1.0.1" - gopd "^1.2.0" - set-function-name "^2.0.2" - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -requires-port@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== - -resolve-alpn@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" - integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-pkg-maps@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" - integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== - -resolve@^1.10.0, resolve@^1.22.4, resolve@~1.22.2: - version "1.22.10" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" - integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== - dependencies: - is-core-module "^2.16.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -responselike@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" - integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== - dependencies: - lowercase-keys "^2.0.0" - -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== - -reusify@^1.0.4: - version "1.1.0" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" - integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== - -rfdc@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" - integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -roarr@^2.15.3: - version "2.15.4" - resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd" - integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== - dependencies: - boolean "^3.0.1" - detect-node "^2.0.4" - globalthis "^1.0.1" - json-stringify-safe "^5.0.1" - semver-compare "^1.0.0" - sprintf-js "^1.1.2" - -rollup@^4.20.0: - version "4.39.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.39.0.tgz#9dc1013b70c0e2cb70ef28350142e9b81b3f640c" - integrity sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g== - dependencies: - "@types/estree" "1.0.7" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.39.0" - "@rollup/rollup-android-arm64" "4.39.0" - "@rollup/rollup-darwin-arm64" "4.39.0" - "@rollup/rollup-darwin-x64" "4.39.0" - "@rollup/rollup-freebsd-arm64" "4.39.0" - "@rollup/rollup-freebsd-x64" "4.39.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.39.0" - "@rollup/rollup-linux-arm-musleabihf" "4.39.0" - "@rollup/rollup-linux-arm64-gnu" "4.39.0" - "@rollup/rollup-linux-arm64-musl" "4.39.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.39.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-musl" "4.39.0" - "@rollup/rollup-linux-s390x-gnu" "4.39.0" - "@rollup/rollup-linux-x64-gnu" "4.39.0" - "@rollup/rollup-linux-x64-musl" "4.39.0" - "@rollup/rollup-win32-arm64-msvc" "4.39.0" - "@rollup/rollup-win32-ia32-msvc" "4.39.0" - "@rollup/rollup-win32-x64-msvc" "4.39.0" - fsevents "~2.3.2" - -run-applescript@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.0.0.tgz#e5a553c2bffd620e169d276c1cd8f1b64778fbeb" - integrity sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A== - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -rxjs@^7.4.0: - version "7.8.2" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" - integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== - dependencies: - tslib "^2.1.0" - -safe-array-concat@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" - integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - get-intrinsic "^1.2.6" - has-symbols "^1.1.0" - isarray "^2.0.5" - -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-push-apply@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" - integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== - dependencies: - es-errors "^1.3.0" - isarray "^2.0.5" - -safe-regex-test@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" - integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - is-regex "^1.2.1" - -"safer-buffer@>= 2.1.2 < 3.0.0": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sanitize-filename@^1.6.3: - version "1.6.3" - resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378" - integrity sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg== - dependencies: - truncate-utf8-bytes "^1.0.0" - -sass-embedded-android-arm64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.86.2.tgz#ed04dd5a066d5c7dc0c469d76897cfe93be1f846" - integrity sha512-q3d3SW5JWv3U4Fxf01Ho0Ij7iSmA9528J8hRQW/qiPq/rNLpaX+YNTQfaWgSQcuKsrHiqJwWwqN7nTL3rdmNGQ== - -sass-embedded-android-arm@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.86.2.tgz#d616fd253f40781a53228002b49dbb1518a83203" - integrity sha512-gjve+jvwUUdY96VxfhNWyJ0BCHFcMiLuESNWYVuntSGPsuSiTZJFMVZxtb7oEXl5HDn9NL5IbPMbox8R8A4Gew== - -sass-embedded-android-ia32@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.86.2.tgz#261fa22f995f540f186c1da3da2c6579a882a90a" - integrity sha512-AbWxVmiZxKC4O5AH1X1rypngu+Mc5/Jl8ZcO7X3RBL3MDSH87MNoSjYHtYeC/j9BFzFK+5h9uluRq+86DoRX0Q== - -sass-embedded-android-riscv64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.86.2.tgz#99a05e1de78965f22fac06cbeeff1c1e2223d7b4" - integrity sha512-5IFIRPyWtTUBHV1kWJfJCTr9gYeF9yA8bkuvUJ6cCMrj58CiWnGODeqzz8SWpR6TIOwJMl6cT8lKGWQbMMtdUA== - -sass-embedded-android-x64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.86.2.tgz#9de9e2fd4ec7d454ab04ab82c9abe410dde2f3f6" - integrity sha512-DzcDdmYwMmyFu/d5YXH2/qYQ0sJh3XoLma4ktzptmQnhgyTo4ajqC313TBCSrUThBxJPcfzy5ji+mZRWJpGHEg== - -sass-embedded-darwin-arm64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.86.2.tgz#d20dcba78efbd47262adfb374e12cf704b7d088e" - integrity sha512-wmcrNCdhdod9n67g+G/lm3pwv5kNqHSsfBwq6oTgpKUtoecc44UhKMaZ7P5foTTTRybVVj7w5qVPGh8H25Tlgg== - -sass-embedded-darwin-x64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.86.2.tgz#39fe94c0ca1ff31787c0897d31f78100e0606530" - integrity sha512-dHfnCfimKklYanqlubidA3Kyk9g7Ltcs7btfzrrWzvyfRAFKkg826aDHfnnDw8ihBYlmNrHa4jqxPSP5L88m3w== - -sass-embedded-linux-arm64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.86.2.tgz#93dd6a4e5f81fff66217377ffff28fa4492a3c9e" - integrity sha512-K7sw2w2TMboorrIRM5EQIU7FAvERyfOc227dLkGx7mhInBq5bUX9ixI8sN0AGdvmFmBipE4RAlmfYkjjavroxQ== - -sass-embedded-linux-arm@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.86.2.tgz#2463d3eff2c7cb11ebc3d9021e0d7303250cc43a" - integrity sha512-ZTUvotjO/+CIXs3/fFpFWHLmUnEtvilIgiTHilx8yS2eReJWBzlgXneHQf6ZSNMqCNF/lbiJbJokBEDhW77drg== - -sass-embedded-linux-ia32@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.86.2.tgz#ee76816190314f2b550e0df42566058d7eb08ab3" - integrity sha512-+OHfCDU3S86oHlKWolp1mtk/6HAcsvBw7wqff5ze3Gp62jSfe4KKojhKhCtBs2ZK7W/O7U/7WM58sSWdoJ8Tow== - -sass-embedded-linux-musl-arm64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.86.2.tgz#efc831f9b1d5e40d78f6fc414b0f3f134ba6023f" - integrity sha512-j9GVzPLaPmXQJroq+Dw1loH+EB3mQcP4RtIumIWzJh1HvfQG9QEoevG2oiofXS1Wd8705N6Cp3rCFrg1eIUtfw== - -sass-embedded-linux-musl-arm@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.86.2.tgz#32e836003b27d70262a94b0bbb582d70bb0430bc" - integrity sha512-8DZRt9ipTeyTXe+Hpck3lmQBCXgFza4kbqkyByT1tleGx95hNxSNFBdrK6oYHLIxDz2HXr46PyapP2QlHODBcA== - -sass-embedded-linux-musl-ia32@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.86.2.tgz#9b0f95f0b3ad7ffaa6936c5c0a6ecb760cdf1800" - integrity sha512-ZfOmohK3bNKQifJs9DULS4HjBmVy2K8BOi1p7JvWik+SSnpXi9MK1mEJi7w71ktZZ+NvFgpDbeIvCpxyaZJsKg== - -sass-embedded-linux-musl-riscv64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.86.2.tgz#4337c25b7b88c7a010ce4032b04c6de2687e5af6" - integrity sha512-MdT2L1sMSv7ytOCAj8OAf4srm7jDiAmpiHH+0cxMJPwu8uo1oa1aMjcXBW0vfC+SB8ugoBapW0Fnfu/QjVgmjw== - -sass-embedded-linux-musl-x64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.86.2.tgz#bbd28eb7ea9c5f9a3aac143e94c898a6e95600d8" - integrity sha512-huV+hy3UDRDQwwcECXZL2J4+yxRnOYCGET4y/eyJoLprlpRzl41z+byikXDsz8/f0HsttZ1DOcUmPcJwts9rJw== - -sass-embedded-linux-riscv64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.86.2.tgz#7acaa5deab2c575d6270f84afd4749f9a524a7e8" - integrity sha512-SwMgxIcsiMqOrM9Ki+kDULHRPBvwnGoVyX0MNKPeTADTMm2ISD9sK7p5L7UyDmz+DE4Zgf0qx5pT1K1KP1pn8A== - -sass-embedded-linux-x64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.86.2.tgz#ff5e2951c08f01d168ae6bdc06f6f152ac430d6c" - integrity sha512-Tw3w6KGp5YNBaVpRj1F5xhUS6ol+bVlVo+tvMKYoH2pDy5BHb+vMftviCaJDtTsZiYKFXWHAaygmXF8YGOwvPg== - -sass-embedded-win32-arm64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.86.2.tgz#86aadaefffb35a6c770fbb218416337fb3fe1891" - integrity sha512-P45xUyLQ4F8s89yZNMWqkQGWHKx8J/ALS/Jl8JJeZcSVRFPQCaldZ/Zx8K2kAdVh5dg4OiFne8/YqpXBfWlHtw== - -sass-embedded-win32-ia32@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.86.2.tgz#97e16a996a010d55d6b2b6e267b5d976deacf327" - integrity sha512-AsvPpk3dmJRXCoZu9UKL7CXtWmXb4/CMQwo6wRe4SzwHpwHOy+Hj30lh5SRvcr9+J/knA7Aje2xMPxFPYgE4uQ== - -sass-embedded-win32-x64@1.86.2: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.86.2.tgz#38b2dc127c4855700c499c92602f105169cf86b7" - integrity sha512-Nvhyr2BxZN/Rh9YnBDc0iGboLA5lAx8h0/Dvut2iGdxVZ2eVqcV/uLXfsPJ9KHf+QNW5CIo2zOjQKqUQJjh2sw== - -sass-embedded@^1.83.4: - version "1.86.2" - resolved "https://registry.yarnpkg.com/sass-embedded/-/sass-embedded-1.86.2.tgz#50239e02bddcc750c33132efbb8ee6c014248769" - integrity sha512-ER9yUk71007a+6azLBR0RzA4Vd4VtXpaRpI+HXqpEIARhleTKYUxXrh6nY+272q91xAzoXqBKVlTizOvNmb5yQ== - dependencies: - "@bufbuild/protobuf" "^2.0.0" - buffer-builder "^0.2.0" - colorjs.io "^0.5.0" - immutable "^5.0.2" - rxjs "^7.4.0" - supports-color "^8.1.1" - sync-child-process "^1.0.2" - varint "^6.0.0" - optionalDependencies: - sass-embedded-android-arm "1.86.2" - sass-embedded-android-arm64 "1.86.2" - sass-embedded-android-ia32 "1.86.2" - sass-embedded-android-riscv64 "1.86.2" - sass-embedded-android-x64 "1.86.2" - sass-embedded-darwin-arm64 "1.86.2" - sass-embedded-darwin-x64 "1.86.2" - sass-embedded-linux-arm "1.86.2" - sass-embedded-linux-arm64 "1.86.2" - sass-embedded-linux-ia32 "1.86.2" - sass-embedded-linux-musl-arm "1.86.2" - sass-embedded-linux-musl-arm64 "1.86.2" - sass-embedded-linux-musl-ia32 "1.86.2" - sass-embedded-linux-musl-riscv64 "1.86.2" - sass-embedded-linux-musl-x64 "1.86.2" - sass-embedded-linux-riscv64 "1.86.2" - sass-embedded-linux-x64 "1.86.2" - sass-embedded-win32-arm64 "1.86.2" - sass-embedded-win32-ia32 "1.86.2" - sass-embedded-win32-x64 "1.86.2" - -sax@^1.2.4: - version "1.4.1" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" - integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== - -semver-compare@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" - integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== - -"semver@2 || 3 || 4 || 5": - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.2.0, semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.2, semver@^7.3.5, semver@^7.3.6, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1: - version "7.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" - integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== - -serialize-error@^11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-11.0.3.tgz#b54f439e15da5b4961340fbbd376b6b04aa52e92" - integrity sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g== - dependencies: - type-fest "^2.12.2" - -serialize-error@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-7.0.1.tgz#f1360b0447f61ffb483ec4157c737fab7d778e18" - integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== - dependencies: - type-fest "^0.13.1" - -serialport@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/serialport/-/serialport-12.0.0.tgz#136f0976042f57a2e99e886221a2109934531602" - integrity sha512-AmH3D9hHPFmnF/oq/rvigfiAouAKyK/TjnrkwZRYSFZxNggJxwvbAbfYrLeuvq7ktUdhuHdVdSjj852Z55R+uA== - dependencies: - "@serialport/binding-mock" "10.2.2" - "@serialport/bindings-cpp" "12.0.1" - "@serialport/parser-byte-length" "12.0.0" - "@serialport/parser-cctalk" "12.0.0" - "@serialport/parser-delimiter" "12.0.0" - "@serialport/parser-inter-byte-timeout" "12.0.0" - "@serialport/parser-packet-length" "12.0.0" - "@serialport/parser-readline" "12.0.0" - "@serialport/parser-ready" "12.0.0" - "@serialport/parser-regex" "12.0.0" - "@serialport/parser-slip-encoder" "12.0.0" - "@serialport/parser-spacepacket" "12.0.0" - "@serialport/stream" "12.0.0" - debug "4.3.4" - -set-cookie-parser@^2.4.8: - version "2.7.1" - resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz#3016f150072202dfbe90fadee053573cc89d2943" - integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== - -set-function-length@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - -set-function-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" - integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - functions-have-names "^1.2.3" - has-property-descriptors "^1.0.2" - -set-proto@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" - integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== - dependencies: - dunder-proto "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -shell-quote@^1.7.3: - version "1.8.2" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.2.tgz#d2d83e057959d53ec261311e9e9b8f51dcb2934a" - integrity sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA== - -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - -side-channel-map@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" - integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - -side-channel-weakmap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" - integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - side-channel-map "^1.0.1" - -side-channel@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -siginfo@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" - integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== - -signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -signal-exit@^4.0.1, signal-exit@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" - integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== - -simple-update-notifier@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" - integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== - dependencies: - semver "^7.5.3" - -sirv@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.1.tgz#32a844794655b727f9e2867b777e0060fbe07bf3" - integrity sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A== - dependencies: - "@polka/url" "^1.0.0-next.24" - mrmime "^2.0.0" - totalist "^3.0.0" - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -slash@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" - integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== - -slashes@^3.0.12: - version "3.0.12" - resolved "https://registry.yarnpkg.com/slashes/-/slashes-3.0.12.tgz#3d664c877ad542dc1509eaf2c50f38d483a6435a" - integrity sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA== - -slice-ansi@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" - integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== - dependencies: - ansi-styles "^4.0.0" - astral-regex "^2.0.0" - is-fullwidth-code-point "^3.0.0" - -smart-buffer@^4.0.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - -source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== - -source-map-support@^0.5.19: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -spark-md5@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" - integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== - -spdx-correct@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" - integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz#5d607d27fc806f66d7b64a766650fa890f04ed66" - integrity sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w== - -spdx-expression-parse@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" - integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-expression-parse@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz#a23af9f3132115465dac215c099303e4ceac5794" - integrity sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.21" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" - integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== - -speakingurl@^14.0.1: - version "14.0.1" - resolved "https://registry.yarnpkg.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" - integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== - -sprintf-js@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - -stable-hash@^0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/stable-hash/-/stable-hash-0.0.5.tgz#94e8837aaeac5b4d0f631d2972adef2924b40269" - integrity sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA== - -stackback@0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" - integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== - -stat-mode@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465" - integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg== - -std-env@^3.8.0: - version "3.8.1" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.8.1.tgz#2b81c631c62e3d0b964b87f099b8dcab6c9a5346" - integrity sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA== - -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^5.0.1, string-width@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -string.prototype.trim@^1.2.10: - version "1.2.10" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" - integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - define-data-property "^1.1.4" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-object-atoms "^1.0.0" - has-property-descriptors "^1.0.2" - -string.prototype.trimend@^1.0.8, string.prototype.trimend@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" - integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -string.prototype.trimstart@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" - integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~0.10.x: - version "0.10.31" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" - integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== - -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^7.0.1: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== - dependencies: - ansi-regex "^6.0.1" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-final-newline@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" - integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== - -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -sublevel-pouchdb@9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/sublevel-pouchdb/-/sublevel-pouchdb-9.0.0.tgz#e8c3ed95c08301c79e0cd840fb7c244ad5f78b6e" - integrity sha512-pX4r8+F7wuts0C81kUJ341h4bl2aRe7qV572FE8X1FMz9VkKlmi2nPD1vfeiOJXz5Y09I4MHjGULAbqvTfQZEQ== - dependencies: - level-codec "9.0.2" - ltgt "2.2.1" - readable-stream "1.1.14" - -sumchecker@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42" - integrity sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg== - dependencies: - debug "^4.1.0" - -superjson@^2.2.1, superjson@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/superjson/-/superjson-2.2.2.tgz#9d52bf0bf6b5751a3c3472f1292e714782ba3173" - integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q== - dependencies: - copy-anything "^3.0.2" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -sync-child-process@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/sync-child-process/-/sync-child-process-1.0.2.tgz#45e7c72e756d1243e80b547ea2e17957ab9e367f" - integrity sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA== - dependencies: - sync-message-port "^1.0.0" - -sync-message-port@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sync-message-port/-/sync-message-port-1.1.3.tgz#6055c565ee8c81d2f9ee5aae7db757e6d9088c0c" - integrity sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg== - -synckit@^0.11.0: - version "0.11.1" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.1.tgz#8ea44544e9d9c0540963c6bddb8f14616fef5425" - integrity sha512-fWZqNBZNNFp/7mTUy1fSsydhKsAKJ+u90Nk7kOK5Gcq9vObaqLBLjWFDBkyVU9Vvc6Y71VbOevMuGhqv02bT+Q== - dependencies: - "@pkgr/core" "^0.2.0" - tslib "^2.8.1" - -synckit@^0.9.1: - version "0.9.2" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.2.tgz#a3a935eca7922d48b9e7d6c61822ee6c3ae4ec62" - integrity sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw== - dependencies: - "@pkgr/core" "^0.1.0" - tslib "^2.6.2" - -tapable@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== - -tar@^6.1.12: - version "6.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" - integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - -temp-file@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/temp-file/-/temp-file-3.4.0.tgz#766ea28911c683996c248ef1a20eea04d51652c7" - integrity sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg== - dependencies: - async-exit-hook "^2.0.1" - fs-extra "^10.0.0" - -test-exclude@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" - integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^10.4.1" - minimatch "^9.0.4" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -through2@3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.2.tgz#99f88931cfc761ec7678b41d5d7336b5b6a07bf4" - integrity sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ== - dependencies: - inherits "^2.0.4" - readable-stream "2 || 3" - -tiny-typed-emitter@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" - integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== - -tinybench@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" - integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== - -tinyexec@^0.3.1: - version "0.3.2" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" - integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== - -tinyglobby@^0.2.12: - version "0.2.12" - resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.12.tgz#ac941a42e0c5773bd0b5d08f32de82e74a1a61b5" - integrity sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww== - dependencies: - fdir "^6.4.3" - picomatch "^4.0.2" - -tinypool@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" - integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== - -tinyrainbow@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" - integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== - -tinyspy@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a" - integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q== - -tmp-promise@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.3.tgz#60a1a1cc98c988674fcbfd23b6e3367bdeac4ce7" - integrity sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ== - dependencies: - tmp "^0.2.0" - -tmp@^0.2.0: - version "0.2.3" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" - integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -totalist@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8" - integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ== - -tough-cookie@^4.0.0: - version "4.1.4" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" - integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== - dependencies: - psl "^1.1.33" - punycode "^2.1.1" - universalify "^0.2.0" - url-parse "^1.5.3" - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -truncate-utf8-bytes@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b" - integrity sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ== - dependencies: - utf8-byte-length "^1.0.1" - -ts-api-utils@^1.3.0: - version "1.4.3" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" - integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== - -ts-api-utils@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" - integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== - -tsconfck@^3.0.3: - version "3.1.5" - resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.5.tgz#2f07f9be6576825e7a77470a5304ce06c7746e61" - integrity sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg== - -tsconfig-paths@^3.15.0: - version "3.15.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" - integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== - dependencies: - "@types/json5" "^0.0.29" - json5 "^1.0.2" - minimist "^1.2.6" - strip-bom "^3.0.0" - -tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - -type-check@^0.4.0, type-check@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" - integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== - dependencies: - prelude-ls "^1.2.1" - -type-fest@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" - integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" - integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== - -type-fest@^2.12.2: - version "2.19.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" - integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== - -type-fest@^4.33.0: - version "4.39.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.39.0.tgz#c7758be50a83a5b879e7a59ea52421e9816b3928" - integrity sha512-w2IGJU1tIgcrepg9ZJ82d8UmItNQtOFJG0HCUE3SzMokKkTsruVDALl2fAdiEzJlfduoU+VyXJWIIUZ+6jV+nw== - -typed-array-buffer@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" - integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-typed-array "^1.1.14" - -typed-array-byte-length@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" - integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== - dependencies: - call-bind "^1.0.8" - for-each "^0.3.3" - gopd "^1.2.0" - has-proto "^1.2.0" - is-typed-array "^1.1.14" - -typed-array-byte-offset@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" - integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - for-each "^0.3.3" - gopd "^1.2.0" - has-proto "^1.2.0" - is-typed-array "^1.1.15" - reflect.getprototypeof "^1.0.9" - -typed-array-length@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" - integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - reflect.getprototypeof "^1.0.6" - -typescript-eslint-parser-for-extra-files@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/typescript-eslint-parser-for-extra-files/-/typescript-eslint-parser-for-extra-files-0.7.0.tgz#4d7655c6e627352a7a14ffb58dde9d88fde8313e" - integrity sha512-dNjuJCeGHA2yreEzQC8NRrZylgrJKZJ/kpLNusbRqEUAJfwJo2eOALpr2vsxcoqAzDyi72T8FQypSVhAOAaJrw== - dependencies: - globby "^11.1.0" - is-glob "^4.0.3" - -typescript@5.7.3: - version "5.7.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" - integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== - -typescript@^5.3.3: - version "5.8.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" - integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== - -ufo@^1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" - integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== - -unbox-primitive@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" - integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== - dependencies: - call-bound "^1.0.3" - has-bigints "^1.0.2" - has-symbols "^1.1.0" - which-boxed-primitive "^1.1.1" - -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== - -undici-types@~6.21.0: - version "6.21.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" - integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== - -unicorn-magic@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz#4efd45c85a69e0dd576d25532fbfa22aa5c8a104" - integrity sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA== - -universalify@^0.1.0: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== - -universalify@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" - integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== - -universalify@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" - integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== - -unplugin@^1.1.0: - version "1.16.1" - resolved "https://registry.yarnpkg.com/unplugin/-/unplugin-1.16.1.tgz#a844d2e3c3b14a4ac2945c42be80409321b61199" - integrity sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w== - dependencies: - acorn "^8.14.0" - webpack-virtual-modules "^0.6.2" - -unrs-resolver@^1.3.2: - version "1.3.3" - resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.3.3.tgz#46bd5dd2ecc650365e050055fc208b5f4ae57803" - integrity sha512-PFLAGQzYlyjniXdbmQ3dnGMZJXX5yrl2YS4DLRfR3BhgUsE1zpRIrccp9XMOGRfIHpdFvCn/nr5N1KMVda4x3A== - optionalDependencies: - "@unrs/resolver-binding-darwin-arm64" "1.3.3" - "@unrs/resolver-binding-darwin-x64" "1.3.3" - "@unrs/resolver-binding-freebsd-x64" "1.3.3" - "@unrs/resolver-binding-linux-arm-gnueabihf" "1.3.3" - "@unrs/resolver-binding-linux-arm-musleabihf" "1.3.3" - "@unrs/resolver-binding-linux-arm64-gnu" "1.3.3" - "@unrs/resolver-binding-linux-arm64-musl" "1.3.3" - "@unrs/resolver-binding-linux-ppc64-gnu" "1.3.3" - "@unrs/resolver-binding-linux-s390x-gnu" "1.3.3" - "@unrs/resolver-binding-linux-x64-gnu" "1.3.3" - "@unrs/resolver-binding-linux-x64-musl" "1.3.3" - "@unrs/resolver-binding-wasm32-wasi" "1.3.3" - "@unrs/resolver-binding-win32-arm64-msvc" "1.3.3" - "@unrs/resolver-binding-win32-ia32-msvc" "1.3.3" - "@unrs/resolver-binding-win32-x64-msvc" "1.3.3" - -upath@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" - integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== - -update-browserslist-db@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" - integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== - dependencies: - escalade "^3.2.0" - picocolors "^1.1.1" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -url-parse@^1.5.3: - version "1.5.10" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" - integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== - dependencies: - querystringify "^2.1.1" - requires-port "^1.0.0" - -utf-8-validate@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-6.0.5.tgz#8087d39902be2cc15bdb21a426697ff256d65aab" - integrity sha512-EYZR+OpIXp9Y1eG1iueg8KRsY8TuT8VNgnanZ0uA3STqhHQTLwbl+WX76/9X5OY12yQubymBpaBSmMPkSTQcKA== - dependencies: - node-gyp-build "^4.3.0" - -utf8-byte-length@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz#f9f63910d15536ee2b2d5dd4665389715eac5c1e" - integrity sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA== - -util-deprecate@^1.0.1, util-deprecate@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -util@^0.12.5: - version "0.12.5" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" - integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== - dependencies: - inherits "^2.0.3" - is-arguments "^1.0.4" - is-generator-function "^1.0.7" - is-typed-array "^1.1.3" - which-typed-array "^1.1.2" - -uuid@8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -varint@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/varint/-/varint-6.0.0.tgz#9881eb0ce8feaea6512439d19ddf84bf551661d0" - integrity sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg== - -verror@^1.10.0: - version "1.10.1" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.1.tgz#4bf09eeccf4563b109ed4b3d458380c972b0cdeb" - integrity sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg== - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - -vite-hot-client@^0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/vite-hot-client/-/vite-hot-client-0.2.4.tgz#c88789f1615cf4e95690cd5fca98b2e449a29637" - integrity sha512-a1nzURqO7DDmnXqabFOliz908FRmIppkBKsJthS8rbe8hBEXwEwe4C3Pp33Z1JoFCYfVL4kTOMLKk0ZZxREIeA== - -vite-node@2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.9.tgz#549710f76a643f1c39ef34bdb5493a944e4f895f" - integrity sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA== - dependencies: - cac "^6.7.14" - debug "^4.3.7" - es-module-lexer "^1.5.4" - pathe "^1.1.2" - vite "^5.0.0" - -vite-plugin-inspect@0.8.9: - version "0.8.9" - resolved "https://registry.yarnpkg.com/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz#01a7e484ccbc12a8c86ee8bc90efe13aeb0fed1b" - integrity sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A== - dependencies: - "@antfu/utils" "^0.7.10" - "@rollup/pluginutils" "^5.1.3" - debug "^4.3.7" - error-stack-parser-es "^0.1.5" - fs-extra "^11.2.0" - open "^10.1.0" - perfect-debounce "^1.0.0" - picocolors "^1.1.1" - sirv "^3.0.0" - -vite-plugin-vue-devtools@^7.7.1: - version "7.7.2" - resolved "https://registry.yarnpkg.com/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-7.7.2.tgz#87f9c88ec4784abc4fc87609967c5896c6868c49" - integrity sha512-5V0UijQWiSBj32blkyPEqIbzc6HO9c1bwnBhx+ay2dzU0FakH+qMdNUT8nF9BvDE+i6I1U8CqCuJiO20vKEdQw== - dependencies: - "@vue/devtools-core" "^7.7.2" - "@vue/devtools-kit" "^7.7.2" - "@vue/devtools-shared" "^7.7.2" - execa "^9.5.1" - sirv "^3.0.0" - vite-plugin-inspect "0.8.9" - vite-plugin-vue-inspector "^5.3.1" - -vite-plugin-vue-inspector@^5.3.1: - version "5.3.1" - resolved "https://registry.yarnpkg.com/vite-plugin-vue-inspector/-/vite-plugin-vue-inspector-5.3.1.tgz#e57abdb11b15dea0f5db0b0af2e2d0b236c18717" - integrity sha512-cBk172kZKTdvGpJuzCCLg8lJ909wopwsu3Ve9FsL1XsnLBiRT9U3MePcqrgGHgCX2ZgkqZmAGR8taxw+TV6s7A== - dependencies: - "@babel/core" "^7.23.0" - "@babel/plugin-proposal-decorators" "^7.23.0" - "@babel/plugin-syntax-import-attributes" "^7.22.5" - "@babel/plugin-syntax-import-meta" "^7.10.4" - "@babel/plugin-transform-typescript" "^7.22.15" - "@vue/babel-plugin-jsx" "^1.1.5" - "@vue/compiler-dom" "^3.3.4" - kolorist "^1.8.0" - magic-string "^0.30.4" - -vite-plugin-vuetify@^2.0.4: - version "2.1.0" - resolved "https://registry.yarnpkg.com/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.0.tgz#56767be099fd18a44ec2f9831d49269722e0e538" - integrity sha512-4wEAQtZaigPpwbFcZbrKpYwutOsWwWdeXn22B9XHzDPQNxVsKT+K9lKcXZnI5JESO1Iaql48S9rOk8RZZEt+Mw== - dependencies: - "@vuetify/loader-shared" "^2.1.0" - debug "^4.3.3" - upath "^2.0.1" - -vite-tsconfig-paths@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz#d9a71106a7ff2c1c840c6f1708042f76a9212ed4" - integrity sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w== - dependencies: - debug "^4.1.1" - globrex "^0.1.2" - tsconfck "^3.0.3" - -vite@^5.0.0, vite@^5.4.16: - version "5.4.16" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.16.tgz#471983257a890ef33f2700cbbbc2134f2d08abf1" - integrity sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ== - dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" - optionalDependencies: - fsevents "~2.3.3" - -vitest@^2.1.9: - version "2.1.9" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.1.9.tgz#7d01ffd07a553a51c87170b5e80fea3da7fb41e7" - integrity sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q== - dependencies: - "@vitest/expect" "2.1.9" - "@vitest/mocker" "2.1.9" - "@vitest/pretty-format" "^2.1.9" - "@vitest/runner" "2.1.9" - "@vitest/snapshot" "2.1.9" - "@vitest/spy" "2.1.9" - "@vitest/utils" "2.1.9" - chai "^5.1.2" - debug "^4.3.7" - expect-type "^1.1.0" - magic-string "^0.30.12" - pathe "^1.1.2" - std-env "^3.8.0" - tinybench "^2.9.0" - tinyexec "^0.3.1" - tinypool "^1.0.1" - tinyrainbow "^1.2.0" - vite "^5.0.0" - vite-node "2.1.9" - why-is-node-running "^2.3.0" - -vscode-uri@^3.0.8: - version "3.1.0" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" - integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== - -vue-demi@>=0.14.10, vue-demi@^0.14.10: - version "0.14.10" - resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" - integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== - -vue-demi@^0.13.11: - version "0.13.11" - resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.13.11.tgz#7d90369bdae8974d87b1973564ad390182410d99" - integrity sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A== - -vue-eslint-parser@^9.3.1, vue-eslint-parser@^9.4.3: - version "9.4.3" - resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz#9b04b22c71401f1e8bca9be7c3e3416a4bde76a8" - integrity sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg== - dependencies: - debug "^4.3.4" - eslint-scope "^7.1.1" - eslint-visitor-keys "^3.3.0" - espree "^9.3.1" - esquery "^1.4.0" - lodash "^4.17.21" - semver "^7.3.6" - -vue-i18n@^10.0.0, vue-i18n@^10.0.6: - version "10.0.6" - resolved "https://registry.yarnpkg.com/vue-i18n/-/vue-i18n-10.0.6.tgz#d7df8a575479c9d771036d0cbd740096740e9f86" - integrity sha512-pQPspK5H4srzlu+47+HEY2tmiY3GyYIvSPgSBdQaYVWv7t1zj1t9p1FvHlxBXyJ17t9stG/Vxj+pykrvPWBLeQ== - dependencies: - "@intlify/core-base" "10.0.6" - "@intlify/shared" "10.0.6" - "@vue/devtools-api" "^6.5.0" - -vue-router@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.5.0.tgz#58fc5fe374e10b6018f910328f756c3dae081f14" - integrity sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w== - dependencies: - "@vue/devtools-api" "^6.6.4" - -vue-tsc@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.2.0.tgz#dd06c56636f760d7534b7a7a0f6669ba93c217b8" - integrity sha512-gtmM1sUuJ8aSb0KoAFmK9yMxb8TxjewmxqTJ1aKphD5Cbu0rULFY6+UQT51zW7SpUcenfPUuflKyVwyx9Qdnxg== - dependencies: - "@volar/typescript" "~2.4.11" - "@vue/language-core" "2.2.0" - -vue@^3.4, vue@^3.5.13: - version "3.5.13" - resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a" - integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== - dependencies: - "@vue/compiler-dom" "3.5.13" - "@vue/compiler-sfc" "3.5.13" - "@vue/runtime-dom" "3.5.13" - "@vue/server-renderer" "3.5.13" - "@vue/shared" "3.5.13" - -vuetify@^3.7.19: - version "3.8.0" - resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.8.0.tgz#de5473f575d83f50f1e1d6030594fc6a650c4f30" - integrity sha512-ROC0Xq2G/25ZyUpQMhaynMyXZBJY1WbOGlqOB810yubp8hfY8RlrOw+mzXJonOq6jylCY32muQ9xiJF1JPTLVA== - -vuvuzela@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/vuvuzela/-/vuvuzela-1.0.3.tgz#3be145e58271c73ca55279dd851f12a682114b0b" - integrity sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ== - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -webpack-virtual-modules@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" - integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" - integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== - dependencies: - is-bigint "^1.1.0" - is-boolean-object "^1.2.1" - is-number-object "^1.1.1" - is-string "^1.1.1" - is-symbol "^1.1.1" - -which-builtin-type@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" - integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== - dependencies: - call-bound "^1.0.2" - function.prototype.name "^1.1.6" - has-tostringtag "^1.0.2" - is-async-function "^2.0.0" - is-date-object "^1.1.0" - is-finalizationregistry "^1.1.0" - is-generator-function "^1.0.10" - is-regex "^1.2.1" - is-weakref "^1.0.2" - isarray "^2.0.5" - which-boxed-primitive "^1.1.0" - which-collection "^1.0.2" - which-typed-array "^1.1.16" - -which-collection@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" - integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== - dependencies: - is-map "^2.0.3" - is-set "^2.0.3" - is-weakmap "^2.0.2" - is-weakset "^2.0.3" - -which-typed-array@^1.1.16, which-typed-array@^1.1.18, which-typed-array@^1.1.2: - version "1.1.19" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" - integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - call-bound "^1.0.4" - for-each "^0.3.5" - get-proto "^1.0.1" - gopd "^1.2.0" - has-tostringtag "^1.0.2" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -which@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/which/-/which-5.0.0.tgz#d93f2d93f79834d4363c7d0c23e00d07c466c8d6" - integrity sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ== - dependencies: - isexe "^3.1.1" - -why-is-node-running@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" - integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== - dependencies: - siginfo "^2.0.0" - stackback "0.0.2" - -word-wrap@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" - integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== - -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" - integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== - dependencies: - ansi-styles "^6.1.0" - string-width "^5.0.1" - strip-ansi "^7.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -ws@^8.18.1: - version "8.18.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.1.tgz#ea131d3784e1dfdff91adb0a4a116b127515e3cb" - integrity sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w== - -xdg-basedir@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-5.1.0.tgz#1efba19425e73be1bc6f2a6ceb52a3d2c884c0c9" - integrity sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ== - -xml-name-validator@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" - integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== - -xmlbuilder@>=11.0.1, xmlbuilder@^15.1.1: - version "15.1.1" - resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" - integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== - -xtend@^4.0.2, xtend@~4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yallist@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml-eslint-parser@^1.2.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz#975dd11f8349e18c15c88b0e41a6d0b0377969cd" - integrity sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA== - dependencies: - eslint-visitor-keys "^3.0.0" - yaml "^2.0.0" - -yaml@^2.0.0: - version "2.7.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.1.tgz#44a247d1b88523855679ac7fa7cda6ed7e135cf6" - integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== - -yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - -yargs@^17.6.2: - version "17.7.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== - dependencies: - cliui "^8.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" - y18n "^5.0.5" - yargs-parser "^21.1.1" - -yauzl@^2.10.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" - integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== - dependencies: - buffer-crc32 "~0.2.3" - fd-slicer "~1.1.0" - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -yoctocolors@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.1.tgz#e0167474e9fbb9e8b3ecca738deaa61dd12e56fc" - integrity sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ== - -zod@^3.24.1: - version "3.24.2" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.24.2.tgz#8efa74126287c675e92f46871cfc8d15c34372b3" - integrity sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==