diff --git a/.github/workflows/build-development.yml b/.github/workflows/build-development.yml index 3d7a63d7..9831fdaa 100644 --- a/.github/workflows/build-development.yml +++ b/.github/workflows/build-development.yml @@ -69,7 +69,7 @@ jobs: tag_name: oyasumi-vDEV-new env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: tauri-apps/tauri-action@v0 + - uses: Raphiiko/tauri-action@dev env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} @@ -80,6 +80,8 @@ jobs: releaseBody: ${{ steps.changelog_reader.outputs.changes }} releaseDraft: false prerelease: true + includeDebug: true + includeRelease: false - name: Get current development release ID if it exists uses: actions/github-script@v6 continue-on-error: true diff --git a/CHANGELOG.md b/CHANGELOG.md index d92c1c56..d89c61fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.0] + +### Added + +- [EXPERIMENTAL] Sleep detection based on the movement of the user's VR headset. +- Automation for disabling sleep mode when SteamVR has been stopped. +- Configuration options for setting the OSC hosts and ports Oyasumi interacts with. +- Support for XSOverlay and Desktop notifications. +- Notifications for when sleep mode is enabled and disabled. + +### Changed + +- The elevated sidecar will be launched on start if the main application is launched with administrator privileges. +- Layout of Sleep Detection pane has been updated to match recent views. +- Configuration options for sleep detection automations can now be edited while the automations are inactive. +- Removing friends from the player list for automatic invite request accepts, now requires confirmation. + +### Fixed + +- Various improvements to the Japanese translations (by [なき](https://twitter.com/NoYu_idea)) +- Various small bugs + ## [1.4.1] ### Added @@ -40,13 +62,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Update check to run every week after application start, in case Oyasumi is left running for a long time. -- Update check to rerun every 10 minutes until at least one check has succeeded, in case Oyasumi is started while offline. +- Update check to rerun every 10 minutes until at least one check has succeeded, in case Oyasumi is started while + offline. ## [1.3.0] ### Added -- New feature for automatically accepting invite requests while on orange/green status, optionally based on a white- or blacklist. +- New feature for automatically accepting invite requests while on orange/green status, optionally based on a white- or + blacklist. - Korean language support, thanks to [@soumt-r](https://github.com/soumt-r). ### Changed @@ -58,7 +82,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed issue where the main window would load before the app was ready, due to a bug in a new version of the `tao` crate. +- Fixed issue where the main window would load before the app was ready, due to a bug in a new version of the `tao` + crate. ## [1.2.1] @@ -93,11 +118,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Sleeping animation automation preset for [GoGo Loco v1.7.1 by franada](https://booth.pm/en/items/3290806). -- Sleeping animation automation (workaround) preset for [GoGo Loco v1.6.2 - v1.7.0 by franada](https://booth.pm/en/items/3290806). +- Sleeping animation automation (workaround) preset + for [GoGo Loco v1.6.2 - v1.7.0 by franada](https://booth.pm/en/items/3290806). ### Changed -- Marked the preset for [ごろ寝システム (Sleep System) by みんみんみーん](https://booth.pm/ko/items/2886739) to also support v2.3 and the new EX version. +- Marked the preset for [ごろ寝システム (Sleep System) by みんみんみーん](https://booth.pm/ko/items/2886739) to also support v2.3 and + the new EX version. - Changed presets to support multiple info links (to show both んみんみーん's EX and non-EX version) ### Fixed @@ -108,7 +135,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Sleeping animation automations for automatically changing the sleeping animation of your avatar based on your sleeping position. +- Sleeping animation automations for automatically changing the sleeping animation of your avatar based on your sleeping + position. - Preset for [ごろ寝システム (Sleep System) v2.2 by みんみんみーん](https://booth.pm/ko/items/2886739). - Setting to start Oyasumi with administrator privileges by default - Editor for writing OSC scripts @@ -121,13 +149,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Navigation item for GPU Automations to show an error icon when the feature is enabled, but no administrator privileges were detected. +- Navigation item for GPU Automations to show an error icon when the feature is enabled, but no administrator privileges + were detected. - Switched to Fontsource for the application font, to remove the dependency on Google for providing fonts at runtime. - Added own updater UI to replace the default Tauri update dialog. ### Fixed -- The main window can now be interacted with through the SteamVR overlay or other overlays like XSOverlay, when given administrator privileges. +- The main window can now be interacted with through the SteamVR overlay or other overlays like XSOverlay, when given + administrator privileges. - Fixed turning off devices sometimes triggering the "disabling sleep mode when a device is turned on" automation. - Fixed Oyasumi freezing when SteamVR is stopped while it is still running. @@ -136,7 +166,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Global sleep mode to more clearly separate triggers and actions for easier future expansion. -- Version migrations for app settings and automation configurations, to aid preservation of configuration during future updates. +- Version migrations for app settings and automation configurations, to aid preservation of configuration during future + updates. - GPU Automations for automatically adjusting the power limits of NVIDIA GPUs ### Changed diff --git a/README.md b/README.md index 0c6d1774..76640ef4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@
:zzz: A collection of utilities to assist with sleeping in virtual reality. :zzz:

+

Latest Version Production Build Status @@ -28,10 +29,6 @@ If you want to come chat, join our Discord Server!

-

- :exclamation:Looking for Japanese translators / 日本語翻訳者募集中:exclamation: -

- ## Getting started Grab the latest installer over on the [Releases](https://github.com/Raphiiko/Oyasumi/releases) page. @@ -42,14 +39,14 @@ Grab the latest installer over on the [Releases](https://github.com/Raphiiko/Oya

Sleeping Animations

GPU Power Limiting

MSI Afterburner Automations

-

Sleep Detection

+

Sleep Automations

Device Overview

-

-

-

-

+

+

+

+

