From 12d67708313a509e88f5277a9d2311864456cddb Mon Sep 17 00:00:00 2001 From: Matthew Holder Date: Tue, 13 Jan 2026 21:24:42 -0600 Subject: [PATCH 1/3] Current v3 work in progress --- .editorconfig | 29 +- .eslintignore | 4 - .eslintrc.cjs | 123 - .gitattributes | 4 - .github/workflows/coverage.yml | 34 - .github/workflows/lint.yml | 34 - .github/workflows/typecheck.yml | 36 - .gitignore | 65 +- .husky/pre-commit | 1 - .idea/.gitignore | 7 + .idea/.name | 1 + .idea/AndroidProjectSystem.xml | 6 + .idea/artifacts/composeApp_jvm.xml | 8 + .idea/codeStyles/Project.xml | 60 + .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/compiler.xml | 6 + .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.ask.xml | 6 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + .idea/deploymentTargetSelector.xml | 10 + .idea/deviceManager.xml | 13 + .idea/dictionaries/project.xml | 9 + .idea/gradle.xml | 17 + src/images/logo.svg => .idea/icon.svg | 0 .idea/inspectionProfiles/Project_Default.xml | 77 + .idea/kotlinc.xml | 16 + .idea/ktfmt.xml | 6 + .idea/ktlint-plugin.xml | 7 + .idea/misc.xml | 14 + .idea/modules.xml | 8 + .idea/receipt.json | 25 + .idea/runConfigurations.xml | 17 + .../_bridgecmdr__desktop_.xml | 39 + .../bridgecmdr__android_.xml | 45 + .../bridgecmdr__desktop_.xml | 32 + .idea/sqldialects.xml | 6 + .idea/vcs.xml | 6 + .java-version | 1 + .ncurc.yaml | 2 - .nvmrc | 1 - .vscode/extensions.json | 3 - .vscode/launch.json | 36 - .vscode/settings.json | 18 - .yarnrc | 1 - .yarnrc.yml | 3 - README.md | 118 +- TODO.md | 20 + build.gradle.kts | 15 + composeApp/build.gradle.kts | 187 + .../src/androidMain/AndroidManifest.xml | 29 + .../sleepingcats/bridgecmdr/MainActivity.kt | 64 + .../bridgecmdr/MainApplication.kt | 30 + .../bridgecmdr/ui/Routes.android.kt | 18 + .../module/PlatformContextModule.android.kt | 12 + .../module/PlatformSpecificModule.android.kt | 59 + .../PlatformDashboardActions.android.kt | 32 + .../ui/service/MobileConnectionService.kt | 219 + .../bridgecmdr/ui/view/MobileSettingsView.kt | 104 + .../bridgecmdr/ui/view/ScannerView.kt | 173 + .../ui/view/model/MobileDashboardViewModel.kt | 18 + .../ui/view/model/MobileSettingsViewModel.kt | 38 + .../org/sleepingcats/core/Platform.android.kt | 10 + .../ic_app_shortcut_foreground.xml | 8 + .../drawable-v24/ic_launcher_foreground.xml | 7 + .../res/drawable/ic_launcher_background.xml | 14 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5474 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 7266 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3268 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 4312 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 7689 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 10625 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 13028 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 18502 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 19737 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 27000 bytes .../src/androidMain/res/values/strings.xml | 3 + .../uk/uuid/slf4j/android/config.properties | 3 + .../drawable/alert_circle.xml | 1 + .../composeResources/drawable/arrow_left.xml | 1 + .../composeResources/drawable/close.xml | 1 + .../composeResources/drawable/cog.xml | 1 + .../composeResources/drawable/cogs.xml | 1 + .../drawable/controller_classic.xml | 1 + .../composeResources/drawable/delete.xml | 1 + .../composeResources/drawable/exit_run.xml | 1 + .../composeResources/drawable/export.xml | 1 + .../composeResources/drawable/flask.xml | 1 + .../drawable/gamepad_square.xml | 1 + .../composeResources/drawable/image_plus.xml | 1 + .../composeResources/drawable/import.xml | 1 + .../composeResources/drawable/information.xml | 1 + .../composeResources/drawable/menu_down.xml | 1 + .../composeResources/drawable/minus.xml | 1 + .../composeResources/drawable/monitor.xml | 1 + .../drawable/nintendo_game_boy.xml | 1 + .../composeResources/drawable/plus.xml | 1 + .../composeResources/drawable/power.xml | 1 + .../drawable/power_socket.xml | 1 + .../drawable/refresh_auto.xml | 1 + .../drawable/server_network.xml | 1 + .../drawable/server_outline.xml | 1 + .../drawable/swap_vertical.xml | 1 + .../drawable/theme_light_dark.xml | 1 + .../drawable/video_switch.xml | 1 + .../drawable/view_dashboard.xml | 1 + .../drawable/volume_medium.xml | 1 + .../composeResources/values/actions.xml | 16 + .../composeResources/values/branding.xml | 7 + .../composeResources/values/devices.xml | 5 + .../composeResources/values/response.xml | 9 + .../composeResources/values/settings.xml | 23 + .../composeResources/values/strings.xml | 9 + .../kotlin/org/sleepingcats/bridgecmdr/App.kt | 65 + .../org/sleepingcats/bridgecmdr/Branding.kt | 10 + .../org/sleepingcats/bridgecmdr/Greeting.kt | 7 + .../sleepingcats/bridgecmdr/common/Mode.kt | 5 + .../bridgecmdr/common/ServerCode.kt | 33 + .../common/extension/NavController.kt | 10 + .../common/extension/Preferences.kt | 28 + .../bridgecmdr/common/extension/String.kt | 12 + .../common/module/RemoteServiceModule.kt | 33 + .../bridgecmdr/common/security/Encryption.kt | 37 + .../common/security/TokenService.kt | 33 + .../common/service/DeviceService.kt | 29 + .../common/service/DriverService.kt | 13 + .../common/service/LegacySettingsService.kt | 7 + .../bridgecmdr/common/service/PowerService.kt | 7 + .../common/service/SerialPortService.kt | 7 + .../common/service/SourceService.kt | 31 + .../bridgecmdr/common/service/TieService.kt | 38 + .../common/service/UserImageService.kt | 19 + .../common/service/core/ConnectionService.kt | 7 + .../common/service/model/Capabilities.kt | 12 + .../bridgecmdr/common/service/model/Device.kt | 37 + .../bridgecmdr/common/service/model/Driver.kt | 19 + .../common/service/model/DriverKind.kt | 6 + .../common/service/model/LegacySettings.kt | 45 + .../common/service/model/PathType.kt | 18 + .../common/service/model/PortInfo.kt | 16 + .../bridgecmdr/common/service/model/Source.kt | 34 + .../bridgecmdr/common/service/model/Tie.kt | 55 + .../common/service/model/UserImage.kt | 28 + .../service/remote/RemoteDeviceService.kt | 58 + .../service/remote/RemoteDriverService.kt | 25 + .../remote/RemoteLegacySettingsService.kt | 9 + .../service/remote/RemotePowerService.kt | 21 + .../service/remote/RemoteSerialPortService.kt | 16 + .../service/remote/RemoteSourceService.kt | 63 + .../common/service/remote/RemoteTieService.kt | 80 + .../service/remote/RemoteUserImageService.kt | 71 + .../bridgecmdr/common/setting/AppTheme.kt | 19 + .../bridgecmdr/common/setting/IconSize.kt | 13 + .../bridgecmdr/common/setting/PowerOffTaps.kt | 16 + .../org/sleepingcats/bridgecmdr/ui/Routes.kt | 27 + .../bridgecmdr/ui/cache/SourceCache.kt | 12 + .../bridgecmdr/ui/cache/UserImageCache.kt | 52 + .../bridgecmdr/ui/cache/core/DataCache.kt | 45 + .../bridgecmdr/ui/component/BackButton.kt | 21 + .../bridgecmdr/ui/component/HideOnScroll.kt | 28 + .../bridgecmdr/ui/component/ImagePreview.kt | 120 + .../bridgecmdr/ui/component/InputModal.kt | 68 + .../bridgecmdr/ui/component/LoadingOverlay.kt | 57 + .../bridgecmdr/ui/component/MediaImage.kt | 63 + .../bridgecmdr/ui/component/MessageModals.kt | 227 + .../ui/component/OutlinedNumericField.kt | 363 + .../ui/component/OutlinedSelectField.kt | 236 + .../bridgecmdr/ui/component/PopupModal.kt | 80 + .../bridgecmdr/ui/component/SelectModal.kt | 84 + .../bridgecmdr/ui/component/SimpleTooltip.kt | 30 + .../bridgecmdr/ui/component/Utilities.kt | 34 + .../bridgecmdr/ui/component/Validation.kt | 50 + .../bridgecmdr/ui/module/CommonModule.kt | 48 + .../ui/platform/PlatformDashboardActions.kt | 11 + .../ui/repository/SettingsRepository.kt | 127 + .../ui/repository/SourceRepository.kt | 54 + .../ui/repository/core/CachingRepository.kt | 17 + .../ui/repository/core/DataRepository.kt | 15 + .../ui/scaffold/FullscreenModalScaffold.kt | 54 + .../sleepingcats/bridgecmdr/ui/theme/Color.kt | 219 + .../sleepingcats/bridgecmdr/ui/theme/Theme.kt | 277 + .../sleepingcats/bridgecmdr/ui/theme/Type.kt | 5 + .../bridgecmdr/ui/view/DashboardView.kt | 138 + .../ui/view/model/ApplicationViewModel.kt | 30 + .../ui/view/model/DashboardViewModel.kt | 102 + .../ui/view/model/core/TrackingViewModel.kt | 94 + .../sleepingcats/core/ApplicationBranding.kt | 42 + .../org/sleepingcats/core/ErrorHandling.kt | 6 + .../kotlin/org/sleepingcats/core/Memoize.kt | 44 + .../kotlin/org/sleepingcats/core/Platform.kt | 47 + .../org/sleepingcats/core/extensions/Flow.kt | 24 + .../org/sleepingcats/core/extensions/List.kt | 31 + .../core/settings/PreferencesDataStore.kt | 8 + .../bridgecmdr/ApplicationTest.kt | 17 + .../bridgecmdr/ComposeAppCommonTest.kt | 11 + .../composeResources/drawable/app_icon.png | Bin 0 -> 6185 bytes .../composeResources/drawable/qrcode.xml | 1 + .../composeResources/values/backup.xml | 11 + .../composeResources/values/devices.xml | 41 + .../composeResources/values/drivers.xml | 22 + .../composeResources/values/errors.xml | 4 + .../composeResources/values/server.xml | 12 + .../composeResources/values/settings.xml | 30 + .../composeResources/values/sources.xml | 28 + .../composeResources/values/strings.xml | 5 + .../jvmMain/composeResources/values/ties.xml | 37 + .../sleepingcats/bridgecmdr/Environment.kt | 5 + .../org/sleepingcats/bridgecmdr/Main.kt | 59 + .../bridgecmdr/common/backup/Exporter.kt | 112 + .../bridgecmdr/common/backup/Importer.kt | 163 + .../common/backup/model/BackupParser.kt | 5 + .../common/backup/model/Version1.kt | 97 + .../common/backup/model/Version2.kt | 111 + .../common/backup/model/Version3.kt | 114 + .../common/backup/model/Version4.kt | 82 + .../common/backup/model/Version5.kt | 34 + .../bridgecmdr/common/core/DriverBuilder.kt | 21 + .../bridgecmdr/common/core/DriverModule.kt | 27 + .../bridgecmdr/common/core/DriverProtocol.kt | 17 + .../common/driver/extron/ExtronSisDriver.kt | 27 + .../driver/shinybow/ShinybowVersion2Driver.kt | 28 + .../driver/shinybow/ShinybowVersion3Driver.kt | 28 + .../common/driver/sony/SonyRs485Driver.kt | 25 + .../driver/teslasmart/TeslaSmartKvmDriver.kt | 26 + .../teslasmart/TeslaSmartMatrixDriver.kt | 28 + .../driver/teslasmart/TeslaSmartSdiDriver.kt | 26 + .../driver/tesmart/TeSmartSmartKvmDriver.kt | 26 + .../tesmart/TeSmartSmartMatrixDriver.kt | 28 + .../driver/tesmart/TeSmartSmartSdiDriver.kt | 26 + .../bridgecmdr/common/extension/ByteArray.kt | 24 + .../bridgecmdr/common/extension/ByteMatrix.kt | 33 + .../common/extension/FileKitResultLauncher.kt | 7 + .../bridgecmdr/common/extension/Table.kt | 15 + .../common/module/DatabaseModule.kt | 12 + .../common/module/LocalServiceModule.kt | 35 + .../common/protocol/ExtronSisProtocol.kt | 25 + .../common/protocol/ShinybowProtocol.kt | 51 + .../common/protocol/SonyProtocol.kt | 59 + .../common/protocol/TeslaElecKvmProtocol.kt | 25 + .../protocol/TeslaElecMatrixProtocol.kt | 27 + .../common/protocol/TeslaElecSdiProtocol.kt | 24 + .../protocol/stream/AbstractCommandStream.kt | 27 + .../common/protocol/stream/CommandStream.kt | 68 + .../protocol/stream/CommandStreamOptions.kt | 9 + .../common/protocol/stream/DataBits.kt | 10 + .../protocol/stream/NetCommandStream.kt | 56 + .../common/protocol/stream/Parity.kt | 17 + .../common/protocol/stream/ProtocolOptions.kt | 26 + .../common/protocol/stream/ProtocolStream.kt | 62 + .../protocol/stream/SerialCommandStream.kt | 38 + .../common/protocol/stream/StopBits.kt | 13 + .../support/AbstractDriverProtocol.kt | 64 + .../common/protocol/support/sony/Address.kt | 16 + .../protocol/support/sony/AddressKind.kt | 9 + .../common/protocol/support/sony/Command.kt | 37 + .../common/protocol/support/sony/Packet.kt | 25 + .../protocol/support/sony/PacketError.kt | 6 + .../protocol/support/sony/PacketType.kt | 7 + .../protocol/support/sony/SonyDriverError.kt | 6 + .../common/service/DatabaseService.kt | 37 + .../common/service/entity/DeviceEntity.kt | 17 + .../common/service/entity/SettingEntity.kt | 14 + .../common/service/entity/SourceEntity.kt | 16 + .../common/service/entity/TieEntity.kt | 19 + .../service/local/LocalDeviceService.kt | 155 + .../service/local/LocalDriverService.kt | 64 + .../local/LocalLegacySettingsService.kt | 32 + .../common/service/local/LocalPowerService.kt | 40 + .../service/local/LocalSerialPortService.kt | 32 + .../service/local/LocalSourceService.kt | 168 + .../common/service/local/LocalTieService.kt | 178 + .../service/local/LocalUserImageService.kt | 141 + .../common/service/migration/Migration.kt | 13 + .../service/migration/MigrationService.kt | 79 + .../M20260105123500CreateSqliteTables.kt | 91 + .../script/M20260105123530MigrateToSqlite.kt | 18 + .../common/service/model/NewUserImage.kt | 12 + .../common/service/table/DevicesTable.kt | 9 + .../common/service/table/SettingsTable.kt | 9 + .../common/service/table/SourcesTable.kt | 9 + .../common/service/table/TiesTable.kt | 11 + .../common/service/table/UserImagesTable.kt | 9 + .../bridgecmdr/server/Application.kt | 59 + .../bridgecmdr/server/Constants.kt | 4 + .../bridgecmdr/server/ServerController.kt | 120 + .../bridgecmdr/server/ServerStatus.kt | 34 + .../bridgecmdr/server/module/ServerModule.kt | 14 + .../bridgecmdr/server/plugin/ErrorHandling.kt | 72 + .../bridgecmdr/server/plugin/Validation.kt | 18 + .../bridgecmdr/server/route/Devices.kt | 76 + .../bridgecmdr/server/route/Drivers.kt | 36 + .../bridgecmdr/server/route/Images.kt | 77 + .../bridgecmdr/server/route/LegacySettings.kt | 15 + .../bridgecmdr/server/route/Power.kt | 24 + .../bridgecmdr/server/route/SerialPort.kt | 19 + .../bridgecmdr/server/route/Sources.kt | 83 + .../bridgecmdr/server/route/Ties.kt | 111 + .../server/security/ServerContext.kt | 106 + .../server/security/UserPrincipal.kt | 3 + .../sleepingcats/bridgecmdr/ui/MainWindow.kt | 46 + .../sleepingcats/bridgecmdr/ui/Routes.jvm.kt | 98 + .../bridgecmdr/ui/cache/DeviceCache.kt | 12 + .../bridgecmdr/ui/cache/DriverCache.kt | 12 + .../bridgecmdr/ui/cache/PortCache.kt | 8 + .../bridgecmdr/ui/cache/TieCache.kt | 12 + .../bridgecmdr/ui/components/Pickers.kt | 21 + .../ui/module/PlatformContextModule.jvm.kt | 10 + .../ui/module/PlatformSpecificModule.jvm.kt | 117 + .../ui/platform/DashboardActions.jvm.kt | 111 + .../ui/repository/DeviceRepository.kt | 25 + .../ui/repository/DriverRepository.kt | 17 + .../ui/repository/PortRepository.kt | 13 + .../bridgecmdr/ui/repository/TieRepository.kt | 28 + .../ui/service/ApplicationService.kt | 28 + .../bridgecmdr/ui/service/SessionService.kt | 51 + .../ui/service/dbus/SessionManager.kt | 14 + .../ui/service/xdg/AutoStartFlow.kt | 102 + .../ui/service/xdg/entry/Builder.kt | 88 + .../ui/service/xdg/entry/DesktopEntryFile.kt | 189 + .../bridgecmdr/ui/service/xdg/entry/Lines.kt | 39 + .../ui/service/xdg/entry/Utilities.kt | 130 + .../bridgecmdr/ui/service/xdg/entry/Value.kt | 90 + .../bridgecmdr/ui/view/BackupManagerView.kt | 120 + .../bridgecmdr/ui/view/DeviceListView.kt | 182 + .../bridgecmdr/ui/view/EditDeviceView.kt | 250 + .../bridgecmdr/ui/view/EditSourceView.kt | 163 + .../bridgecmdr/ui/view/EditTieView.kt | 215 + .../bridgecmdr/ui/view/GeneralSettingsView.kt | 225 + .../bridgecmdr/ui/view/ServerCodeView.kt | 64 + .../bridgecmdr/ui/view/SettingsView.kt | 123 + .../bridgecmdr/ui/view/SourceListView.kt | 156 + .../bridgecmdr/ui/view/TieListView.kt | 316 + .../ui/view/model/BackupManagerViewModel.kt | 48 + .../view/model/DesktopApplicationViewModel.kt | 68 + .../view/model/DesktopDashboardViewModel.kt | 24 + .../ui/view/model/DeviceListViewModel.kt | 67 + .../ui/view/model/EditDeviceViewModel.kt | 185 + .../ui/view/model/EditSourceViewModel.kt | 114 + .../ui/view/model/EditTieViewModel.kt | 193 + .../ui/view/model/GeneralSettingsViewModel.kt | 74 + .../ui/view/model/ServerCodeViewModel.kt | 38 + .../ui/view/model/SettingsViewModel.kt | 40 + .../ui/view/model/SourceListViewModel.kt | 54 + .../ui/view/model/TieListViewModel.kt | 116 + .../ui/view/model/UserImageModel.kt | 28 + .../core/ApplicationEnvironment.kt | 33 + .../org/sleepingcats/core/BaseDirectories.kt | 162 + .../org/sleepingcats/core/Platform.jvm.kt | 10 + composeApp/src/jvmMain/resources/logback.xml | 15 + electron-builder.yml | 43 - electron.vite.config.ts | 69 - gradle.properties | 10 + gradle/libs.versions.toml | 103 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 + gradlew.bat | 94 + package.json | 158 - prettier.config.cjs | 3 - settings.gradle.kts | 38 + src/core/attachments.ts | 27 - src/core/base64.ts | 15 - src/core/basics.ts | 66 - src/core/delegates.ts | 5 - src/core/error-handling.ts | 48 - src/core/keys.ts | 17 - src/core/location.ts | 242 - src/core/rpc/ipc.ts | 7 - src/core/rpc/transformer.ts | 78 - src/core/url.ts | 25 - src/main/boot/01-normalize-source-order.ts | 6 - src/main/dao/devices.ts | 47 - src/main/dao/sources.ts | 70 - src/main/dao/storage.ts | 43 - src/main/dao/ties.ts | 56 - src/main/drivers/extron/sis.ts | 20 - src/main/drivers/shinybow/v2.ts | 20 - src/main/drivers/shinybow/v3.ts | 20 - src/main/drivers/sony/rs485.ts | 20 - src/main/drivers/tesla-smart/kvm.ts | 20 - src/main/drivers/tesla-smart/matrix.ts | 20 - src/main/drivers/tesla-smart/sdi.ts | 20 - src/main/drivers/tesmart/kvm.ts | 20 - src/main/drivers/tesmart/matrix.ts | 20 - src/main/drivers/tesmart/sdi.ts | 20 - src/main/index.ts | 15 - src/main/main.ts | 144 - .../20241118211400-rename-switches.ts | 21 - .../20241119202100-rename-switchId.ts | 28 - .../20241124121000-add-source-order.ts | 20 - src/main/routes/data/devices.ts | 23 - src/main/routes/data/sources.ts | 34 - src/main/routes/data/storage.ts | 25 - src/main/routes/data/ties.ts | 25 - src/main/routes/drivers.ts | 31 - src/main/routes/migration.ts | 15 - src/main/routes/ports.ts | 15 - src/main/routes/router.ts | 36 - src/main/routes/startup.ts | 21 - src/main/routes/system.ts | 16 - src/main/routes/updater.ts | 46 - src/main/services/app.ts | 12 - src/main/services/boot.ts | 21 - src/main/services/database.ts | 356 - src/main/services/desktop.ts | 112 - src/main/services/drivers.ts | 204 - src/main/services/level.d.ts | 9 - src/main/services/level.js | 106 - src/main/services/locale.ts | 2 - src/main/services/migration.ts | 80 - src/main/services/ports.ts | 119 - src/main/services/protocols/extronSis.ts | 32 - src/main/services/protocols/protocols.ts | 49 - src/main/services/protocols/shinybow.ts | 59 - src/main/services/protocols/sonyBvm.ts | 46 - src/main/services/protocols/sonyRs485.ts | 159 - src/main/services/protocols/teslaElec.ts | 85 - src/main/services/rpc/ipc.ts | 176 - src/main/services/rpc/trpc.ts | 6 - src/main/services/startup.ts | 83 - src/main/services/stream.ts | 140 - src/main/services/system.ts | 33 - src/main/services/updater.ts | 163 - src/main/services/user.ts | 13 - src/main/utilities.ts | 6 - src/preload/api.d.ts | 35 - src/preload/index.ts | 30 - src/renderer/BridgeCmdr.vue | 141 - src/renderer/assets/main.scss | 72 - src/renderer/components.d.ts | 6 - src/renderer/components/DropMenu.vue | 153 - src/renderer/components/Highlight.tsx | 22 - src/renderer/components/InputDialog.vue | 149 - src/renderer/components/NumberInput.vue | 161 - src/renderer/components/OptionDialog.vue | 230 - src/renderer/components/Page.vue | 36 - src/renderer/components/ReplaceableImage.vue | 77 - src/renderer/hooks/assets.ts | 54 - src/renderer/hooks/element.ts | 55 - src/renderer/hooks/errors.ts | 57 - src/renderer/hooks/location.ts | 104 - src/renderer/hooks/tracking.ts | 44 - src/renderer/hooks/utilities.ts | 13 - src/renderer/hooks/validation.ts | 228 - src/renderer/hooks/vue.ts | 14 - src/renderer/hooks/vuetify.ts | 81 - src/renderer/index.html | 12 - src/renderer/index.ts | 21 - src/renderer/locales/en/datetimes.ts | 3 - src/renderer/locales/en/functions.ts | 52 - src/renderer/locales/en/messages.json | 75 - src/renderer/locales/en/numbers.ts | 11 - src/renderer/locales/locales.ts | 14 - src/renderer/main.ts | 17 - src/renderer/modals/AlertModal.vue | 38 - src/renderer/modals/ConfirmModal.vue | 41 - src/renderer/modals/DeviceDialog.vue | 197 - src/renderer/modals/SourceDialog.vue | 126 - src/renderer/modals/TieDialog.vue | 224 - src/renderer/modals/dialogs.ts | 138 - src/renderer/pages/DeviceList.vue | 157 - src/renderer/pages/FirstRunLogic.vue | 100 - src/renderer/pages/GeneralPage.vue | 194 - src/renderer/pages/MainDashboard.vue | 189 - src/renderer/pages/SettingsBackupPage.vue | 88 - src/renderer/pages/SettingsPage.vue | 121 - src/renderer/pages/SourceList.vue | 123 - src/renderer/pages/SourcePage.vue | 272 - src/renderer/plugins/i18n.ts | 54 - src/renderer/plugins/router.ts | 26 - src/renderer/plugins/vuetify.ts | 48 - src/renderer/services/appUpdates.ts | 75 - src/renderer/services/backup/export.ts | 53 - .../services/backup/formats/version0.ts | 6 - .../services/backup/formats/version1.ts | 55 - .../services/backup/formats/version2.ts | 27 - .../services/backup/formats/version3.ts | 48 - src/renderer/services/backup/import.ts | 114 - src/renderer/services/dashboard.ts | 224 - src/renderer/services/data/devices.ts | 57 - src/renderer/services/data/sources.ts | 87 - src/renderer/services/data/storage.ts | 105 - src/renderer/services/data/ties.ts | 67 - src/renderer/services/driver.ts | 134 - src/renderer/services/migration.ts | 16 - src/renderer/services/ports.ts | 27 - src/renderer/services/rpc/link.ts | 105 - src/renderer/services/rpc/trpc.ts | 9 - src/renderer/services/rpc/utilities.ts | 23 - src/renderer/services/settings.ts | 78 - src/renderer/services/startup.ts | 25 - src/renderer/services/store.ts | 132 - src/renderer/support/events.ts | 79 - src/renderer/support/files.ts | 102 - src/renderer/support/i18n.ts | 30 - src/renderer/support/locale.ts | 16 - src/tests/core/attachments.test.ts | 58 - src/tests/core/basics.test.ts | 74 - src/tests/core/error-handling.test.ts | 72 - src/tests/core/location.test.ts | 72 - src/tests/core/superjson.test.ts | 90 - src/tests/main/dao/devices.test.ts | 94 - src/tests/main/dao/sources.test.ts | 107 - src/tests/main/dao/storage.test.ts | 40 - src/tests/main/dao/ties.test.ts | 94 - src/tests/main/drivers/extron/sis.test.ts | 73 - src/tests/main/drivers/shinybow/v2.test.ts | 73 - src/tests/main/drivers/shinybow/v3.test.ts | 73 - src/tests/main/drivers/sony/rs485.test.ts | 94 - .../main/drivers/tesla-smart/kvm.test.ts | 67 - .../main/drivers/tesla-smart/matrix.test.ts | 70 - .../main/drivers/tesla-smart/sdi.test.ts | 67 - src/tests/main/drivers/tesmart/kvm.test.ts | 67 - src/tests/main/drivers/tesmart/matrix.test.ts | 70 - src/tests/main/drivers/tesmart/sdi.test.ts | 67 - src/tests/main/services/app.test.ts | 18 - src/tests/main/services/boot.test.ts | 46 - src/tests/main/services/database.test.ts | 414 - src/tests/main/services/desktop.test.ts | 27 - src/tests/main/services/drivers.test.ts | 112 - src/tests/main/services/level.test.ts | 40 - src/tests/main/services/migration.test.ts | 68 - src/tests/main/services/ports.test.ts | 433 - .../main/services/protocols/sonyRs485.test.ts | 69 - src/tests/main/services/startup.test.ts | 196 - src/tests/main/services/system.test.ts | 96 - src/tests/main/services/user.test.ts | 21 - src/tests/main/utilities.test.ts | 23 - src/tests/seeds/boot.seed.ts | 29 - src/tests/seeds/database.seed.ts | 78 - src/tests/seeds/migration.seed.ts | 142 - src/tests/support/mock.ts | 85 - src/tests/support/serial.ts | 24 - src/tests/support/stream.ts | 126 - src/tests/support/vitestZod.ts | 34 - src/vite-env.d.ts | 3 - tsconfig.base.json | 17 - tsconfig.config.json | 9 - tsconfig.json | 8 - tsconfig.node.json | 9 - tsconfig.test.json | 10 - tsconfig.web.json | 7 - vite.config.ts | 58 - yarn.lock | 7237 ----------------- 546 files changed, 14854 insertions(+), 20903 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.cjs delete mode 100644 .gitattributes delete mode 100644 .github/workflows/coverage.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/typecheck.yml delete mode 100755 .husky/pre-commit create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/AndroidProjectSystem.xml create mode 100644 .idea/artifacts/composeApp_jvm.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/compiler.xml create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 .idea/deploymentTargetSelector.xml create mode 100644 .idea/deviceManager.xml create mode 100644 .idea/dictionaries/project.xml create mode 100644 .idea/gradle.xml rename src/images/logo.svg => .idea/icon.svg (100%) create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/kotlinc.xml create mode 100644 .idea/ktfmt.xml create mode 100644 .idea/ktlint-plugin.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/receipt.json create mode 100644 .idea/runConfigurations.xml create mode 100644 .idea/runConfigurations/_bridgecmdr__desktop_.xml create mode 100644 .idea/runConfigurations/bridgecmdr__android_.xml create mode 100644 .idea/runConfigurations/bridgecmdr__desktop_.xml create mode 100644 .idea/sqldialects.xml create mode 100644 .idea/vcs.xml create mode 100644 .java-version delete mode 100644 .ncurc.yaml delete mode 100644 .nvmrc delete mode 100644 .vscode/extensions.json delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json delete mode 100644 .yarnrc delete mode 100644 .yarnrc.yml create mode 100644 TODO.md create mode 100644 build.gradle.kts create mode 100644 composeApp/build.gradle.kts create mode 100644 composeApp/src/androidMain/AndroidManifest.xml create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainActivity.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/MainApplication.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.android.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.android.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.android.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.android.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/MobileSettingsView.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ScannerView.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileDashboardViewModel.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/MobileSettingsViewModel.kt create mode 100644 composeApp/src/androidMain/kotlin/org/sleepingcats/core/Platform.android.kt create mode 100644 composeApp/src/androidMain/res/drawable-v24/ic_app_shortcut_foreground.xml create mode 100644 composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 composeApp/src/androidMain/res/drawable/ic_launcher_background.xml create mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 composeApp/src/androidMain/res/values/strings.xml create mode 100644 composeApp/src/androidMain/resources/uk/uuid/slf4j/android/config.properties create mode 100644 composeApp/src/commonMain/composeResources/drawable/alert_circle.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/arrow_left.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/close.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/cog.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/cogs.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/controller_classic.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/delete.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/exit_run.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/export.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/flask.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/gamepad_square.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/image_plus.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/import.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/information.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/menu_down.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/minus.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/monitor.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/nintendo_game_boy.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/plus.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/power.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/power_socket.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/refresh_auto.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/server_network.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/server_outline.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/swap_vertical.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/theme_light_dark.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/video_switch.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/view_dashboard.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/volume_medium.xml create mode 100644 composeApp/src/commonMain/composeResources/values/actions.xml create mode 100644 composeApp/src/commonMain/composeResources/values/branding.xml create mode 100644 composeApp/src/commonMain/composeResources/values/devices.xml create mode 100644 composeApp/src/commonMain/composeResources/values/response.xml create mode 100644 composeApp/src/commonMain/composeResources/values/settings.xml create mode 100644 composeApp/src/commonMain/composeResources/values/strings.xml create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/App.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Branding.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/Greeting.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/Mode.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/ServerCode.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/NavController.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Preferences.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/String.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/module/RemoteServiceModule.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/Encryption.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/security/TokenService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DeviceService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DriverService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/LegacySettingsService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/PowerService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SerialPortService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/SourceService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/TieService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/UserImageService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/core/ConnectionService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Capabilities.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Device.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Driver.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/DriverKind.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/LegacySettings.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PathType.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/PortInfo.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Source.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/Tie.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/UserImage.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDeviceService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteDriverService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteLegacySettingsService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemotePowerService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSerialPortService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteSourceService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteTieService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/service/remote/RemoteUserImageService.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/AppTheme.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/IconSize.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/common/setting/PowerOffTaps.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/SourceCache.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/UserImageCache.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/core/DataCache.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/BackButton.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/HideOnScroll.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/ImagePreview.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/InputModal.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/LoadingOverlay.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MediaImage.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/MessageModals.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedNumericField.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/OutlinedSelectField.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/PopupModal.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SelectModal.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/SimpleTooltip.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Utilities.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/component/Validation.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/CommonModule.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/PlatformDashboardActions.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/SourceRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/CachingRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/core/DataRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/scaffold/FullscreenModalScaffold.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Color.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Theme.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/theme/Type.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DashboardView.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ApplicationViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DashboardViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/core/TrackingViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/ApplicationBranding.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/ErrorHandling.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/Memoize.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/Platform.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/Flow.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/extensions/List.kt create mode 100644 composeApp/src/commonMain/kotlin/org/sleepingcats/core/settings/PreferencesDataStore.kt create mode 100644 composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ApplicationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/sleepingcats/bridgecmdr/ComposeAppCommonTest.kt create mode 100644 composeApp/src/jvmMain/composeResources/drawable/app_icon.png create mode 100644 composeApp/src/jvmMain/composeResources/drawable/qrcode.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/backup.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/devices.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/drivers.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/errors.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/server.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/settings.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/sources.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/strings.xml create mode 100644 composeApp/src/jvmMain/composeResources/values/ties.xml create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Environment.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/Main.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Exporter.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/Importer.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/BackupParser.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version1.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version2.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version3.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version4.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/backup/model/Version5.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverBuilder.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverModule.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/core/DriverProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/extron/ExtronSisDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion2Driver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/shinybow/ShinybowVersion3Driver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/sony/SonyRs485Driver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartKvmDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartMatrixDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/teslasmart/TeslaSmartSdiDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartKvmDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartMatrixDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/driver/tesmart/TeSmartSmartSdiDriver.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteArray.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/ByteMatrix.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/FileKitResultLauncher.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/extension/Table.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/DatabaseModule.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/module/LocalServiceModule.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ExtronSisProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/AbstractCommandStream.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStream.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/CommandStreamOptions.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/DataBits.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/NetCommandStream.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/Parity.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolOptions.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/ProtocolStream.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/SerialCommandStream.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/stream/StopBits.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/AbstractDriverProtocol.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Address.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/AddressKind.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Command.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/Packet.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketError.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/PacketType.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/sony/SonyDriverError.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/DatabaseService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/DeviceEntity.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SettingEntity.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/SourceEntity.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/entity/TieEntity.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDeviceService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalDriverService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalLegacySettingsService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalPowerService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSerialPortService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalSourceService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalTieService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/local/LocalUserImageService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/Migration.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/MigrationService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123500CreateSqliteTables.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/migration/script/M20260105123530MigrateToSqlite.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/model/NewUserImage.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/DevicesTable.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SettingsTable.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/SourcesTable.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/TiesTable.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/service/table/UserImagesTable.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Application.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/Constants.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerController.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/ServerStatus.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/module/ServerModule.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/ErrorHandling.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/plugin/Validation.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Devices.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Drivers.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Images.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/LegacySettings.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Power.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/SerialPort.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Sources.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/route/Ties.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/ServerContext.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/server/security/UserPrincipal.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/MainWindow.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/Routes.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DeviceCache.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/DriverCache.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/PortCache.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/cache/TieCache.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/components/Pickers.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformContextModule.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/module/PlatformSpecificModule.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/platform/DashboardActions.jvm.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DeviceRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/DriverRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/PortRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/repository/TieRepository.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/ApplicationService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/SessionService.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/dbus/SessionManager.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/AutoStartFlow.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Builder.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/DesktopEntryFile.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Lines.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Utilities.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/xdg/entry/Value.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/BackupManagerView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/DeviceListView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditDeviceView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditSourceView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/EditTieView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/GeneralSettingsView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/ServerCodeView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SettingsView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/SourceListView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/TieListView.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/BackupManagerViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopApplicationViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DesktopDashboardViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/DeviceListViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditDeviceViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditSourceViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/EditTieViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/GeneralSettingsViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/ServerCodeViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SettingsViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/SourceListViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/TieListViewModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/ui/view/model/UserImageModel.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/core/ApplicationEnvironment.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/core/BaseDirectories.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/sleepingcats/core/Platform.jvm.kt create mode 100644 composeApp/src/jvmMain/resources/logback.xml delete mode 100644 electron-builder.yml delete mode 100644 electron.vite.config.ts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat delete mode 100644 package.json delete mode 100644 prettier.config.cjs create mode 100644 settings.gradle.kts delete mode 100644 src/core/attachments.ts delete mode 100644 src/core/base64.ts delete mode 100644 src/core/basics.ts delete mode 100644 src/core/delegates.ts delete mode 100644 src/core/error-handling.ts delete mode 100644 src/core/keys.ts delete mode 100644 src/core/location.ts delete mode 100644 src/core/rpc/ipc.ts delete mode 100644 src/core/rpc/transformer.ts delete mode 100644 src/core/url.ts delete mode 100644 src/main/boot/01-normalize-source-order.ts delete mode 100644 src/main/dao/devices.ts delete mode 100644 src/main/dao/sources.ts delete mode 100644 src/main/dao/storage.ts delete mode 100644 src/main/dao/ties.ts delete mode 100644 src/main/drivers/extron/sis.ts delete mode 100644 src/main/drivers/shinybow/v2.ts delete mode 100644 src/main/drivers/shinybow/v3.ts delete mode 100644 src/main/drivers/sony/rs485.ts delete mode 100644 src/main/drivers/tesla-smart/kvm.ts delete mode 100644 src/main/drivers/tesla-smart/matrix.ts delete mode 100644 src/main/drivers/tesla-smart/sdi.ts delete mode 100644 src/main/drivers/tesmart/kvm.ts delete mode 100644 src/main/drivers/tesmart/matrix.ts delete mode 100644 src/main/drivers/tesmart/sdi.ts delete mode 100644 src/main/index.ts delete mode 100644 src/main/main.ts delete mode 100644 src/main/migrations/20241118211400-rename-switches.ts delete mode 100644 src/main/migrations/20241119202100-rename-switchId.ts delete mode 100644 src/main/migrations/20241124121000-add-source-order.ts delete mode 100644 src/main/routes/data/devices.ts delete mode 100644 src/main/routes/data/sources.ts delete mode 100644 src/main/routes/data/storage.ts delete mode 100644 src/main/routes/data/ties.ts delete mode 100644 src/main/routes/drivers.ts delete mode 100644 src/main/routes/migration.ts delete mode 100644 src/main/routes/ports.ts delete mode 100644 src/main/routes/router.ts delete mode 100644 src/main/routes/startup.ts delete mode 100644 src/main/routes/system.ts delete mode 100644 src/main/routes/updater.ts delete mode 100644 src/main/services/app.ts delete mode 100644 src/main/services/boot.ts delete mode 100644 src/main/services/database.ts delete mode 100644 src/main/services/desktop.ts delete mode 100644 src/main/services/drivers.ts delete mode 100644 src/main/services/level.d.ts delete mode 100644 src/main/services/level.js delete mode 100644 src/main/services/locale.ts delete mode 100644 src/main/services/migration.ts delete mode 100644 src/main/services/ports.ts delete mode 100644 src/main/services/protocols/extronSis.ts delete mode 100644 src/main/services/protocols/protocols.ts delete mode 100644 src/main/services/protocols/shinybow.ts delete mode 100644 src/main/services/protocols/sonyBvm.ts delete mode 100644 src/main/services/protocols/sonyRs485.ts delete mode 100644 src/main/services/protocols/teslaElec.ts delete mode 100644 src/main/services/rpc/ipc.ts delete mode 100644 src/main/services/rpc/trpc.ts delete mode 100644 src/main/services/startup.ts delete mode 100644 src/main/services/stream.ts delete mode 100644 src/main/services/system.ts delete mode 100644 src/main/services/updater.ts delete mode 100644 src/main/services/user.ts delete mode 100644 src/main/utilities.ts delete mode 100644 src/preload/api.d.ts delete mode 100644 src/preload/index.ts delete mode 100644 src/renderer/BridgeCmdr.vue delete mode 100644 src/renderer/assets/main.scss delete mode 100644 src/renderer/components.d.ts delete mode 100644 src/renderer/components/DropMenu.vue delete mode 100644 src/renderer/components/Highlight.tsx delete mode 100644 src/renderer/components/InputDialog.vue delete mode 100644 src/renderer/components/NumberInput.vue delete mode 100644 src/renderer/components/OptionDialog.vue delete mode 100644 src/renderer/components/Page.vue delete mode 100644 src/renderer/components/ReplaceableImage.vue delete mode 100644 src/renderer/hooks/assets.ts delete mode 100644 src/renderer/hooks/element.ts delete mode 100644 src/renderer/hooks/errors.ts delete mode 100644 src/renderer/hooks/location.ts delete mode 100644 src/renderer/hooks/tracking.ts delete mode 100644 src/renderer/hooks/utilities.ts delete mode 100644 src/renderer/hooks/validation.ts delete mode 100644 src/renderer/hooks/vue.ts delete mode 100644 src/renderer/hooks/vuetify.ts delete mode 100644 src/renderer/index.html delete mode 100644 src/renderer/index.ts delete mode 100644 src/renderer/locales/en/datetimes.ts delete mode 100644 src/renderer/locales/en/functions.ts delete mode 100644 src/renderer/locales/en/messages.json delete mode 100644 src/renderer/locales/en/numbers.ts delete mode 100644 src/renderer/locales/locales.ts delete mode 100644 src/renderer/main.ts delete mode 100644 src/renderer/modals/AlertModal.vue delete mode 100644 src/renderer/modals/ConfirmModal.vue delete mode 100644 src/renderer/modals/DeviceDialog.vue delete mode 100644 src/renderer/modals/SourceDialog.vue delete mode 100644 src/renderer/modals/TieDialog.vue delete mode 100644 src/renderer/modals/dialogs.ts delete mode 100644 src/renderer/pages/DeviceList.vue delete mode 100644 src/renderer/pages/FirstRunLogic.vue delete mode 100644 src/renderer/pages/GeneralPage.vue delete mode 100644 src/renderer/pages/MainDashboard.vue delete mode 100644 src/renderer/pages/SettingsBackupPage.vue delete mode 100644 src/renderer/pages/SettingsPage.vue delete mode 100644 src/renderer/pages/SourceList.vue delete mode 100644 src/renderer/pages/SourcePage.vue delete mode 100644 src/renderer/plugins/i18n.ts delete mode 100644 src/renderer/plugins/router.ts delete mode 100644 src/renderer/plugins/vuetify.ts delete mode 100644 src/renderer/services/appUpdates.ts delete mode 100644 src/renderer/services/backup/export.ts delete mode 100644 src/renderer/services/backup/formats/version0.ts delete mode 100644 src/renderer/services/backup/formats/version1.ts delete mode 100644 src/renderer/services/backup/formats/version2.ts delete mode 100644 src/renderer/services/backup/formats/version3.ts delete mode 100644 src/renderer/services/backup/import.ts delete mode 100644 src/renderer/services/dashboard.ts delete mode 100644 src/renderer/services/data/devices.ts delete mode 100644 src/renderer/services/data/sources.ts delete mode 100644 src/renderer/services/data/storage.ts delete mode 100644 src/renderer/services/data/ties.ts delete mode 100644 src/renderer/services/driver.ts delete mode 100644 src/renderer/services/migration.ts delete mode 100644 src/renderer/services/ports.ts delete mode 100644 src/renderer/services/rpc/link.ts delete mode 100644 src/renderer/services/rpc/trpc.ts delete mode 100644 src/renderer/services/rpc/utilities.ts delete mode 100644 src/renderer/services/settings.ts delete mode 100644 src/renderer/services/startup.ts delete mode 100644 src/renderer/services/store.ts delete mode 100644 src/renderer/support/events.ts delete mode 100644 src/renderer/support/files.ts delete mode 100644 src/renderer/support/i18n.ts delete mode 100644 src/renderer/support/locale.ts delete mode 100644 src/tests/core/attachments.test.ts delete mode 100644 src/tests/core/basics.test.ts delete mode 100644 src/tests/core/error-handling.test.ts delete mode 100644 src/tests/core/location.test.ts delete mode 100644 src/tests/core/superjson.test.ts delete mode 100644 src/tests/main/dao/devices.test.ts delete mode 100644 src/tests/main/dao/sources.test.ts delete mode 100644 src/tests/main/dao/storage.test.ts delete mode 100644 src/tests/main/dao/ties.test.ts delete mode 100644 src/tests/main/drivers/extron/sis.test.ts delete mode 100644 src/tests/main/drivers/shinybow/v2.test.ts delete mode 100644 src/tests/main/drivers/shinybow/v3.test.ts delete mode 100644 src/tests/main/drivers/sony/rs485.test.ts delete mode 100644 src/tests/main/drivers/tesla-smart/kvm.test.ts delete mode 100644 src/tests/main/drivers/tesla-smart/matrix.test.ts delete mode 100644 src/tests/main/drivers/tesla-smart/sdi.test.ts delete mode 100644 src/tests/main/drivers/tesmart/kvm.test.ts delete mode 100644 src/tests/main/drivers/tesmart/matrix.test.ts delete mode 100644 src/tests/main/drivers/tesmart/sdi.test.ts delete mode 100644 src/tests/main/services/app.test.ts delete mode 100644 src/tests/main/services/boot.test.ts delete mode 100644 src/tests/main/services/database.test.ts delete mode 100644 src/tests/main/services/desktop.test.ts delete mode 100644 src/tests/main/services/drivers.test.ts delete mode 100644 src/tests/main/services/level.test.ts delete mode 100644 src/tests/main/services/migration.test.ts delete mode 100644 src/tests/main/services/ports.test.ts delete mode 100644 src/tests/main/services/protocols/sonyRs485.test.ts delete mode 100644 src/tests/main/services/startup.test.ts delete mode 100644 src/tests/main/services/system.test.ts delete mode 100644 src/tests/main/services/user.test.ts delete mode 100644 src/tests/main/utilities.test.ts delete mode 100644 src/tests/seeds/boot.seed.ts delete mode 100644 src/tests/seeds/database.seed.ts delete mode 100644 src/tests/seeds/migration.seed.ts delete mode 100644 src/tests/support/mock.ts delete mode 100644 src/tests/support/serial.ts delete mode 100644 src/tests/support/stream.ts delete mode 100644 src/tests/support/vitestZod.ts delete mode 100644 src/vite-env.d.ts delete mode 100644 tsconfig.base.json delete mode 100644 tsconfig.config.json delete mode 100644 tsconfig.json delete mode 100644 tsconfig.node.json delete mode 100644 tsconfig.test.json delete mode 100644 tsconfig.web.json delete mode 100644 vite.config.ts delete mode 100644 yarn.lock 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..cb9ff2a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ 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__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/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..72b1ed0 --- /dev/null +++ b/TODO.md @@ -0,0 +1,20 @@ +- [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 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..0f1386f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + // this is necessary to avoid the plugins to be loaded multiple times + // in each subproject's classloader + 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.jvm) 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..5a3d1f9 --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,187 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + 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) + } + } +} + +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 + } + } +} 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..fde602d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt @@ -0,0 +1,219 @@ +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.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 0000000000000000000000000000000000000000..79a3a184ae02963ebf2ecede3e69d6d183135ff2 GIT binary patch literal 5474 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 Y0KrEaES9L=F#rGn07*qoM6N<$f+6{^cmMzZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..295ab6d2716deac9faf215308958b745192450b0 GIT binary patch literal 7266 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..073e227ceee2ff4c915a24c0dfe17af78db305ca GIT binary patch literal 3268 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 bK~w_(nvw(PAM`)D00000NkvXXu0mjfl9rxy literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..50319ad0b37431b52f64628d84872fffeaad0c01 GIT binary patch literal 13028 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
X1X`i*=TqIaCxi(NJ$$TR;#Azlh~`{(u)ViED3 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..03cffad3e1d1ea4882fb4dd152daae36107ddb27 GIT binary patch literal 6185 zcmc&&c|4Ts+kYr2Yf33gQXP?lRI*OiBZ_RH2$f~jU_wj`!#R~*IzLJ$Ygwb=5JtwB zP8maXVGJ2UwiwyQGTZyi6rFlMzxVgP=b!hF=kqN0bzS#$UEk}u?)(16Se-j9EFdWW z0D!QWsj&?J@Pd!Lz}79`ZShvu8hG1w!}MYR0EoQdTs%a0fjjtd@6BH>+_Z&z-b7sS z_W%$GgvvEvSb*D=8y+ffe`qRIR}uguD$R@y&fiL#89@H|U@2JjGxQ}XvSrI=oKvgR6%Q@&C_-U>k`jhF0vQN7CT9|d-(vnQ5 zPkIz$)*exC$lqFD%s@QVf&C9|zoh^`JG)Q}^y$*6Yank}vF;G)` zL$g7AT>*UfBHPIW`o(#(Qonux02)mAK?5L4`i6PmxwopeA24lztgCS)KzUPBj{Gsu zkuTwheHSVxaCgv=F9C4HzYCQcxLvk97_2up1WrI)g$=j9MEq}@`h%0Df1=1q{gcAk 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 H%fI~>6_pX@ literal 0 HcmV?d00001 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..6b290f6 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt @@ -0,0 +1,51 @@ +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 = + when (version) { + 2 -> 0..99 + 3 -> 0..999 + else -> throw IllegalArgumentException("Unsupported Shinybow protocol version: $version") + } + + private val toArg: (input: Int) -> String = + 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, + ) { + check(input in argRange) { "Input out of range ($argRange): $input" } + check(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..1c88af3 --- /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, + ) { + check(input in 1..255) { "Input out of range (1..255): $input" } + logger.info { "$name/channel($input) -> $uri" } + sendCommand(uri, Command.SetChannel, 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..8b0cba4 --- /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, + ) { + check(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..0e85f18 --- /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, + ) { + check(input in 1..99) { "Input out of range (1..99): $input" } + check(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..62cf019 --- /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, + ) { + check(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(), 0xee.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..2995e6a --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/support/AbstractDriverProtocol.kt @@ -0,0 +1,64 @@ +package org.sleepingcats.bridgecmdr.common.protocol.support + +import io.github.oshai.kotlinlogging.KLogger +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +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 { + 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 { 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 { 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 { 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..6d5c668 --- /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?, + arg1: Int?, + ): 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..e9470f3 --- /dev/null +++ b/composeApp/src/jvmMain/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + 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..4c63d47 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,103 @@ +[versions] +agp = "8.11.2" +android-compileSdk = "36" +android-minSdk = "26" +android-targetSdk = "36" +androidx-activity = "1.12.0" +androidx-datastore = "1.2.0" +androidx-lifecycle = "2.9.6" +androidx-navigation = "2.9.1" +composeHotReload = "1.0.0" +composeMultiplatform = "1.9.3" +junit = "4.13.2" +kotlin = "2.2.21" +kotlin-serializaiton = "1.9.0" +kotlinx-coroutines = "1.10.2" +ktor = "3.3.3" +koin = "4.1.1" +logback = "1.5.21" +exposed = "1.0.0-rc-3" +sqlite = "3.49.1.0" +konform = "0.11.0" +atlassian-onetime = "2.1.1" +jserialcomm = "2.11.4" +oshai-logging = "7.0.3" +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" + +[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-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" } + +[plugins] +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 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + 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== From 65c9c01607848a820aae5eb87f8355f78f85f94f Mon Sep 17 00:00:00 2001 From: Matthew Holder Date: Thu, 15 Jan 2026 19:09:24 -0600 Subject: [PATCH 2/3] Created tests for the driver protocols and fixed several issues with the protocols --- .idea/modules.xml | 1 + .../bridgecmdr__coverage_.xml | 27 +++ .../runConfigurations/bridgecmdr__tests_.xml | 27 +++ build.gradle.kts | 5 +- composeApp/build.gradle.kts | 10 + .../common/protocol/ShinybowProtocol.kt | 14 +- .../common/protocol/SonyProtocol.kt | 4 +- .../common/protocol/TeslaElecKvmProtocol.kt | 2 +- .../protocol/TeslaElecMatrixProtocol.kt | 6 +- .../common/protocol/TeslaElecSdiProtocol.kt | 4 +- .../common/protocol/support/sony/Command.kt | 4 +- composeApp/src/jvmMain/resources/logback.xml | 1 + .../org/sleepingcats/bridgecmdr/test/.empty | 0 .../test/driver/AbstractProtocolTest.kt | 110 +++++++++++ .../test/driver/ExtronSisProtocolTest.kt | 58 ++++++ .../test/driver/ShinybowProtocolTest.kt | 187 ++++++++++++++++++ .../driver/sony/InternalSonyProtocolTest.kt | 101 ++++++++++ .../test/driver/sony/SonyProtocolTest.kt | 116 +++++++++++ .../teslaelect/TeslaElecKvmProtocolTest.kt | 67 +++++++ .../teslaelect/TeslaElecMatrixProtocolTest.kt | 75 +++++++ .../teslaelect/TeslaElecSdiProtocolTest.kt | 67 +++++++ gradle/libs.versions.toml | 21 +- 22 files changed, 880 insertions(+), 27 deletions(-) create mode 100644 .idea/runConfigurations/bridgecmdr__coverage_.xml create mode 100644 .idea/runConfigurations/bridgecmdr__tests_.xml create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/.empty create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/AbstractProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ExtronSisProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/ShinybowProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/InternalSonyProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/sony/SonyProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecKvmProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecMatrixProtocolTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/org/sleepingcats/bridgecmdr/test/driver/teslaelect/TeslaElecSdiProtocolTest.kt diff --git a/.idea/modules.xml b/.idea/modules.xml index cb9ff2a..e073e13 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,6 +3,7 @@ + \ 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__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/build.gradle.kts b/build.gradle.kts index 0f1386f..84600eb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,14 +1,13 @@ 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.jvm) 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 index 5a3d1f9..0f4d7d4 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -3,6 +3,7 @@ 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) @@ -95,10 +96,16 @@ kotlin { 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 = @@ -185,3 +192,6 @@ compose.desktop { } } } + +kover { +} 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 index 6b290f6..79e7340 100644 --- a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/ShinybowProtocol.kt @@ -8,19 +8,21 @@ class ShinybowProtocol( ) : AbstractDriverProtocol("shinybow/v$version.0") { private val stream = ProtocolStream.create(name) - private val argRange = + private val argRange by lazy { when (version) { - 2 -> 0..99 - 3 -> 0..999 + 2 -> 1..99 + 3 -> 1..999 else -> throw IllegalArgumentException("Unsupported Shinybow protocol version: $version") } + } - private val toArg: (input: Int) -> String = + 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, @@ -43,8 +45,8 @@ class ShinybowProtocol( videoOutput: Int, audioOutput: Int, ) { - check(input in argRange) { "Input out of range ($argRange): $input" } - check(videoOutput in argRange) { "Input out of range ($argRange): $input" } + 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 index 1c88af3..9ef1fa0 100644 --- a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/SonyProtocol.kt @@ -52,8 +52,8 @@ class SonyProtocol : AbstractDriverProtocol("sony/bvm") { videoOutput: Int, audioOutput: Int, ) { - check(input in 1..255) { "Input out of range (1..255): $input" } + require(input in 1..255) { "Input out of range (1..255): $input" } logger.info { "$name/channel($input) -> $uri" } - sendCommand(uri, Command.SetChannel, input) + 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 index 8b0cba4..4d8208e 100644 --- a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecKvmProtocol.kt @@ -17,7 +17,7 @@ class TeslaElecKvmProtocol : AbstractDriverProtocol("teslaElec/kvm") { videoOutput: Int, audioOutput: Int, ) { - check(input in 1..255) { "Input out of range (1..255): $input" } + 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 index 0e85f18..7a3a211 100644 --- a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecMatrixProtocol.kt @@ -19,9 +19,9 @@ class TeslaElecMatrixProtocol : AbstractDriverProtocol("teslaElec/matrix") { videoOutput: Int, audioOutput: Int, ) { - check(input in 1..99) { "Input out of range (1..99): $input" } - check(videoOutput in 1..99) { "Input out of range (1..99): $input" } + 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") + 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 index 62cf019..793abab 100644 --- a/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt +++ b/composeApp/src/jvmMain/kotlin/org/sleepingcats/bridgecmdr/common/protocol/TeslaElecSdiProtocol.kt @@ -17,8 +17,8 @@ class TeslaElecSdiProtocol : AbstractDriverProtocol("teslaElec/sdi") { videoOutput: Int, audioOutput: Int, ) { - check(input in 1..255) { "Input out of range (1..255): $input" } + 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(), 0xee.toByte())) + sendCommand(uri, byteArrayOf(0xaa.toByte(), 0xcc.toByte(), 0x01, input.toByte())) } } 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 index 6d5c668..dde61cb 100644 --- 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 @@ -14,8 +14,8 @@ enum class Command( destination: Address, source: Address, command: Command, - arg0: Int?, - arg1: Int?, + 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" } diff --git a/composeApp/src/jvmMain/resources/logback.xml b/composeApp/src/jvmMain/resources/logback.xml index e9470f3..6ef148d 100644 --- a/composeApp/src/jvmMain/resources/logback.xml +++ b/composeApp/src/jvmMain/resources/logback.xml @@ -11,5 +11,6 @@ + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c63d47..78c9ffc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,26 +3,27 @@ agp = "8.11.2" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36" -androidx-activity = "1.12.0" +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" -junit = "4.13.2" 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.21" -exposed = "1.0.0-rc-3" +logback = "1.5.24" +exposed = "1.0.0-rc-4" sqlite = "3.49.1.0" -konform = "0.11.0" -atlassian-onetime = "2.1.1" +konform = "0.11.1" +atlassian-onetime = "2.2.0" jserialcomm = "2.11.4" -oshai-logging = "7.0.3" -dbus-java = "5.1.1" +oshai-logging = "7.0.14" +dbus-java = "5.2.0" reorderable = "3.0.0" zxing = "3.5.4" filekit = "0.12.0" @@ -30,6 +31,7 @@ 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" } @@ -44,6 +46,7 @@ androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.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" } @@ -90,8 +93,10 @@ 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" } From 11d22eb40a183395ef572e24d810bd83a049e1b3 Mon Sep 17 00:00:00 2001 From: Matthew Holder Date: Thu, 15 Jan 2026 19:56:29 -0600 Subject: [PATCH 3/3] Ensure that source communication doesn't cause 500 errors, reverted DBus library --- TODO.md | 1 + .../bridgecmdr/ui/service/MobileConnectionService.kt | 1 + .../protocol/support/AbstractDriverProtocol.kt | 12 +++++++++--- gradle/libs.versions.toml | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 72b1ed0..1a29e0c 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ - [ ] 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/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt index fde602d..98532e5 100644 --- a/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt +++ b/composeApp/src/androidMain/kotlin/org/sleepingcats/bridgecmdr/ui/service/MobileConnectionService.kt @@ -62,6 +62,7 @@ class MobileConnectionService( HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, // Server may be overloaded or having issues. + HttpStatusCode.InternalServerError, HttpStatusCode.RequestTimeout, HttpStatusCode.BadGateway, HttpStatusCode.ServiceUnavailable, 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 index 2995e6a..7023402 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -12,6 +13,11 @@ 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) { @@ -34,7 +40,7 @@ abstract class AbstractDriverProtocol( final override suspend fun powerOn(uri: String) = coroutineScope { async { - runCatching { sendPowerOn(uri) } + runCatching { withTimeout(TIMEOUT) { sendPowerOn(uri) } } .onFailure { logger.error(it) { "$name/powerOff: failed to send power on" } } .getOrDefault(Unit) } @@ -43,7 +49,7 @@ abstract class AbstractDriverProtocol( final override suspend fun powerOff(uri: String) = coroutineScope { async { - runCatching { sendPowerOff(uri) } + runCatching { withTimeout(TIMEOUT) { sendPowerOff(uri) } } .onFailure { logger.error(it) { "$name/powerOff: failed to send power off" } } .getOrDefault(Unit) } @@ -56,7 +62,7 @@ abstract class AbstractDriverProtocol( audioOutput: Int, ) = coroutineScope { async { - runCatching { sendActivate(uri, input, videoOutput, audioOutput) } + runCatching { withTimeout(TIMEOUT) { sendActivate(uri, input, videoOutput, audioOutput) } } .onFailure { logger.error(it) { "$name/activate: failed to send activate" } } .getOrDefault(Unit) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78c9ffc..4187750 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ konform = "0.11.1" atlassian-onetime = "2.2.0" jserialcomm = "2.11.4" oshai-logging = "7.0.14" -dbus-java = "5.2.0" +dbus-java = "5.1.1" reorderable = "3.0.0" zxing = "3.5.4" filekit = "0.12.0"