@@ -57,20 +54,19 @@ Grab the latest installer over on the [Releases](https://github.com/Raphiiko/Oya

General Settings

Auto Accept Invite Requests

Battery Automations

-

About

+

Sleep Detection

-

-

-

-

-

+

+

+

+

+

- ## Features @@ -102,19 +98,21 @@ Grab the latest installer over on the [Releases](https://github.com/Raphiiko/Oya - :email: Automatically accept invite requests - Automatically let friends in while you are asleep! - Configure whose invite requests are accepted using a black- or whitelist. -- :zzz: Manage automations with a sleep mode in various ways: +- :zzz: Automatically enable and disable sleep mode for triggering automations - Detect falling asleep: + - When Oyasumi guesses you are asleep based on your movement - When a controller or tracker battery percentage falls below a threshold - When turning off your controllers - On a time schedule - Detect waking up: - When turning on a controller or tracker - On a time schedule + - When SteamVR is stopped - :calling: [Premade expression menu](https://github.com/Raphiiko/Oyasumi/wiki/Oyasumi-Expression-Menu) for controlling some features right from within VRChat - 🗺️ Multi language support - English - Dutch (Nederlands) - - Japanese (日本語) + - Japanese (日本語) (Outsourced + Community contributions by [なき](https://twitter.com/NoYu_idea)) - Korean\* (한국어) (Community contribution by [Soumt](https://github.com/soumt-r)) - Traditional Chinese\* (繁體中文) (Community contribution by [狐 Kon](https://github.com/XoF-eLtTiL)) - Simplified Chinese\* (简体中文) (Community contribution by [狐 Kon](https://github.com/XoF-eLtTiL)) diff --git a/package.json b/package.json index 30833f3d..a0c6866b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oyasumi", - "version": "1.4.1", + "version": "1.5.0", "author": "Raphiiko", "license": "MIT", "type": "module", diff --git a/src-elevated-sidecar/Cargo.lock b/src-elevated-sidecar/Cargo.lock index 13aeb6ff..5103f4b5 100644 --- a/src-elevated-sidecar/Cargo.lock +++ b/src-elevated-sidecar/Cargo.lock @@ -678,7 +678,7 @@ dependencies = [ [[package]] name = "oyasumi-elevated-sidecar" -version = "1.4.0" +version = "1.5.0" dependencies = [ "codesigned", "directories", @@ -693,16 +693,16 @@ dependencies = [ "simplelog", "sysinfo", "tokio", - "winapi", - "windows-sys 0.36.1", ] [[package]] name = "oyasumi-shared" -version = "1.4.0" +version = "1.5.0" dependencies = [ "openvr", "serde", + "winapi", + "windows-sys 0.36.1", ] [[package]] diff --git a/src-elevated-sidecar/Cargo.toml b/src-elevated-sidecar/Cargo.toml index e72fe10d..05141d8c 100644 --- a/src-elevated-sidecar/Cargo.toml +++ b/src-elevated-sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oyasumi-elevated-sidecar" -version = "1.4.1" +version = "1.5.0" authors = ["Raphiiko"] license = "MIT" edition = "2021" @@ -27,11 +27,5 @@ features = ["full"] version = "0.14.20" features = ["full"] -[dependencies.windows-sys] -version = "0.36.1" -features = ["Win32_UI_Shell", "Win32_Foundation"] -[dependencies.winapi] -version = "0.3.9" -features = ["handleapi", "processthreadsapi", "winnt", "securitybaseapi", "impl-default"] diff --git a/src-elevated-sidecar/src/main.rs b/src-elevated-sidecar/src/main.rs index 827584e3..33f88ee5 100644 --- a/src-elevated-sidecar/src/main.rs +++ b/src-elevated-sidecar/src/main.rs @@ -1,9 +1,10 @@ #![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" +all(not(debug_assertions), target_os = "windows"), +windows_subsystem = "windows" )] #[macro_use(lazy_static)] extern crate lazy_static; + use hyper::service::{make_service_fn, service_fn}; use hyper::Server; use log::{error, info}; @@ -11,23 +12,23 @@ use oyasumi_shared::models::ElevatedSidecarInitRequest; use std::convert::Infallible; use std::env; use std::fs::{ - // OpenOptions, + // OpenOptions, File }; use std::net::SocketAddr; use std::path::Path; use std::time::Duration; use sysinfo::{Pid, PidExt, System, SystemExt}; -use windows::{is_elevated, relaunch_with_elevation}; -mod afterburner; -mod http_handler; -mod nvml; -mod windows; use directories::BaseDirs; +use oyasumi_shared::windows::{is_elevated, relaunch_with_elevation}; use simplelog::{ ColorChoice, CombinedLogger, Config, LevelFilter, TermLogger, TerminalMode, WriteLogger, }; +mod afterburner; +mod http_handler; +mod nvml; + #[tokio::main] async fn main() { // Initialize logging @@ -56,7 +57,7 @@ async fn main() { // .unwrap(), ), ]) - .unwrap(); + .unwrap(); // Get port of host http server from 1st argument let args: Vec = env::args().collect(); if args.len() < 3 { @@ -98,7 +99,7 @@ async fn main() { sidecar_port: server.local_addr().port(), sidecar_pid: std::process::id(), }) - .unwrap(), + .unwrap(), ) .send() .await; diff --git a/src-shared/Cargo.lock b/src-shared/Cargo.lock index 22af33c2..d8a3789f 100644 --- a/src-shared/Cargo.lock +++ b/src-shared/Cargo.lock @@ -43,10 +43,12 @@ dependencies = [ [[package]] name = "oyasumi-shared" -version = "1.4.0" +version = "1.5.0" dependencies = [ "openvr", "serde", + "winapi", + "windows-sys", ] [[package]] @@ -103,3 +105,68 @@ name = "unicode-ident" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" diff --git a/src-shared/Cargo.toml b/src-shared/Cargo.toml index 267ecbe3..fec3eb2e 100644 --- a/src-shared/Cargo.toml +++ b/src-shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oyasumi-shared" -version = "1.4.1" +version = "1.5.0" authors = ["Raphiiko"] edition = "2021" license = "MIT" @@ -13,3 +13,10 @@ serde = { version = "1.0", features = ["derive"] } git = "https://github.com/Raphiiko/oyasumi-rust-openvr" rev = "c2f95d27a6f911cf360420b2279f0d1c9f2af52e" +[dependencies.windows-sys] +version = "0.36.1" +features = ["Win32_UI_Shell", "Win32_Foundation"] + +[dependencies.winapi] +version = "0.3.9" +features = ["handleapi", "processthreadsapi", "winnt", "securitybaseapi", "impl-default"] \ No newline at end of file diff --git a/src-shared/src/lib.rs b/src-shared/src/lib.rs index ff929469..5bbdfe2a 100644 --- a/src-shared/src/lib.rs +++ b/src-shared/src/lib.rs @@ -1 +1,2 @@ -pub mod models; \ No newline at end of file +pub mod models; +pub mod windows; \ No newline at end of file diff --git a/src-shared/src/models.rs b/src-shared/src/models.rs index 7fa27b73..1a015657 100644 --- a/src-shared/src/models.rs +++ b/src-shared/src/models.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use openvr::TrackedDeviceClass; +use serde::{Deserialize, Serialize}; // SIDECAR COMMUNICATION @@ -93,3 +93,27 @@ pub struct OVRDevicePose { pub struct DeviceUpdateEvent { pub device: OVRDevice, } + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SleepDetectorStateReport { + pub distance_in_last_15_minutes: f64, + pub distance_in_last_10_minutes: f64, + pub distance_in_last_5_minutes: f64, + pub distance_in_last_1_minute: f64, + pub distance_in_last_10_seconds: f64, + pub rotation_in_last_15_minutes: f64, + pub rotation_in_last_10_minutes: f64, + pub rotation_in_last_5_minutes: f64, + pub rotation_in_last_1_minute: f64, + pub rotation_in_last_10_seconds: f64, + pub start_time: u128, + pub last_log: u128, +} + + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GestureDetected { + pub gesture: String, +} \ No newline at end of file diff --git a/src-elevated-sidecar/src/windows.rs b/src-shared/src/windows.rs similarity index 100% rename from src-elevated-sidecar/src/windows.rs rename to src-shared/src/windows.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1411c8c5..bc344e28 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,15 @@ version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "async-broadcast" version = "0.4.1" @@ -280,9 +289,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "bytes" -version = "1.2.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8a7b6a70fde80372154c65702f00a0f56f3e1c36abbc6c440484be248856db" +checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be" [[package]] name = "cairo-rs" @@ -715,6 +724,17 @@ dependencies = [ "syn", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + [[package]] name = "deflate" version = "0.7.20" @@ -1466,9 +1486,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" -version = "0.14.23" +version = "0.14.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" +checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c" dependencies = [ "bytes", "futures-channel", @@ -1724,6 +1744,15 @@ version = "0.2.137" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89" +[[package]] +name = "libdbus-sys" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8d7ae751e1cb825c840ae5e682f59b098cdfd213c350ac268b61449a5f58a0" +dependencies = [ + "pkg-config", +] + [[package]] name = "line-wrap" version = "0.1.1" @@ -1783,6 +1812,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e72d50edb17756489e79d52eb146927bec8eba9dd48faadf9ef08bca3791ad5" +dependencies = [ + "cc", + "dirs-next", + "objc-foundation", + "objc_id", + "time 0.3.17", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1821,6 +1863,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "matrixmultiply" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add85d4dd35074e6fedc608f8c8f513a3548619a9024b751949ef0e8e45a4d84" +dependencies = [ + "rawpointer", +] + [[package]] name = "md5" version = "0.7.0" @@ -1900,6 +1951,33 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "nalgebra" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6515c882ebfddccaa73ead7320ca28036c4bc84c9bcca3cc0cbba8efe89223a" +dependencies = [ + "approx", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232c68884c0c99810a5a4d333ef7e47689cfd0edc85efc9e54e1e6bf5212766" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "native-tls" version = "0.2.11" @@ -1982,6 +2060,18 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfa211d18e360f08e36c364308f394b5eb23a6629150690e109a916dc6f610e" +dependencies = [ + "dbus", + "log", + "mac-notification-sys", + "tauri-winrt-notification", +] + [[package]] name = "ntapi" version = "0.4.0" @@ -2001,6 +2091,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -2219,8 +2318,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "oyasumi" -version = "1.4.0" +version = "1.5.0" dependencies = [ + "bytes", "chrono", "cronjob", "futures", @@ -2230,6 +2330,7 @@ dependencies = [ "md5", "mime", "mime_guess", + "nalgebra", "openvr", "openvr_sys", "oyasumi-shared", @@ -2237,6 +2338,7 @@ dependencies = [ "rosc", "serde", "serde_json", + "soloud", "substring", "sysinfo", "tauri", @@ -2255,10 +2357,12 @@ dependencies = [ [[package]] name = "oyasumi-shared" -version = "1.4.0" +version = "1.5.0" dependencies = [ "openvr", "serde", + "winapi", + "windows-sys 0.36.1", ] [[package]] @@ -2573,6 +2677,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11bafc859c6815fbaffbbbf4229ecb767ac913fecb27f9ad4343662e9ef099ea" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.21" @@ -2672,6 +2785,12 @@ dependencies = [ "cty", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.5.3" @@ -2852,6 +2971,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" +[[package]] +name = "safe_arch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794821e4ccb0d9f979512f9c1973480123f9bd62a90d74ab0f9426fcf8f4a529" +dependencies = [ + "bytemuck", +] + [[package]] name = "safemem" version = "0.3.3" @@ -2987,9 +3115,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.87" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce777b7b150d76b9cf60d28b55f5847135a003f7d7350c6be7a773508ce7d45" +checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" dependencies = [ "itoa 1.0.4", "ryu", @@ -3123,6 +3251,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50582927ed6f77e4ac020c057f37a268fc6aebc29225050365aacbb9deeeddc4" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "siphasher" version = "0.3.10" @@ -3154,6 +3295,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "soloud" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b27367e7bceeceb167477f3d0dc633bb822327b44dceb59d5814ee7788f9a3" +dependencies = [ + "bitflags", + "paste", + "soloud-sys", +] + +[[package]] +name = "soloud-sys" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507a862f3ce15071b74bc8e8e4c40dfa9f6c44fc3b96e60cb55b5aad08333727" +dependencies = [ + "cmake", +] + [[package]] name = "soup2" version = "0.2.1" @@ -3235,6 +3396,27 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ac893c7d471c8a21f31cfe213ec4f6d9afeed25537c772e08ef3f005f8729e" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339f799d8b549e3744c7ac7feb216383e4005d94bdb22561b3ab8f3b808ae9fb" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "substring" version = "1.4.5" @@ -3375,6 +3557,7 @@ dependencies = [ "http", "ignore", "minisign-verify", + "notify-rust", "objc", "once_cell", "open", @@ -3579,6 +3762,17 @@ dependencies = [ "windows 0.39.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58de036c4d2e20717024de2a3c4bf56c301f07b21bc8ef9b57189fce06f1f3b" +dependencies = [ + "quick-xml", + "strum", + "windows 0.39.0", +] + [[package]] name = "tempfile" version = "3.3.0" @@ -4212,6 +4406,16 @@ dependencies = [ "cc", ] +[[package]] +name = "wide" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feff0a412894d67223777b6cc8d68c0dab06d52d95e9890d5f2d47f10dd9366c" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9377329e..e1b53901 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oyasumi" -version = "1.4.1" +version = "1.5.0" description = "" authors = ["Raphiiko"] license = "MIT" @@ -18,7 +18,7 @@ tauri-build = { version = "1.0.4", features = [] } oyasumi-shared = { path = "../src-shared", version = '*' } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } -tauri = { version = "1.1.1", features = ["dialog-message", "dialog-open", "fs-exists", "fs-read-dir", "fs-read-file", "http-request", "process-exit", "shell-execute", "shell-open", "updater"] } +tauri = { version = "1.1.1", features = ["dialog-message", "dialog-open", "fs-exists", "fs-read-dir", "fs-read-file", "http-request", "notification-all", "process-exit", "shell-execute", "shell-open", "updater"] } lazy_static = "1.4.0" cronjob = "0.4.17" openvr_sys = "2.0.3" @@ -35,6 +35,9 @@ mime = "0.3.16" md5 = "0.7.0" mime_guess = "2.0.4" substring = "1.4.5" +bytes = "1.4.0" +nalgebra = "0.32.1" +soloud = "1.0.2" [dependencies.windows-sys] version = "0.36.1" @@ -49,7 +52,7 @@ version = "1.21.1" features = ["full"] [dependencies.hyper] -version = "0.14.20" +version = "0.14.24" features = ["full"] [dependencies.tauri-plugin-single-instance] @@ -76,7 +79,7 @@ rev = "c2f95d27a6f911cf360420b2279f0d1c9f2af52e" [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL -default = [ "custom-protocol" ] +default = ["custom-protocol"] # this feature is used used for production builds where `devPath` points to the filesystem # DO NOT remove this -custom-protocol = [ "tauri/custom-protocol" ] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/sounds/credit.txt b/src-tauri/sounds/credit.txt new file mode 100644 index 00000000..bbd3c7bb --- /dev/null +++ b/src-tauri/sounds/credit.txt @@ -0,0 +1,2 @@ +Notification sounds by Aarni Koskela (akx). +https://github.com/akx/Notifications diff --git a/src-tauri/sounds/notification_bell.ogg b/src-tauri/sounds/notification_bell.ogg new file mode 100644 index 00000000..7822f9a1 Binary files /dev/null and b/src-tauri/sounds/notification_bell.ogg differ diff --git a/src-tauri/sounds/notification_block.ogg b/src-tauri/sounds/notification_block.ogg new file mode 100644 index 00000000..cdcc935f Binary files /dev/null and b/src-tauri/sounds/notification_block.ogg differ diff --git a/src-tauri/src/background/openvr.rs b/src-tauri/src/background/openvr.rs index 899b399c..2ea0f29e 100644 --- a/src-tauri/src/background/openvr.rs +++ b/src-tauri/src/background/openvr.rs @@ -9,11 +9,12 @@ use log::info; use openvr::TrackedDeviceIndex; use oyasumi_shared::models::{DeviceUpdateEvent, OVRDevice, OVRDevicePose}; use serde::Serialize; +use sleep_detector::SleepDetector; use substring::Substring; use sysinfo::SystemExt; use tauri::Manager; -use crate::TAURI_WINDOW; +use crate::{gesture_detector::GestureDetector, sleep_detector, TAURI_WINDOW}; #[derive(Serialize, Clone)] pub enum OpenVRStatus { @@ -34,7 +35,7 @@ pub struct OpenVRManager { impl OpenVRManager { pub fn new() -> OpenVRManager { - let manager = OpenVRManager { + let mut manager = OpenVRManager { state: Arc::new(OpenVRManagerState { active: Mutex::new(false), status: Mutex::new(OpenVRStatus::INACTIVE), @@ -60,8 +61,8 @@ impl OpenVRManager { *_active = active; } - fn openvr_loop(&self) { - let core: OpenVRManagerCore = OpenVRManagerCore::new(self.state.clone()); + fn openvr_loop(&mut self) { + let mut core: OpenVRManagerCore = OpenVRManagerCore::new(self.state.clone()); thread::spawn(move || { core.openvr_loop(); }); @@ -70,14 +71,20 @@ impl OpenVRManager { struct OpenVRManagerCore { state: Arc, + sleep_detector: SleepDetector, + gesture_detector: GestureDetector, } impl OpenVRManagerCore { pub fn new(state: Arc) -> OpenVRManagerCore { - OpenVRManagerCore { state } + OpenVRManagerCore { + state, + sleep_detector: SleepDetector::new(), + gesture_detector: GestureDetector::new(), + } } - fn openvr_loop(&self) { + fn openvr_loop(&mut self) { // Thread dependencies let mut sysinfo = sysinfo::System::new_all(); @@ -89,12 +96,13 @@ impl OpenVRManagerCore { let mut ovr_system: Option = None; // Manager State - let state_active = self.state.active.lock().unwrap(); // Main Loop 'ovr_loop: loop { thread::sleep(Duration::from_millis(32)); + let state_active = self.state.active.lock().unwrap(); if *state_active { + drop(state_active); // If we're not active, try to initialize OpenVR if let None = ovr_context { // Stop if we cannot yet (re)initialize OpenVR @@ -211,7 +219,7 @@ impl OpenVRManagerCore { } } - fn refresh_device_poses(&self, system: &openvr::System) { + fn refresh_device_poses(&mut self, system: &openvr::System) { let poses = system.device_to_absolute_tracking_pose(openvr::TrackingUniverseOrigin::Standing, 0.0); for n in 0..poses.len() { @@ -244,6 +252,11 @@ impl OpenVRManagerCore { let pos = openvr_sys::HmdVector3_t { v: [matrix[0][3], matrix[1][3], matrix[2][3]], }; + // Update sleep and gesture detectors (0 == HMD) + if n == 0 { + self.sleep_detector.log_pose(pos.v, [q.x, q.y, q.z, q.w]); + self.gesture_detector.log_pose(pos.v, [q.x, q.y, q.z, q.w]); + } // Emit event { let window_guard = TAURI_WINDOW.lock().unwrap(); diff --git a/src-tauri/src/commands/notifications.rs b/src-tauri/src/commands/notifications.rs new file mode 100644 index 00000000..d1be5ae2 --- /dev/null +++ b/src-tauri/src/commands/notifications.rs @@ -0,0 +1,8 @@ +use std::net::UdpSocket; + +#[tauri::command] +pub fn xsoverlay_send_message(message: Vec) { + let socket = UdpSocket::bind("0.0.0.0:0").unwrap(); + let _ = socket.set_broadcast(true); + let _ = socket.send_to(&message, "127.0.0.1:42069"); +} diff --git a/src-tauri/src/commands/os.rs b/src-tauri/src/commands/os.rs index ce47e080..f86fcd8e 100644 --- a/src-tauri/src/commands/os.rs +++ b/src-tauri/src/commands/os.rs @@ -1,5 +1,48 @@ +use std::{collections::HashMap, sync::Mutex}; + use log::error; use serde::{Deserialize, Serialize}; +use soloud::*; + +lazy_static! { + static ref SOUNDS: Mutex>> = Mutex::new(HashMap::new()); + static ref SOLOUD: Mutex = Mutex::new(Soloud::default().unwrap()); +} + +pub fn load_sounds() { + let mut sounds = SOUNDS.lock().unwrap(); + sounds.insert( + String::from("notification_bell"), + std::fs::read("sounds/notification_bell.ogg").unwrap(), + ); + sounds.insert( + String::from("notification_block"), + std::fs::read("sounds/notification_block.ogg").unwrap(), + ); +} + +#[tauri::command] +pub fn play_sound(name: String) { + std::thread::spawn(move || { + let mut wav = audio::Wav::default(); + { + let sound_data_guard = SOUNDS.lock().unwrap(); + let sound_data = sound_data_guard.get(&name).unwrap(); + wav.load_mem(sound_data).unwrap(); + } + { + let sl = SOLOUD.lock().unwrap(); + sl.play(&wav); + } + loop { + std::thread::sleep(std::time::Duration::from_millis(100)); + let sl = SOLOUD.lock().unwrap(); + if sl.active_voice_count() == 0 { + break; + } + } + }); +} #[tauri::command] pub async fn run_command(command: String, args: Vec) -> Result { diff --git a/src-tauri/src/gesture_detector.rs b/src-tauri/src/gesture_detector.rs new file mode 100644 index 00000000..fac362d2 --- /dev/null +++ b/src-tauri/src/gesture_detector.rs @@ -0,0 +1,137 @@ +use nalgebra::{Quaternion, UnitQuaternion}; +use oyasumi_shared::models::GestureDetected; +use tauri::Manager; + +use crate::{utils::get_time, TAURI_WINDOW}; + +const MAX_EVENT_AGE_MS: u128 = 5000; // 5 seconds + +#[derive(Clone, Copy)] +struct YawEvent { + yaw: f64, + timestamp: u128, +} + +pub struct GestureDetector { + events: Vec, + last_detection: u128, +} + +impl GestureDetector { + pub fn new() -> Self { + Self { + events: Vec::new(), + last_detection: 0, + } + } + + pub fn log_pose(&mut self, _position: [f32; 3], quaternion: [f64; 4]) { + // Determine yaw + let q = UnitQuaternion::from_quaternion(Quaternion::new( + quaternion[3], + quaternion[0], + quaternion[1], + quaternion[2], + )); + let yaw = (2.0 * q.as_ref().imag().y.atan2(q.as_ref().scalar())) + * (180.0 / std::f64::consts::PI) + + 180.0; + // Log yaw event + let event = YawEvent { + yaw, + timestamp: get_time(), + }; + self.events.push(event); + // Remove old events + let oldest_time = event.timestamp - MAX_EVENT_AGE_MS; + let old_event_count = self + .events + .iter() + .take_while(|e| e.timestamp < oldest_time) + .count(); + self.events.drain(..old_event_count); + // Convert events to relative movements + let mut movements = Vec::new(); + for i in 0..self.events.len() - 1 { + let yaw1 = self.events[i].yaw; + let yaw2 = self.events[i + 1].yaw; + let yaw_diff = yaw2 - yaw1; + let yaw_diff = if yaw_diff > 180.0 { + yaw_diff - 360.0 + } else if yaw_diff < -180.0 { + yaw_diff + 360.0 + } else { + yaw_diff + }; + movements.push(yaw_diff); + } + // Detect head shake + if get_time() - self.last_detection >= 5000 { + if self.detect_head_shake(movements) { + self.last_detection = get_time(); + { + let window_guard = TAURI_WINDOW.lock().unwrap(); + let window = window_guard.as_ref().unwrap(); + window + .emit_all( + "GESTURE_DETECTED", + GestureDetected { + gesture: "head_shake".to_string(), + }, + ) + .ok(); + } + } + } + } + + fn detect_head_shake(&self, movements: Vec) -> bool { + let mut data = movements.clone(); + let mut offset_dir = 1.0; + let mut change: Option; + let change_dir_a = self.detect_angular_change(data.clone(), -15.0); + let change_dir_b = self.detect_angular_change(data.clone(), 15.0); + if change_dir_a.is_some() { + change = change_dir_a; + } else if change_dir_b.is_some() { + change = change_dir_b; + offset_dir = -1.0; + } else { + return false; + } + data = data[change.unwrap()..].to_vec(); + change = self.detect_angular_change(data.clone(), 30.0 * offset_dir); + if change.is_none() { + return false; + } + data = data[change.unwrap()..].to_vec(); + change = self.detect_angular_change(data.clone(), -15.0 * offset_dir); + if change.is_none() { + return false; + } + return true; + } + + fn detect_angular_change(&self, mut data: Vec, mut offset: f64) -> Option { + let mut delta = 0.0; + // Flip data if we're looking for a negative offset + if offset < 0.0 { + data = data.iter().map(|x| -x).collect(); + offset *= -1.0; + } + // Loop over all data points + for i in 0..data.len() { + delta += data[i]; + // if delta is negative, reset to 0 + if delta < 0.0 { + delta = 0.0; + } + // If we have passed the given offset, angular change has been detected + if delta >= offset { + return Some(i); + } + } + // Desired angular change could not be found + return None; + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 57180ff4..ce14b401 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,10 +6,12 @@ #[macro_use(lazy_static)] extern crate lazy_static; +use crate::commands::admin::start_elevation_sidecar; use crate::image_cache::ImageCache; use background::openvr::OpenVRManager; use cronjob::CronJob; -use log::LevelFilter; +use log::{info, LevelFilter}; +use oyasumi_shared::windows::is_elevated; use std::{net::UdpSocket, sync::Mutex}; use tauri::Manager; use tauri_plugin_fs_extra::FsExtra; @@ -21,20 +23,26 @@ mod commands { pub mod afterburner; pub mod http; pub mod log_parser; + pub mod notifications; pub mod nvml; pub mod openvr; pub mod os; pub mod osc; pub mod splash; } + mod background { pub mod http_server; pub mod log_parser; pub mod openvr; pub mod osc; } + mod elevated_sidecar; +mod gesture_detector; mod image_cache; +mod sleep_detector; +mod utils; lazy_static! { static ref OPENVR_MANAGER: Mutex> = Default::default(); @@ -101,17 +109,35 @@ fn main() { *OPENVR_MANAGER.lock().unwrap() = Some(openvr_manager); // Spawn HTTP server thread background::http_server::spawn_http_server_thread(); + // Load sounds + commands::os::load_sounds(); }); // Setup start of minute cronjob let mut cron = CronJob::new("CRON_MINUTE_START", on_cron_minute_start); cron.seconds("0"); CronJob::start_job_threaded(cron); + // If we have admin privileges, prelaunch the elevation sidecar + if is_elevated() { + info!("[Core] Main process is running with elevation. Pre-launching sidecar..."); + loop { + { + let main_http_port = MAIN_HTTP_SERVER_PORT.lock().unwrap(); + if main_http_port.is_some() { + start_elevation_sidecar(); + break; + } + } + } + } else { + info!("[Core] Main process is running without elevation. Sidecar will be launched on demand."); + } Ok(()) }) .invoke_handler(tauri::generate_handler![ commands::openvr::openvr_get_devices, commands::openvr::openvr_status, commands::os::run_command, + commands::os::play_sound, commands::splash::close_splashscreen, commands::nvml::nvml_status, commands::nvml::nvml_get_devices, @@ -126,6 +152,7 @@ fn main() { commands::log_parser::init_vrc_log_watcher, commands::http::get_http_server_port, commands::afterburner::msi_afterburner_set_profile, + commands::notifications::xsoverlay_send_message, ]) .run(tauri::generate_context!()) .expect("An error occurred while running the application"); diff --git a/src-tauri/src/sleep_detector.rs b/src-tauri/src/sleep_detector.rs new file mode 100644 index 00000000..e31b980d --- /dev/null +++ b/src-tauri/src/sleep_detector.rs @@ -0,0 +1,207 @@ +use oyasumi_shared::models::SleepDetectorStateReport; +use tauri::Manager; + +use crate::{utils::get_time, TAURI_WINDOW}; + +const MAX_EVENT_AGE_MS: u128 = 900000; // 15 minutes + +#[derive(Clone, Copy)] +struct PoseEvent { + x: f32, + y: f32, + z: f32, + quaternion: [f64; 4], + timestamp: u128, // in milliseconds +} + +impl PoseEvent { + fn distance_to(&self, other: &PoseEvent) -> f64 { + let dx: f64 = (self.x - other.x).into(); + let dy: f64 = (self.y - other.y).into(); + let dz: f64 = (self.z - other.z).into(); + (dx * dx + dy * dy + dz * dz).sqrt() + } + fn angular_distance_degrees(&self, other: &PoseEvent) -> f64 { + let q1 = self.quaternion; + let q2 = other.quaternion; + let dot_product = q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3]; + let angle = 2.0 * dot_product.abs().clamp(-1.0, 1.0).acos(); + let angle_degrees = angle * 180.0 / std::f64::consts::PI; + angle_degrees + } +} + +pub struct SleepDetector { + events: Vec, + distance_in_last_15_minutes: f64, + distance_in_last_10_minutes: f64, + distance_in_last_5_minutes: f64, + distance_in_last_1_minute: f64, + distance_in_last_10_seconds: f64, + rotation_in_last_15_minutes: f64, + rotation_in_last_10_minutes: f64, + rotation_in_last_5_minutes: f64, + rotation_in_last_1_minute: f64, + rotation_in_last_10_seconds: f64, + // reqwest_client: reqwest::Client, + start_time: u128, + last_log: u128, + next_state_report: u128, +} + +impl SleepDetector { + pub fn new() -> Self { + Self { + events: Vec::new(), + distance_in_last_10_seconds: 0.0, + distance_in_last_1_minute: 0.0, + distance_in_last_5_minutes: 0.0, + distance_in_last_10_minutes: 0.0, + distance_in_last_15_minutes: 0.0, + rotation_in_last_10_seconds: 0.0, + rotation_in_last_1_minute: 0.0, + rotation_in_last_5_minutes: 0.0, + rotation_in_last_10_minutes: 0.0, + rotation_in_last_15_minutes: 0.0, + // reqwest_client: reqwest::Client::new(), + start_time: 0, + last_log: 0, + next_state_report: 0, + } + } + + pub fn log_pose(&mut self, position: [f32; 3], quaternion: [f64; 4]) { + // Add the event + let event = PoseEvent { + x: position[0], + y: position[1], + z: position[2], + quaternion, + timestamp: get_time(), + }; + self.events.push(event); + // Remove old events + let oldest_time = event.timestamp - MAX_EVENT_AGE_MS; + let old_event_count = self + .events + .iter() + .take_while(|e| e.timestamp < oldest_time) + .count(); + self.events.drain(..old_event_count); + // Calculate new distances + self.distance_in_last_15_minutes = self.distance_in_window(900000); + self.distance_in_last_10_minutes = self.distance_in_window(600000); + self.distance_in_last_5_minutes = self.distance_in_window(300000); + self.distance_in_last_1_minute = self.distance_in_window(60000); + self.distance_in_last_10_seconds = self.distance_in_window(10000); + self.rotation_in_last_15_minutes = self.rotation_in_window(900000); + self.rotation_in_last_10_minutes = self.rotation_in_window(600000); + self.rotation_in_last_5_minutes = self.rotation_in_window(300000); + self.rotation_in_last_1_minute = self.rotation_in_window(60000); + self.rotation_in_last_10_seconds = self.rotation_in_window(10000); + // Set new start time if there hasn't been any data in over a minute + if get_time() - self.last_log > 60000 { + self.start_time = get_time(); + } + // Update the last log time + self.last_log = event.timestamp; + // Send a state report if it's been over a second since the last one + if get_time() > self.next_state_report { + self.next_state_report = get_time() + 1000; + self.send_state_report(); + } + } + + fn distance_in_window(&mut self, window_ms: u128) -> f64 { + let start_time = get_time() - window_ms; + let start_index = self + .events + .iter() + .position(|e| e.timestamp >= start_time) + .unwrap_or(0); + let events = &self.events[start_index..]; + let mut total_distance = 0.0; + let mut i = 0; + while i < events.len() - 1 { + let event_a = &events[i]; + let event_b = &events[i + 1]; + let distance = event_a.distance_to(event_b); + total_distance += distance; + i += 1; + } + total_distance + } + + fn rotation_in_window(&mut self, window_ms: u128) -> f64 { + let start_time = get_time() - window_ms; + let start_index = self + .events + .iter() + .position(|e| e.timestamp >= start_time) + .unwrap_or(0); + let events = &self.events[start_index..]; + let mut total_rotation = 0.0; + let mut i = 0; + while i < events.len() - 1 { + let event_a = &events[i]; + let event_b = &events[i + 1]; + let rotation = event_a.angular_distance_degrees(event_b); + total_rotation += rotation; + i += 1; + } + total_rotation + } + + fn send_state_report(&self) { + { + let window_guard = TAURI_WINDOW.lock().unwrap(); + let window = window_guard.as_ref().unwrap(); + window + .emit_all( + "SLEEP_DETECTOR_STATE_REPORT", + SleepDetectorStateReport { + distance_in_last_15_minutes: self.distance_in_last_15_minutes, + distance_in_last_10_minutes: self.distance_in_last_10_minutes, + distance_in_last_5_minutes: self.distance_in_last_5_minutes, + distance_in_last_1_minute: self.distance_in_last_1_minute, + distance_in_last_10_seconds: self.distance_in_last_10_seconds, + rotation_in_last_15_minutes: self.rotation_in_last_15_minutes, + rotation_in_last_10_minutes: self.rotation_in_last_10_minutes, + rotation_in_last_5_minutes: self.rotation_in_last_5_minutes, + rotation_in_last_1_minute: self.rotation_in_last_1_minute, + rotation_in_last_10_seconds: self.rotation_in_last_10_seconds, + start_time: self.start_time, + last_log: self.last_log, + }, + ) + .ok(); + // self.send_influxdb_report(); + } + } + + // #[tokio::main] + // async fn send_influxdb_report(&self) { + // let f = self.reqwest_client + // .post("http://localhost:8086/api/v2/write?org=org&bucket=bucket&precision=ms") + // .header("Authorization", "Token yXuwflYgacQn8GQp7VmXV23jdC5mG3k5XVBHiA7_Ojv7xCLZyB-FttolJcCRop4knUvN-vi_uMxbZjaBa5SfbQ==") // Yes this is a token I checked in. It's for a local test database, for debugging. Don't worry about it. + // .header("Content-Type", "text/plain; charset=utf-8") + // .header("Accept", "application/json") + // .body(format!( + // "sleep_detector distance_in_last_15_minutes={},distance_in_last_10_minutes={},distance_in_last_5_minutes={},distance_in_last_1_minute={},distance_in_last_10_seconds={},rotation_in_last_15_minutes={},rotation_in_last_10_minutes={},rotation_in_last_5_minutes={},rotation_in_last_1_minute={},rotation_in_last_10_seconds={} {}", + // self.distance_in_last_15_minutes, + // self.distance_in_last_10_minutes, + // self.distance_in_last_5_minutes, + // self.distance_in_last_1_minute, + // self.distance_in_last_10_seconds, + // self.rotation_in_last_15_minutes, + // self.rotation_in_last_10_minutes, + // self.rotation_in_last_5_minutes, + // self.rotation_in_last_1_minute, + // self.rotation_in_last_10_seconds, + // self.last_log + // )) + // .send(); + // // Block until the request is sent + // let _ = futures::executor::block_on(f); + // } +} diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 00000000..13de98de --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,7 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn get_time() -> u128 { + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + since_the_epoch.as_millis() +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9d414dfe..7740e139 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,7 +9,7 @@ }, "package": { "productName": "Oyasumi", - "version": "1.4.1" + "version": "1.5.0" }, "tauri": { "allowlist": { @@ -18,7 +18,8 @@ "readDir": true, "exists": true, "scope": [ - "$HOME/AppData/LocalLow/VRChat/**/*" + "$HOME/AppData/LocalLow/VRChat/**/*", + "$RESOURCE/*" ] }, "shell": { @@ -39,6 +40,9 @@ "https://assets.vrchat.com/*", "https://files.vrchat.cloud/*" ] + }, + "notification": { + "all": true } }, "bundle": { @@ -67,7 +71,9 @@ "providerShortName": null, "signingIdentity": null }, - "resources": [], + "resources": [ + "sounds/*.ogg" + ], "shortDescription": "", "targets": "all", "windows": { @@ -110,7 +116,7 @@ "center": true, "theme": "Dark", "transparent": true, - "userAgent": "Oyasumi/1.4.1 (https://github.com/Raphiiko/Oyasumi)" + "userAgent": "Oyasumi/1.5.0 (https://github.com/Raphiiko/Oyasumi)" }, { "width": 500, @@ -122,8 +128,8 @@ "center": true, "theme": "Dark", "transparent": true, - "userAgent": "Oyasumi/1.4.1 (https://github.com/Raphiiko/Oyasumi)" + "userAgent": "Oyasumi/1.5.0 (https://github.com/Raphiiko/Oyasumi)" } ] } -} \ No newline at end of file +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 61ea87d5..8b153666 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -11,8 +11,13 @@ import { GpuAutomationsViewComponent } from './views/dashboard-view/views/gpu-au import { OscAutomationsViewComponent } from './views/dashboard-view/views/osc-automations-view/osc-automations-view.component'; import { StatusAutomationsViewComponent } from './views/dashboard-view/views/status-automations-view/status-automations-view.component'; import { AutoInviteRequestAcceptViewComponent } from './views/dashboard-view/views/auto-invite-request-accept-view/auto-invite-request-accept-view.component'; +import { SleepDebugViewComponent } from './views/sleep-debug-view/sleep-debug-view.component'; const routes: Routes = [ + { + path: 'sleepDebug', + component: SleepDebugViewComponent, + }, { path: 'dashboard', component: DashboardViewComponent, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b33858c8..25ed2809 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -83,12 +83,20 @@ import { InviteAutomationsService } from './services/invite-automations.service' import { GpuPowerlimitingPaneComponent } from './views/dashboard-view/views/gpu-automations-view/gpu-powerlimiting-pane/gpu-powerlimiting-pane.component'; import { MsiAfterburnerPaneComponent } from './views/dashboard-view/views/gpu-automations-view/msi-afterburner-pane/msi-afterburner-pane.component'; import { invoke } from '@tauri-apps/api'; +import { SleepModeChangeOnSteamVRStatusAutomationService } from './services/sleep-detection-automations/sleep-mode-change-on-steamvr-status-automation.service'; +import { ImageFallbackDirective } from './directives/image-fallback.directive'; +import { SleepDebugViewComponent } from './views/sleep-debug-view/sleep-debug-view.component'; +import { SleepModeForSleepDetectorAutomationService } from './services/sleep-detection-automations/sleep-mode-for-sleep-detector-automation.service'; +import { SleepDetectorCalibrationModalComponent } from './views/dashboard-view/views/sleep-detection-view/sleep-detector-calibration-modal/sleep-detector-calibration-modal.component'; +import { SleepDetectorEnableSleepModeModalComponent } from './views/dashboard-view/views/sleep-detection-view/sleep-detector-enable-sleepmode-modal/sleep-detector-enable-sleep-mode-modal.component'; +import { SettingsNotificationsTabComponent } from './views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component'; export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); } @NgModule({ + bootstrap: [AppComponent], declarations: [ AppComponent, DashboardViewComponent, @@ -98,9 +106,12 @@ export function createTranslateLoader(http: HttpClient) { DeviceListComponent, DeviceListItemComponent, VarDirective, + ImageFallbackDirective, AboutViewComponent, OverviewViewComponent, SleepDetectionViewComponent, + SleepDetectorCalibrationModalComponent, + SleepDetectorEnableSleepModeModalComponent, TimeEnableSleepModeModalComponent, TimeDisableSleepModeModalComponent, BatteryPercentageEnableSleepModeModalComponent, @@ -121,6 +132,7 @@ export function createTranslateLoader(http: HttpClient) { UpdateModalComponent, LanguageSelectModalComponent, SettingsGeneralTabComponent, + SettingsNotificationsTabComponent, SettingsUpdatesTabComponent, SettingsDebugTabComponent, SettingsVRChatTabComponent, @@ -133,6 +145,7 @@ export function createTranslateLoader(http: HttpClient) { FriendSelectionModalComponent, GpuPowerlimitingPaneComponent, MsiAfterburnerPaneComponent, + SleepDebugViewComponent, ], imports: [ CommonModule, @@ -166,7 +179,6 @@ export function createTranslateLoader(http: HttpClient) { }, }, ], - bootstrap: [AppComponent], }) export class AppModule { constructor( @@ -186,9 +198,11 @@ export class AppModule { // GPU automations private gpuAutomations: GpuAutomationsService, // Sleep mode automations + private sleepModeForSleepDetectorAutomationService: SleepModeForSleepDetectorAutomationService, private sleepModeEnableOnControllersPoweredOffAutomation: SleepModeEnableOnControllersPoweredOffAutomationService, private sleepModeEnableAtBatteryPercentageAutomation: SleepModeEnableAtBatteryPercentageAutomationService, private sleepModeEnableAtTimeAutomationService: SleepModeEnableAtTimeAutomationService, + private sleepModeChangeOnSteamVRStatusAutomationService: SleepModeChangeOnSteamVRStatusAutomationService, private sleepModeDisableAtTimeAutomationService: SleepModeDisableAtTimeAutomationService, private sleepModeDisableOnDevicePowerOnAutomationService: SleepModeDisableOnDevicePowerOnAutomationService, // Battery automations @@ -231,9 +245,11 @@ export class AppModule { // GPU automations this.gpuAutomations.init(), // Sleep mode automations + this.sleepModeForSleepDetectorAutomationService.init(), this.sleepModeEnableOnControllersPoweredOffAutomation.init(), this.sleepModeEnableAtBatteryPercentageAutomation.init(), this.sleepModeEnableAtTimeAutomationService.init(), + this.sleepModeChangeOnSteamVRStatusAutomationService.init(), this.sleepModeDisableAtTimeAutomationService.init(), this.sleepModeDisableOnDevicePowerOnAutomationService.init(), // Battery automations diff --git a/src/app/components/confirm-modal/confirm-modal.component.html b/src/app/components/confirm-modal/confirm-modal.component.html index c4ae8a16..904f4afd 100644 --- a/src/app/components/confirm-modal/confirm-modal.component.html +++ b/src/app/components/confirm-modal/confirm-modal.component.html @@ -1,16 +1,16 @@ diff --git a/src/app/components/confirm-modal/confirm-modal.component.ts b/src/app/components/confirm-modal/confirm-modal.component.ts index 15ba4534..c1c4c5c0 100644 --- a/src/app/components/confirm-modal/confirm-modal.component.ts +++ b/src/app/components/confirm-modal/confirm-modal.component.ts @@ -1,12 +1,13 @@ import { Component, OnInit } from '@angular/core'; import { SimpleModalComponent } from 'ngx-simple-modal'; import { fadeUp } from 'src/app/utils/animations'; +import { TString } from '../../models/translatable-string'; export interface ConfirmModalInputModel { - title?: string; - message?: string; - confirmButtonText?: string; - cancelButtonText?: string; + title?: TString; + message?: TString; + confirmButtonText?: TString; + cancelButtonText?: TString; showCancel?: boolean; } @@ -24,10 +25,10 @@ export class ConfirmModalComponent extends SimpleModalComponent implements OnInit, ConfirmModalInputModel { - title?: string; - message?: string; - confirmButtonText?: string; - cancelButtonText?: string; + title?: TString; + message?: TString; + confirmButtonText?: TString; + cancelButtonText?: TString; showCancel?: boolean; constructor() { diff --git a/src/app/components/dashboard-navbar/dashboard-navbar.component.html b/src/app/components/dashboard-navbar/dashboard-navbar.component.html index 226f8dc3..b9aa6634 100644 --- a/src/app/components/dashboard-navbar/dashboard-navbar.component.html +++ b/src/app/components/dashboard-navbar/dashboard-navbar.component.html @@ -1,6 +1,11 @@
- Oyasumi Icon + Oyasumi Icon
Oyasumi diff --git a/src/app/components/dashboard-navbar/dashboard-navbar.component.ts b/src/app/components/dashboard-navbar/dashboard-navbar.component.ts index ed38affd..5cd15f05 100644 --- a/src/app/components/dashboard-navbar/dashboard-navbar.component.ts +++ b/src/app/components/dashboard-navbar/dashboard-navbar.component.ts @@ -7,6 +7,7 @@ import { fade } from '../../utils/animations'; import { NVMLService } from '../../services/nvml.service'; import { ElevatedSidecarService } from '../../services/elevated-sidecar.service'; import { UpdateService } from '../../services/update.service'; +import { Router } from '@angular/router'; @Component({ selector: 'app-dashboard-navbar', @@ -25,7 +26,8 @@ export class DashboardNavbarComponent implements OnInit { private gpuAutomations: GpuAutomationsService, private nvml: NVMLService, private sidecar: ElevatedSidecarService, - private update: UpdateService + private update: UpdateService, + private router: Router ) { this.updateAvailable = this.update.updateAvailable.pipe(map((a) => !!a.manifest)); this.settingErrors = combineLatest([ @@ -85,4 +87,12 @@ export class DashboardNavbarComponent implements OnInit { } ngOnInit(): void {} + + logoClicked = 0; + + onLogoClick() { + if (this.logoClicked++ > 5) { + this.router.navigate(['/sleepDebug']); + } + } } diff --git a/src/app/components/friend-selection-modal/friend-selection-modal.component.html b/src/app/components/friend-selection-modal/friend-selection-modal.component.html index ad8c87ae..d8714272 100644 --- a/src/app/components/friend-selection-modal/friend-selection-modal.component.html +++ b/src/app/components/friend-selection-modal/friend-selection-modal.component.html @@ -25,6 +25,7 @@
- +
- +
this.eRef.nativeElement; + element.src = this.appImgFallback || 'https://via.placeholder.com/200'; + } +} diff --git a/src/app/migrations/app-settings.migrations.ts b/src/app/migrations/app-settings.migrations.ts index 27923de1..2f667644 100644 --- a/src/app/migrations/app-settings.migrations.ts +++ b/src/app/migrations/app-settings.migrations.ts @@ -5,6 +5,7 @@ import { info } from 'tauri-plugin-log-api'; const migrations: { [v: number]: (data: any) => any } = { 1: toLatest, 2: from1to2, + 3: from2to3, }; export function migrateAppSettings(data: any): AppSettings { @@ -32,6 +33,17 @@ function toLatest(data: any): any { return data; } +function from2to3(data: any): any { + data.version = 3; + data.oscSendingHost = APP_SETTINGS_DEFAULT.oscSendingHost; + data.oscSendingPort = APP_SETTINGS_DEFAULT.oscSendingPort; + data.oscReceivingHost = APP_SETTINGS_DEFAULT.oscReceivingHost; + data.oscReceivingPort = APP_SETTINGS_DEFAULT.oscReceivingPort; + data.enableXSOverlayNotifications = APP_SETTINGS_DEFAULT.enableXSOverlayNotifications; + data.enableDesktopNotifications = APP_SETTINGS_DEFAULT.enableDesktopNotifications; + return data; +} + function from1to2(data: any): any { data.version = 2; data.askForAdminOnStart = false; diff --git a/src/app/migrations/automation-configs.migrations.ts b/src/app/migrations/automation-configs.migrations.ts index 22a4a016..70085347 100644 --- a/src/app/migrations/automation-configs.migrations.ts +++ b/src/app/migrations/automation-configs.migrations.ts @@ -9,6 +9,7 @@ const migrations: { [v: number]: (data: any) => any } = { 4: from3to4, 5: from4to5, 6: from5to6, + 7: from6to7, }; export function migrateAutomationConfigs(data: any): AutomationConfigs { @@ -40,6 +41,17 @@ function toLatest(data: any): any { return data; } +function from6to7(data: any): any { + data.version = 7; + data.SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS = cloneDeep( + AUTOMATION_CONFIGS_DEFAULT.SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS + ); + data.SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR = cloneDeep( + AUTOMATION_CONFIGS_DEFAULT.SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR + ); + return data; +} + function from5to6(data: any): any { data.version = 6; data.MSI_AFTERBURNER = cloneDeep(AUTOMATION_CONFIGS_DEFAULT.MSI_AFTERBURNER); diff --git a/src/app/models/automations.ts b/src/app/models/automations.ts index 6096f934..65f8d683 100644 --- a/src/app/models/automations.ts +++ b/src/app/models/automations.ts @@ -8,9 +8,11 @@ export type AutomationType = | 'GPU_POWER_LIMITS' | 'MSI_AFTERBURNER' // SLEEP MODE AUTOMATIONS + | 'SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR' | 'SLEEP_MODE_ENABLE_AT_TIME' | 'SLEEP_MODE_ENABLE_AT_BATTERY_PERCENTAGE' | 'SLEEP_MODE_ENABLE_ON_CONTROLLERS_POWERED_OFF' + | 'SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS' | 'SLEEP_MODE_DISABLE_AT_TIME' | 'SLEEP_MODE_DISABLE_ON_DEVICE_POWER_ON' // BATTERY AUTOMATIONS @@ -24,13 +26,15 @@ export type AutomationType = | 'AUTO_ACCEPT_INVITE_REQUESTS'; export interface AutomationConfigs { - version: 6; + version: 7; GPU_POWER_LIMITS: GPUPowerLimitsAutomationConfig; MSI_AFTERBURNER: MSIAfterburnerAutomationConfig; // SLEEP MODE AUTOMATIONS + SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR: SleepModeEnableForSleepDetectorAutomationConfig; SLEEP_MODE_ENABLE_AT_TIME: SleepModeEnableAtTimeAutomationConfig; SLEEP_MODE_ENABLE_AT_BATTERY_PERCENTAGE: SleepModeEnableAtBatteryPercentageAutomationConfig; SLEEP_MODE_ENABLE_ON_CONTROLLERS_POWERED_OFF: SleepModeEnableAtControllersPoweredOffAutomationConfig; + SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS: SleepModeChangeOnSteamVRStatusAutomationConfig; SLEEP_MODE_DISABLE_AT_TIME: SleepModeDisableAtTimeAutomationConfig; SLEEP_MODE_DISABLE_ON_DEVICE_POWER_ON: SleepModeDisableOnDevicePowerOnAutomationConfig; // BATTERY AUTOMATIONS @@ -74,6 +78,12 @@ export interface MSIAfterburnerAutomationConfig extends AutomationConfig { } // SLEEP MODE AUTOMATIONS +export interface SleepModeEnableForSleepDetectorAutomationConfig extends AutomationConfig { + calibrationValue: number; + sensitivity: 'LOWEST' | 'LOW' | 'MEDIUM' | 'HIGH' | 'HIGHEST'; + sleepCheck: boolean; +} + export interface SleepModeEnableAtTimeAutomationConfig extends AutomationConfig { time: string | null; } @@ -85,6 +95,10 @@ export interface SleepModeEnableAtBatteryPercentageAutomationConfig extends Auto export interface SleepModeEnableAtControllersPoweredOffAutomationConfig extends AutomationConfig {} +export interface SleepModeChangeOnSteamVRStatusAutomationConfig extends AutomationConfig { + disableOnSteamVRStop: boolean; +} + export interface SleepModeDisableAtTimeAutomationConfig extends AutomationConfig { time: string | null; } @@ -137,7 +151,7 @@ export interface AutoAcceptInviteRequestsAutomationConfig extends AutomationConf // export const AUTOMATION_CONFIGS_DEFAULT: AutomationConfigs = { - version: 6, + version: 7, // GPU AUTOMATIONS GPU_POWER_LIMITS: { enabled: false, @@ -158,6 +172,12 @@ export const AUTOMATION_CONFIGS_DEFAULT: AutomationConfigs = { onSleepDisableProfile: 0, }, // SLEEP MODE AUTOMATIONS + SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR: { + enabled: false, + calibrationValue: 0.01, + sensitivity: 'MEDIUM', + sleepCheck: false, + }, SLEEP_MODE_ENABLE_AT_TIME: { enabled: false, time: null, @@ -170,6 +190,10 @@ export const AUTOMATION_CONFIGS_DEFAULT: AutomationConfigs = { SLEEP_MODE_ENABLE_ON_CONTROLLERS_POWERED_OFF: { enabled: false, }, + SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS: { + enabled: true, + disableOnSteamVRStop: false, + }, SLEEP_MODE_DISABLE_AT_TIME: { enabled: false, time: null, diff --git a/src/app/models/events.ts b/src/app/models/events.ts index 722b1cc8..fa7e8b29 100644 --- a/src/app/models/events.ts +++ b/src/app/models/events.ts @@ -3,3 +3,18 @@ import { OVRDevice } from './ovr-device'; export interface DeviceUpdateEvent { device: OVRDevice; } + +export interface SleepDetectorStateReport { + distanceInLast15Minutes: number; + distanceInLast10Minutes: number; + distanceInLast5Minutes: number; + distanceInLast1Minute: number; + distanceInLast10Seconds: number; + rotationInLast15Minutes: number; + rotationInLast10Minutes: number; + rotationInLast5Minutes: number; + rotationInLast1Minute: number; + rotationInLast10Seconds: number; + startTime: number; + lastLog: number; +} diff --git a/src/app/models/settings.ts b/src/app/models/settings.ts index bbda5b7e..cb39301e 100644 --- a/src/app/models/settings.ts +++ b/src/app/models/settings.ts @@ -1,16 +1,28 @@ export interface AppSettings { - version: 2; + version: 3; userLanguage: string; lighthouseConsolePath: string; askForAdminOnStart: boolean; + oscSendingHost: string; + oscSendingPort: number; + oscReceivingHost: string; + oscReceivingPort: number; + enableDesktopNotifications: boolean; + enableXSOverlayNotifications: boolean; } export const APP_SETTINGS_DEFAULT: AppSettings = { - version: 2, + version: 3, userLanguage: 'en', askForAdminOnStart: false, lighthouseConsolePath: 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\SteamVR\\tools\\lighthouse\\bin\\win64\\lighthouse_console.exe', + oscSendingHost: '127.0.0.1', + oscSendingPort: 9000, + oscReceivingHost: '127.0.0.1', + oscReceivingPort: 9001, + enableXSOverlayNotifications: false, + enableDesktopNotifications: false, }; export type ExecutableReferenceStatus = diff --git a/src/app/services/notification.service.ts b/src/app/services/notification.service.ts new file mode 100644 index 00000000..5ccf9618 --- /dev/null +++ b/src/app/services/notification.service.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@angular/core'; +import { getVersion } from '../utils/app-utils'; +import { invoke } from '@tauri-apps/api'; +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from '@tauri-apps/api/notification'; +import { AppSettingsService } from './app-settings.service'; +import { APP_SETTINGS_DEFAULT, AppSettings } from '../models/settings'; +import { cloneDeep } from 'lodash'; +import { firstValueFrom } from 'rxjs'; + +interface XSOMessage { + messageType: number; + index: number; + volume: number; + audioPath: string; + timeout: number; + title: string; + content: string; + icon: string; + height: number; + opacity: number; + useBase64Icon: boolean; + sourceApp: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class NotificationService { + constructor(private appSettingsService: AppSettingsService) {} + + public async play_sound(sound: 'bell' | 'block') { + await invoke('play_sound', { + name: 'notification_' + sound, + }); + } + + public async send(title: string, content: string) { + const settings = await firstValueFrom(this.appSettingsService.settings); + if (settings.enableDesktopNotifications) { + await this.sendDesktopNotification(title, content); + } else if (settings.enableXSOverlayNotifications) { + await this.sendXSOverlayNotification(title, content, false, 3.0); + } + } + + public async enableDesktopNotifications(enabled: boolean) { + // Attempt enabling + if (enabled) { + // Check for permissions + let permissionGranted = await isPermissionGranted(); + // Attempt requesting permissions + if (!permissionGranted) { + const permission = await requestPermission(); + permissionGranted = permission === 'granted'; + if (!permissionGranted) enabled = false; + } + // Enable if successful + if (permissionGranted) { + // First disable XSOverlay notifications + await this.enableXSOverlayNotifications(false); + // Enable Desktop notifications + await this.appSettingsService.updateSettings({ + enableDesktopNotifications: true, + }); + } + } + // Disable (also if enabling was not successful) + if (!enabled) { + // Disable Desktop notifications + await this.appSettingsService.updateSettings({ + enableDesktopNotifications: false, + }); + } + } + + public async enableXSOverlayNotifications(enabled: boolean) { + if (enabled) { + // Disable desktop notifications if enabling + await this.enableDesktopNotifications(false); + // Enable XSOverlay notifications + await this.appSettingsService.updateSettings({ + enableXSOverlayNotifications: true, + }); + } else { + // Disable XSOverlay notifications + await this.appSettingsService.updateSettings({ + enableXSOverlayNotifications: false, + }); + } + } + + private async sendDesktopNotification(title: string, content: string) { + let permissionGranted = await isPermissionGranted(); + if (!permissionGranted) { + await this.enableDesktopNotifications(false); + return; + } + await sendNotification({ + title, + body: content, + }); + } + + private async sendXSOverlayNotification( + title: string, + content: string, + sound: boolean, + timeout: number + ) { + const message: XSOMessage = { + messageType: 1, + index: 0, + volume: sound ? 1.0 : 0.0, + audioPath: sound ? 'default' : '', + timeout, + title, + content, + icon: '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', + height: 175, + opacity: 0, + useBase64Icon: true, + sourceApp: 'Oyasumi/' + (await getVersion()), + }; + await invoke('xsoverlay_send_message', { + message: Array.from(new TextEncoder().encode(JSON.stringify(message))), + }); + } +} diff --git a/src/app/services/openvr.service.ts b/src/app/services/openvr.service.ts index 0d609c1c..eccafddb 100644 --- a/src/app/services/openvr.service.ts +++ b/src/app/services/openvr.service.ts @@ -3,7 +3,7 @@ import { listen } from '@tauri-apps/api/event'; import { DeviceUpdateEvent } from '../models/events'; import { invoke } from '@tauri-apps/api/tauri'; import { OVRDevice, OVRDevicePose } from '../models/ovr-device'; -import { BehaviorSubject, interval, Observable, startWith, Subject, takeUntil } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { cloneDeep, orderBy } from 'lodash'; import { AppSettingsService } from './app-settings.service'; diff --git a/src/app/services/osc.service.ts b/src/app/services/osc.service.ts index 3ca10d48..85281597 100644 --- a/src/app/services/osc.service.ts +++ b/src/app/services/osc.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@angular/core'; import { invoke } from '@tauri-apps/api'; -import { message } from '@tauri-apps/api/dialog'; -import { exit } from '@tauri-apps/api/process'; import { SleepService } from './sleep.service'; import { OscScript, OscScriptSleepAction } from '../models/osc-script'; import { cloneDeep } from 'lodash'; @@ -9,50 +7,74 @@ import { TaskQueue } from '../utils/task-queue'; import { debug, info } from 'tauri-plugin-log-api'; import { listen } from '@tauri-apps/api/event'; import { OSCMessage, OSCMessageRaw, parseOSCMessage } from '../models/osc-message'; -import { Observable, Subject } from 'rxjs'; +import { BehaviorSubject, filter, firstValueFrom, map, Observable, Subject, take, tap } from 'rxjs'; +import { AppSettingsService } from './app-settings.service'; @Injectable({ providedIn: 'root', }) export class OscService { - address = '127.0.0.1:9000'; private scriptQueue: TaskQueue = new TaskQueue({ runUniqueTasksConcurrently: true }); private _messages: Subject = new Subject(); public messages: Observable = this._messages.asObservable(); - constructor(private sleep: SleepService) {} + private _initializedOnAddress: BehaviorSubject = new BehaviorSubject< + string | null + >(null); + + constructor(private sleep: SleepService, private appSettings: AppSettingsService) {} async init() { - const result = await invoke('osc_init', { receiveAddr: '127.0.0.1:9001' }); - if (!result) { - info( - '[OSC] Could not bind a UDP socket to interact with VRChat over OSC (possibly due to incorrectly configured permissions). Quitting...' - ); - await message( - 'Could not bind a UDP socket to interact with VRChat over OSC. Please give Oyasumi the correct permissions.', - { type: 'error', title: 'Oyasumi' } - ); - await exit(0); - return; - } listen('OSC_MESSAGE', (data) => { this._messages.next(parseOSCMessage(data.payload)); }); + this.appSettings.settings + .pipe( + map( + (settings) => [settings.oscReceivingHost, settings.oscReceivingPort] as [string, number] + ), + take(1), + filter(([host, port]) => port > 0 && port <= 65535), + tap(([host, port]) => this.init_receiver(host, port)) + ) + .subscribe(); + } + + async init_receiver(host: string, port: number): Promise { + const receiveAddr = `${host}:${port}`; + if (this._initializedOnAddress.value === receiveAddr) return true; + const result = await invoke('osc_init', { receiveAddr }); + if (!result) { + info(`[OSC] Could not bind a UDP socket on ${receiveAddr}.`); + this._initializedOnAddress.next(null); + } else { + this._initializedOnAddress.next(receiveAddr); + } + return result; } async send_float(address: string, value: number) { debug(`[OSC] Sending float ${value} to ${address}`); - await invoke('osc_send_float', { addr: this.address, oscAddr: address, data: value }); + const addr = await firstValueFrom(this.appSettings.settings).then( + (settings) => settings.oscSendingHost + ':' + settings.oscSendingPort + ); + await invoke('osc_send_float', { addr, oscAddr: address, data: value }); } async send_int(address: string, value: number) { debug(`[OSC] Sending int ${value} to ${address}`); - await invoke('osc_send_int', { addr: this.address, oscAddr: address, data: value }); + const addr = await firstValueFrom(this.appSettings.settings).then( + (settings) => settings.oscSendingHost + ':' + settings.oscSendingPort + ); + await invoke('osc_send_int', { addr, oscAddr: address, data: value }); } async send_bool(address: string, value: boolean) { debug(`[OSC] Sending bool ${value} to ${address}`); - await invoke('osc_send_bool', { addr: this.address, oscAddr: address, data: value }); + const addr = await firstValueFrom(this.appSettings.settings).then( + (settings) => settings.oscSendingHost + ':' + settings.oscSendingPort + ); + await invoke('osc_send_bool', { addr, oscAddr: address, data: value }); } queueScript(script: OscScript, replaceId?: string) { diff --git a/src/app/services/sleep-detection-automations/sleep-mode-change-on-steamvr-status-automation.service.ts b/src/app/services/sleep-detection-automations/sleep-mode-change-on-steamvr-status-automation.service.ts new file mode 100644 index 00000000..f504f563 --- /dev/null +++ b/src/app/services/sleep-detection-automations/sleep-mode-change-on-steamvr-status-automation.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@angular/core'; +import { AutomationConfigService } from '../automation-config.service'; +import { OpenVRService } from '../openvr.service'; +import { + AUTOMATION_CONFIGS_DEFAULT, + SleepModeChangeOnSteamVRStatusAutomationConfig, +} from '../../models/automations'; +import { cloneDeep } from 'lodash'; +import { debounceTime, map, pairwise, tap } from 'rxjs'; +import { SleepService } from '../sleep.service'; + +@Injectable({ + providedIn: 'root', +}) +export class SleepModeChangeOnSteamVRStatusAutomationService { + private config: SleepModeChangeOnSteamVRStatusAutomationConfig = cloneDeep( + AUTOMATION_CONFIGS_DEFAULT.SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS + ); + + constructor( + private automationConfig: AutomationConfigService, + private openvr: OpenVRService, + private sleep: SleepService + ) {} + + async init() { + this.automationConfig.configs + .pipe(map((configs) => configs.SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS)) + .subscribe((config) => (this.config = config)); + + this.openvr.status + .pipe( + map((status) => status === 'INITIALIZED'), + debounceTime(2000), + pairwise(), + tap(([initializedBefore, initializedAfter]) => { + if (initializedBefore && !initializedAfter && this.config.disableOnSteamVRStop) { + this.sleep.disableSleepMode({ + type: 'AUTOMATION', + automation: 'SLEEP_MODE_CHANGE_ON_STEAMVR_STATUS', + }); + } + }) + ) + .subscribe(); + } +} diff --git a/src/app/services/sleep-detection-automations/sleep-mode-for-sleep-detector-automation.service.ts b/src/app/services/sleep-detection-automations/sleep-mode-for-sleep-detector-automation.service.ts new file mode 100644 index 00000000..2a1083bc --- /dev/null +++ b/src/app/services/sleep-detection-automations/sleep-mode-for-sleep-detector-automation.service.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@angular/core'; +import { AutomationConfigService } from '../automation-config.service'; +import { listen } from '@tauri-apps/api/event'; +import { + AUTOMATION_CONFIGS_DEFAULT, + SleepModeEnableForSleepDetectorAutomationConfig, +} from '../../models/automations'; +import { cloneDeep } from 'lodash'; +import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; +import { SleepService } from '../sleep.service'; +import { SleepDetectorStateReport } from '../../models/events'; +import { NotificationService } from '../notification.service'; +import { TranslateService } from '@ngx-translate/core'; + +@Injectable({ + providedIn: 'root', +}) +export class SleepModeForSleepDetectorAutomationService { + private sleepEnableTimeoutId: number | null = null; + private lastEnableAttempt = 0; + private enableConfig: SleepModeEnableForSleepDetectorAutomationConfig = cloneDeep( + AUTOMATION_CONFIGS_DEFAULT.SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR + ); + private _lastStateReport: BehaviorSubject = + new BehaviorSubject(null); + + public lastStateReport: Observable = + this._lastStateReport.asObservable(); + + private calibrationFactors: { [key: string]: number } = { + LOWEST: 100, + LOW: 150, + MEDIUM: 200, + HIGH: 250, + HIGHEST: 300, + }; + + constructor( + private automationConfig: AutomationConfigService, + private sleep: SleepService, + private notifications: NotificationService, + private translate: TranslateService + ) {} + + async init() { + this.automationConfig.configs.subscribe( + (configs) => (this.enableConfig = configs.SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR) + ); + await listen('SLEEP_DETECTOR_STATE_REPORT', (event) => + this.handleStateReportForEnable(event.payload) + ); + await listen<{ gesture: string }>('GESTURE_DETECTED', async (event) => { + if (event.payload.gesture !== 'head_shake') return; + if (this.sleepEnableTimeoutId) { + clearTimeout(this.sleepEnableTimeoutId); + this.sleepEnableTimeoutId = null; + await this.notifications.play_sound('bell'); + await this.notifications.send( + this.translate.instant('notifications.sleepCheckCancel.title'), + this.translate.instant('notifications.sleepCheckCancel.content') + ); + } + }); + } + + async handleStateReportForEnable(report: SleepDetectorStateReport) { + this._lastStateReport.next(report); + // Stop here if the automation is disabled + if (!this.enableConfig.enabled) return; + // Stop here if the sleep mode is already enabled + if (await firstValueFrom(this.sleep.mode)) return; + // Stop here if the sleep detection has been running for less than 15 minutes + if (Date.now() - report.startTime < 1000 * 60 * 15) return; + // Stop here if the positional movement was too high in the past 15 minutes + if ( + report.distanceInLast15Minutes > + this.enableConfig.calibrationValue * this.calibrationFactors[this.enableConfig.sensitivity] + ) + return; + // Stop here if the last time we tried enabling was less than 15 minutes ago + if (Date.now() - this.lastEnableAttempt < 1000 * 60 * 1500) return; + // Attempt enabling sleep mode + this.lastEnableAttempt = Date.now(); + // If necessary, first check if the user is asleep, allowing them to cancel. + if (this.enableConfig.sleepCheck) { + await this.notifications.send( + this.translate.instant('notifications.sleepCheck.title'), + this.translate.instant('notifications.sleepCheck.content') + ); + if (this.sleepEnableTimeoutId) return; + this.sleepEnableTimeoutId = setTimeout(async () => { + this.sleepEnableTimeoutId = null; + await this.sleep.enableSleepMode({ + type: 'AUTOMATION', + automation: 'SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR', + }); + }, 20000) as unknown as number; + } + // Otherwise, just enable sleep mode straight away. + else { + await this.sleep.enableSleepMode({ + type: 'AUTOMATION', + automation: 'SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR', + }); + } + } + + async test() {} + + async calibrate(): Promise { + let distanceInLast10Seconds = -1; + if (this._lastStateReport.value) { + if (Date.now() - this._lastStateReport.value.startTime > 1000 * 10) { + distanceInLast10Seconds = this._lastStateReport.value.distanceInLast10Seconds; + } + } + if (distanceInLast10Seconds > 0) { + await this.automationConfig.updateAutomationConfig( + 'SLEEP_MODE_ENABLE_FOR_SLEEP_DETECTOR', + { + calibrationValue: distanceInLast10Seconds, + } + ); + } + return distanceInLast10Seconds; + } +} diff --git a/src/app/services/sleep.service.ts b/src/app/services/sleep.service.ts index 1ea48a73..ea1972d7 100644 --- a/src/app/services/sleep.service.ts +++ b/src/app/services/sleep.service.ts @@ -21,6 +21,8 @@ import { OVRDevicePose } from '../models/ovr-device'; import { SleepingPoseDetector } from '../utils/sleeping-pose-detector'; import * as THREE from 'three'; import { info } from 'tauri-plugin-log-api'; +import { NotificationService } from './notification.service'; +import { TranslateService } from '@ngx-translate/core'; export const SETTINGS_KEY_SLEEP_MODE = 'SLEEP_MODE'; @@ -53,7 +55,11 @@ export class SleepService { this.forcePose$ ).pipe(startWith('UNKNOWN' as SleepingPose), distinctUntilChanged()) as Observable; - constructor(private openvr: OpenVRService) {} + constructor( + private openvr: OpenVRService, + private notifications: NotificationService, + private translate: TranslateService + ) {} async init() { this._mode.next((await this.store.get(SETTINGS_KEY_SLEEP_MODE)) || false); @@ -74,6 +80,10 @@ export class SleepService { this._mode.next(true); await this.store.set(SETTINGS_KEY_SLEEP_MODE, true); await this.store.save(); + await this.notifications.send( + this.translate.instant('notifications.sleepModeEnabled.title'), + this.translate.instant('notifications.sleepModeEnabled.content') + ); } async disableSleepMode(reason: SleepModeStatusChangeReason) { @@ -83,6 +93,10 @@ export class SleepService { this._mode.next(false); await this.store.set(SETTINGS_KEY_SLEEP_MODE, false); await this.store.save(); + await this.notifications.send( + this.translate.instant('notifications.sleepModeDisabled.title'), + this.translate.instant('notifications.sleepModeDisabled.content') + ); } private getSleepingPoseForDevicePose(pose: OVRDevicePose): SleepingPose { diff --git a/src/app/services/vrchat.service.ts b/src/app/services/vrchat.service.ts index 591ae951..0f923a48 100644 --- a/src/app/services/vrchat.service.ts +++ b/src/app/services/vrchat.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Body, Client, getClient, Response, ResponseType } from '@tauri-apps/api/http'; +import { Body, Client, getClient, HttpOptions, Response, ResponseType } from '@tauri-apps/api/http'; import { APIConfig, CurrentUser, LimitedUser, Notification, UserStatus } from 'vrchat/dist'; import { parse as parseSetCookieHeader } from 'set-cookie-parser'; import { Store } from 'tauri-plugin-store-api'; @@ -90,7 +90,7 @@ export class VRChatService { } async init() { - this.http = await getClient(); + this.http = await this.patchHttpClient(await getClient()); // Load settings from disk await this.loadSettings(); // Construct user agent @@ -170,7 +170,6 @@ export class VRChatService { if (!authCookie || (authCookieExpiry && authCookieExpiry < Date.now() / 1000)) throw new Error('Called verify2FA() before successfully calling login()'); const headers = this.getDefaultHeaders(); - info(`[VRChat] API Request: /auth/twofactorauth/${method}/verify`); const response = await this.http.post( `${BASE_URL}/auth/twofactorauth/${method}/verify`, Body.json({ code }), @@ -220,7 +219,6 @@ export class VRChatService { { typeId: 'STATUS_CHANGE', runnable: () => { - info(`[VRChat] API Request: /users/${userId}`); return this.http.put(`${BASE_URL}/users/${userId}`, Body.json({ status }), { headers: this.getDefaultHeaders(), }); @@ -274,7 +272,6 @@ export class VRChatService { const result = await this.apiCallQueue.queueTask>({ typeId: 'DELETE_NOTIFICATION', runnable: () => { - info(`[VRChat] API Request: /auth/user/notifications/${notificationId}/hide`); return this.http.put( `${BASE_URL}/auth/user/notifications/${notificationId}/hide`, undefined, @@ -308,7 +305,6 @@ export class VRChatService { const response = await this.apiCallQueue.queueTask>({ typeId: 'INVITE', runnable: () => { - info(`[VRChat] API Request: /invite/${inviteeId}`); return this.http.post(`${BASE_URL}/invite/${inviteeId}`, Body.json({ instanceId }), { headers: this.getDefaultHeaders(), }); @@ -339,7 +335,6 @@ export class VRChatService { const response = await this.apiCallQueue.queueTask>({ typeId: 'LIST_FRIENDS', runnable: () => { - info(`[VRChat] API Request: /auth/user/friends`); return this.http.get(`${BASE_URL}/auth/user/friends`, { headers: this.getDefaultHeaders(), query: { @@ -518,7 +513,6 @@ export class VRChatService { } } // Request the current user - info(`[VRChat] API Request: /auth/user`); const response = await this.http.get( `${BASE_URL}/auth/user`, { @@ -576,8 +570,6 @@ export class VRChatService { } private async fetchApiConfig() { - info('[VRChat] Fetching API config'); - info('[VRChat] API Request: /config'); const response = await this.http.get(`${BASE_URL}/config`, { responseType: ResponseType.JSON, headers: this.getDefaultHeaders(), @@ -671,4 +663,28 @@ export class VRChatService { await this.store.set(SETTINGS_KEY_VRCHAT_API, this.settings.value); await this.store.save(); } + + private async patchHttpClient(client: Client): Promise { + const isDev = (await getVersion()) === 'DEV'; + const next = client.request.bind(client); + async function requestWrapper(options: HttpOptions): Promise> { + info(`[VRChat] API Request: ${options.url}`); + if (isDev) + console.log(`[DEBUG] [VRChat] API Request: ${options.method} ${options.url}`, options); + try { + const response = await next(options); + if (isDev) + console.log( + `[DEBUG] [VRChat] API Response (${response.status}): ${options.method} ${options.url}`, + response + ); + return response; + } catch (e) { + error(`[VRChat] HTTP Request Error: ${e}`); + throw e; + } + } + client.request = requestWrapper.bind(client); + return client; + } } diff --git a/src/app/utils/regex-utils.ts b/src/app/utils/regex-utils.ts new file mode 100644 index 00000000..8689e6b3 --- /dev/null +++ b/src/app/utils/regex-utils.ts @@ -0,0 +1,17 @@ +export function isValidIPv6(value: string): boolean { + return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/i.test( + value + ); +} + +export function isValidIPv4(value: string): boolean { + return /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test( + value + ); +} + +export function isValidHostname(value: string): boolean { + return /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/.test( + value + ); +} diff --git a/src/app/views/dashboard-view/views/about-view/about-view.component.html b/src/app/views/dashboard-view/views/about-view/about-view.component.html index 1bee8f52..95aeaf01 100644 --- a/src/app/views/dashboard-view/views/about-view/about-view.component.html +++ b/src/app/views/dashboard-view/views/about-view/about-view.component.html @@ -15,7 +15,11 @@
- +
about.author.role
@@ -39,12 +43,8 @@
about.translations
- Raphiiko | English - -
-
- Raphiiko | Nederlands - + なき | 日本語 +
Outsourced | 日本語 @@ -62,6 +62,14 @@ 狐Kon | 简体中文
+
+ Raphiiko | English + +
+
+ Raphiiko | Nederlands + +
about.projectLinks
+

Loading Report...

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Start Time{{ report.startTime }}Last Log{{ report.lastLog }}
DistanceRotation
Last 10s{{ report.distanceInLast10Seconds }}mLast 10s{{ report.rotationInLast10Seconds }}°
Last 1m{{ report.distanceInLast1Minute }}mLast 1m{{ report.rotationInLast1Minute }}°
Last 5m{{ report.distanceInLast5Minutes }}mLast 5m{{ report.rotationInLast5Minutes }}°
Last 10m{{ report.distanceInLast10Minutes }}mLast 10m{{ report.rotationInLast10Minutes }}°
Last 15m{{ report.distanceInLast15Minutes }}mLast 15m{{ report.rotationInLast15Minutes }}°
+ diff --git a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.scss b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.scss index 94807dc8..7756c76f 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.scss +++ b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.scss @@ -15,3 +15,19 @@ padding-bottom: 1em; } + +.sleeping-detection-table { + width: 100%; + border: 1px solid var(--color-surface-5); + border-radius: var(--surface-border-radius); + + td { + padding: 0.5em; + border: 1px solid var(--color-surface-5); + border-radius: var(--surface-border-radius); + } + &, + * { + user-select: all !important; + } +} diff --git a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.ts b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.ts index d9b6baa7..d197eed2 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.ts +++ b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.ts @@ -12,6 +12,7 @@ import { SETTINGS_KEY_AUTOMATION_CONFIGS } from '../../../../../services/automat import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { error } from 'tauri-plugin-log-api'; +import { SleepModeForSleepDetectorAutomationService } from '../../../../../services/sleep-detection-automations/sleep-mode-for-sleep-detector-automation.service'; @Component({ selector: 'app-settings-debug-tab', @@ -27,7 +28,8 @@ export class SettingsDebugTabComponent extends SettingsTabComponent { constructor( settingsService: AppSettingsService, private router: Router, - private translate: TranslateService + private translate: TranslateService, + protected sleepModeForSleepDetectorAutomationService: SleepModeForSleepDetectorAutomationService ) { super(settingsService); } diff --git a/src/app/views/dashboard-view/views/settings-view/settings-general-tab/settings-general-tab.component.html b/src/app/views/dashboard-view/views/settings-view/settings-general-tab/settings-general-tab.component.html index 45d3f021..2660bee7 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-general-tab/settings-general-tab.component.html +++ b/src/app/views/dashboard-view/views/settings-view/settings-general-tab/settings-general-tab.component.html @@ -55,39 +55,43 @@

settings.general.lighthouseConsole.title

settings.general.adminPrivileges.title

-
-
- settings.general.adminPrivileges.label - settings.general.adminPrivileges.description -
-
- +
+
+
+ settings.general.adminPrivileges.label + settings.general.adminPrivileges.description +
+
+ +

settings.general.telemetry.title

-
-
- settings.general.telemetry.label - settings.general.telemetry.description -
-
- +
+
+
+ settings.general.telemetry.label + settings.general.telemetry.description +
+
+ +
diff --git a/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.html b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.html new file mode 100644 index 00000000..41d8f3dc --- /dev/null +++ b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.html @@ -0,0 +1,43 @@ +
+

settings.notifications.providers.title

+

settings.notifications.providers.description

+
+
+
+ settings.notifications.providers.xsoverlay.title + settings.notifications.providers.xsoverlay.description +
+
+ +
+
+ +
+
+ settings.notifications.providers.desktop.title + settings.notifications.providers.desktop.description +
+
+ +
+
+
+
diff --git a/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.scss b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.scss new file mode 100644 index 00000000..b75a0130 --- /dev/null +++ b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.scss @@ -0,0 +1 @@ +@import '../settings-tab/settings-tab.component.scss'; diff --git a/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.spec.ts b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.spec.ts new file mode 100644 index 00000000..704d44c7 --- /dev/null +++ b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SettingsNotificationsTabComponent } from './settings-notifications-tab.component'; + +describe('SettingsVrchatTabComponent', () => { + let component: SettingsNotificationsTabComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SettingsNotificationsTabComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SettingsNotificationsTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.ts b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.ts new file mode 100644 index 00000000..51975681 --- /dev/null +++ b/src/app/views/dashboard-view/views/settings-view/settings-notifications-tab/settings-notifications-tab.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { SettingsTabComponent } from '../settings-tab/settings-tab.component'; +import { AppSettingsService } from '../../../../../services/app-settings.service'; +import { hshrink, vshrink } from '../../../../../utils/animations'; +import { NotificationService } from '../../../../../services/notification.service'; + +@Component({ + selector: 'app-settings-notifications-tab', + templateUrl: './settings-notifications-tab.component.html', + styleUrls: ['./settings-notifications-tab.component.scss'], + animations: [vshrink(), hshrink()], +}) +export class SettingsNotificationsTabComponent extends SettingsTabComponent { + constructor(settingsService: AppSettingsService, protected notifications: NotificationService) { + super(settingsService); + } + + override async ngOnInit() { + super.ngOnInit(); + } +} diff --git a/src/app/views/dashboard-view/views/settings-view/settings-tab/settings-tab.component.scss b/src/app/views/dashboard-view/views/settings-view/settings-tab/settings-tab.component.scss index 10c5f15e..45aef062 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-tab/settings-tab.component.scss +++ b/src/app/views/dashboard-view/views/settings-view/settings-tab/settings-tab.component.scss @@ -13,39 +13,16 @@ padding: 0.125em 0.25em; font-weight: 500; } -} - -.setting-row { - display: flex; - flex-direction: row; - align-items: center; - background: var(--color-surface-1); - padding: 1em; - border-radius: 0.5em; - .setting-row-label { + h2 { display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - flex: 1; - - > span:first-child { - color: var(--color-text-1); - margin-bottom: 0.25em; - } + flex-direction: row; + align-items: center; + justify-content: space-between; - > span:last-child { - color: var(--color-text-3); + button { + margin-left: 1em; + font-size: 0.6em; } } - - .setting-row-action { - flex-shrink: 0; - margin-left: 1em; - padding: 0 1em; - display: flex; - flex-direction: column; - justify-content: center; - } } diff --git a/src/app/views/dashboard-view/views/settings-view/settings-view.component.html b/src/app/views/dashboard-view/views/settings-view/settings-view.component.html index 7df6013c..4d0446a3 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-view.component.html +++ b/src/app/views/dashboard-view/views/settings-view/settings-view.component.html @@ -7,6 +7,13 @@
settings.tab.vrchat
+
+
settings.tab.notifications
+
+ = new Subject(); - activeTab: 'GENERAL' | 'VRCHAT' | 'UPDATES' | 'DEBUG' = 'GENERAL'; + activeTab: SettingsTab = 'GENERAL'; - constructor(private update: UpdateService) {} + constructor(private update: UpdateService, private activatedRoute: ActivatedRoute) {} async ngOnInit() { this.update.updateAvailable.pipe(takeUntil(this.destroy$)).subscribe((available) => { this.updateAvailable = available; }); + const fragment = await firstValueFrom(this.activatedRoute.fragment); + if (fragment) this.activeTab = fragment as SettingsTab; } ngOnDestroy() { diff --git a/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.html b/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.html index bc797220..fbb96b3b 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.html +++ b/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.html @@ -38,3 +38,202 @@

settings.vrchat.logIn.title

settings.vrchat.logIn.disclaimers.brand

settings.vrchat.logIn.disclaimers.liability

+
+

+ settings.vrchat.OSC.title + +

+

settings.vrchat.OSC.description

+
+
+
+ settings.vrchat.OSC.receivingHost.title + settings.vrchat.OSC.receivingHost.description +
+
+ check + error +
+ +
+
+
+
+
+
+ error +
+
{{ + 'settings.vrchat.OSC.errors.' + oscReceivingHostError | translate + }}
+
+
+
+
+ settings.vrchat.OSC.receivingPort.title + settings.vrchat.OSC.receivingPort.description +
+
+ check + error +
+ +
+
+
+
+
+
+ error +
+
{{ + 'settings.vrchat.OSC.errors.' + oscReceivingPortError | translate + }}
+
+
+
+
+ settings.vrchat.OSC.sendingHost.title + settings.vrchat.OSC.sendingHost.description +
+
+ check + error +
+ +
+
+
+
+
+
+ error +
+
{{ + 'settings.vrchat.OSC.errors.' + oscSendingHostError | translate + }}
+
+
+
+
+ settings.vrchat.OSC.sendingPort.title + settings.vrchat.OSC.sendingPort.description +
+
+ check + error +
+ +
+
+
+
+
+
+ error +
+
{{ + 'settings.vrchat.OSC.errors.' + oscSendingPortError | translate + }}
+
+
+
+
diff --git a/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.ts b/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.ts index 8a204562..32641315 100644 --- a/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.ts +++ b/src/app/views/dashboard-view/views/settings-view/settings-vrchat-tab/settings-vrchat-tab.component.ts @@ -1,40 +1,258 @@ import { Component } from '@angular/core'; import { SettingsTabComponent } from '../settings-tab/settings-tab.component'; import { AppSettingsService } from '../../../../../services/app-settings.service'; -import { vshrink } from '../../../../../utils/animations'; +import { hshrink, vshrink } from '../../../../../utils/animations'; import { VRChatService, VRChatServiceStatus } from '../../../../../services/vrchat.service'; -import { takeUntil } from 'rxjs'; +import { + combineLatest, + debounceTime, + distinctUntilChanged, + map, + of, + startWith, + Subject, + switchMap, + takeUntil, + tap, +} from 'rxjs'; import { CurrentUser as VRChatUser } from 'vrchat/dist'; import { SimpleModalService } from 'ngx-simple-modal'; +import { OscService } from '../../../../../services/osc.service'; +import { isValidHostname, isValidIPv4, isValidIPv6 } from '../../../../../utils/regex-utils'; +import { APP_SETTINGS_DEFAULT } from '../../../../../models/settings'; @Component({ selector: 'app-settings-vrchat-tab', templateUrl: './settings-vrchat-tab.component.html', styleUrls: ['./settings-vrchat-tab.component.scss'], - animations: [vshrink()], + animations: [vshrink(), hshrink()], }) export class SettingsVRChatTabComponent extends SettingsTabComponent { - vrchatStatus: VRChatServiceStatus = 'PRE_INIT'; - currentUser: VRChatUser | null = null; + // VRChat Account + protected vrchatStatus: VRChatServiceStatus = 'PRE_INIT'; + protected currentUser: VRChatUser | null = null; + + // Sending Host + protected oscSendingHost = ''; + protected oscSendingHostChange: Subject = new Subject(); + protected oscSendingHostStatus: 'INIT' | 'OK' | 'CHECKING' | 'ERROR' = 'INIT'; + protected oscSendingHostError?: string; + // Sending Port + protected oscSendingPort = 0; + protected oscSendingPortChange: Subject = new Subject(); + protected oscSendingPortStatus: 'INIT' | 'OK' | 'CHECKING' | 'ERROR' = 'INIT'; + protected oscSendingPortError?: string; + // Receiving Host + protected oscReceivingHost = ''; + protected oscReceivingHostChange: Subject = new Subject(); + protected oscReceivingHostStatus: 'INIT' | 'OK' | 'CHECKING' | 'ERROR' = 'INIT'; + protected oscReceivingHostError?: string; + // Receiving Port + protected oscReceivingPort = 0; + protected oscReceivingPortChange: Subject = new Subject(); + protected oscReceivingPortStatus: 'INIT' | 'OK' | 'CHECKING' | 'ERROR' = 'INIT'; + protected oscReceivingPortError?: string; constructor( settingsService: AppSettingsService, private vrchat: VRChatService, - private modalService: SimpleModalService + private modalService: SimpleModalService, + private osc: OscService ) { super(settingsService); } override async ngOnInit() { super.ngOnInit(); - this.vrchat.status.pipe(takeUntil(this.destroy$)).subscribe((status) => { - this.vrchatStatus = status; - }); - this.vrchat.user.pipe(takeUntil(this.destroy$)).subscribe((user) => { - this.currentUser = user; + // Listen for account changes + this.vrchat.status + .pipe(takeUntil(this.destroy$)) + .subscribe((status) => (this.vrchatStatus = status)); + this.vrchat.user.pipe(takeUntil(this.destroy$)).subscribe((user) => (this.currentUser = user)); + this.listenForReceivingHostChanges(); + this.listenForReceivingPortChanges(); + this.listenForSendingHostChanges(); + this.listenForSendingPortChanges(); + this.listenForSettingsChanges(); + } + + listenForSettingsChanges() { + this.settingsService.settings.pipe(takeUntil(this.destroy$)).subscribe((settings) => { + if (settings.oscReceivingHost !== this.oscReceivingHost) { + this.oscReceivingHost = settings.oscReceivingHost; + this.oscReceivingHostChange.next(this.oscReceivingHost); + } + if (settings.oscReceivingPort !== this.oscReceivingPort) { + this.oscReceivingPort = settings.oscReceivingPort; + this.oscReceivingPortChange.next(this.oscReceivingPort + ''); + } + if (settings.oscSendingHost !== this.oscSendingHost) { + this.oscSendingHost = settings.oscSendingHost; + this.oscSendingHostChange.next(this.oscSendingHost); + } + if (settings.oscSendingPort !== this.oscSendingPort) { + this.oscSendingPort = settings.oscSendingPort; + this.oscSendingPortChange.next(this.oscSendingPort + ''); + } }); } + listenForReceivingHostChanges() { + this.oscReceivingHostChange + .pipe( + takeUntil(this.destroy$), + distinctUntilChanged(), + switchMap((value) => combineLatest([of(value)]).pipe(map(([value]) => value))), + tap(() => { + this.oscReceivingHostStatus = 'CHECKING'; + this.oscReceivingHostError = undefined; + }), + debounceTime(300) + ) + .subscribe(async (host) => { + // Validate host + if (host === '' || !(isValidIPv6(host) || isValidIPv4(host) || isValidHostname(host))) { + this.oscReceivingHostStatus = 'ERROR'; + this.oscReceivingHostError = 'invalidHost'; + return; + } + // Try to bind + if (!(await this.osc.init_receiver(host, this.oscReceivingPort))) { + this.oscReceivingPortStatus = 'ERROR'; + this.oscReceivingPortError = 'bindFailed'; + return; + } + // Save new host + this.oscReceivingHost = host; + this.oscReceivingHostStatus = 'OK'; + this.settingsService.updateSettings({ + oscReceivingHost: host, + }); + }); + } + + listenForSendingHostChanges() { + this.oscSendingHostChange + .pipe( + takeUntil(this.destroy$), + distinctUntilChanged(), + switchMap((value) => combineLatest([of(value)]).pipe(map(([value]) => value))), + tap(() => { + this.oscSendingHostStatus = 'CHECKING'; + this.oscSendingHostError = undefined; + }), + debounceTime(300) + ) + .subscribe(async (host) => { + // Validate host + if (host === '' || !(isValidIPv6(host) || isValidIPv4(host) || isValidHostname(host))) { + this.oscSendingHostStatus = 'ERROR'; + this.oscSendingHostError = 'invalidHost'; + return; + } + // Save new host + this.oscSendingHost = host; + this.oscSendingHostStatus = 'OK'; + this.settingsService.updateSettings({ + oscSendingHost: host, + }); + }); + } + + listenForReceivingPortChanges() { + this.oscReceivingPortChange + .pipe( + takeUntil(this.destroy$), + distinctUntilChanged(), + switchMap((value) => + combineLatest([ + this.settingsService.settings.pipe( + map((settings) => settings.oscSendingPort), + startWith(this.oscSendingPort), + distinctUntilChanged() + ), + of(value), + ]).pipe(map(([_, value]) => value)) + ), + tap(() => { + this.oscReceivingPortStatus = 'CHECKING'; + this.oscReceivingPortError = undefined; + }), + debounceTime(300) + ) + .subscribe(async (value) => { + // Parse port + let port = parseInt(value); + if (isNaN(port) || port > 65535 || port <= 0) { + this.oscReceivingPortStatus = 'ERROR'; + this.oscReceivingPortError = 'invalidPort'; + return; + } + // Validate port + if (port === this.oscSendingPort && this.oscReceivingHost === this.oscSendingHost) { + this.oscReceivingPortStatus = 'ERROR'; + this.oscReceivingPortError = 'samePort'; + return; + } + // Try to bind + if (!(await this.osc.init_receiver(this.oscReceivingHost, port))) { + this.oscReceivingPortStatus = 'ERROR'; + this.oscReceivingPortError = 'bindFailed'; + return; + } + // Save new port + this.oscReceivingPort = port; + this.oscReceivingPortStatus = 'OK'; + this.settingsService.updateSettings({ + oscReceivingPort: port, + }); + }); + } + + listenForSendingPortChanges() { + this.oscSendingPortChange + .pipe( + takeUntil(this.destroy$), + distinctUntilChanged(), + switchMap((value) => + combineLatest([ + this.settingsService.settings.pipe( + map((settings) => settings.oscReceivingPort), + startWith(this.oscReceivingPort), + distinctUntilChanged() + ), + of(value), + ]).pipe(map(([_, value]) => value)) + ), + tap(() => { + this.oscSendingPortStatus = 'CHECKING'; + this.oscSendingPortError = undefined; + }), + debounceTime(300) + ) + .subscribe((value) => { + // Parse port + let port = parseInt(value); + if (isNaN(port) || port > 65535 || port <= 0) { + this.oscSendingPortStatus = 'ERROR'; + this.oscSendingPortError = 'invalidPort'; + return; + } + // Validate port + if (port === this.oscReceivingPort && this.oscReceivingHost === this.oscSendingHost) { + this.oscSendingPortStatus = 'ERROR'; + this.oscSendingPortError = 'samePort'; + return; + } + // Save new port + this.oscSendingPort = port; + this.oscSendingPortStatus = 'OK'; + this.settingsService.updateSettings({ + oscSendingPort: port, + }); + }); + } + login() { this.vrchat.showLoginModal(); } @@ -42,4 +260,11 @@ export class SettingsVRChatTabComponent extends SettingsTabComponent { async logout() { await this.vrchat.logout(); } + + resetOSCAddresses() { + this.oscSendingHostChange.next(APP_SETTINGS_DEFAULT.oscSendingHost); + this.oscSendingPortChange.next(APP_SETTINGS_DEFAULT.oscSendingPort + ''); + this.oscReceivingHostChange.next(APP_SETTINGS_DEFAULT.oscReceivingHost); + this.oscReceivingPortChange.next(APP_SETTINGS_DEFAULT.oscReceivingPort + ''); + } } diff --git a/src/app/views/dashboard-view/views/sleep-detection-view/battery-percentage-enable-sleepmode-modal/battery-percentage-enable-sleep-mode-modal.component.html b/src/app/views/dashboard-view/views/sleep-detection-view/battery-percentage-enable-sleepmode-modal/battery-percentage-enable-sleep-mode-modal.component.html index e755f0b1..1cfee314 100644 --- a/src/app/views/dashboard-view/views/sleep-detection-view/battery-percentage-enable-sleepmode-modal/battery-percentage-enable-sleep-mode-modal.component.html +++ b/src/app/views/dashboard-view/views/sleep-detection-view/battery-percentage-enable-sleepmode-modal/battery-percentage-enable-sleep-mode-modal.component.html @@ -9,7 +9,7 @@