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:
+
@@ -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 @@
- {{ title || 'comp.confirm-modal.defaultTitle' | translate }}
+ {{ title || 'comp.confirm-modal.defaultTitle' | tsTranslate }}
- {{ cancelButtonText || 'shared.modals.cancel' | translate }}
+ {{ cancelButtonText || 'shared.modals.cancel' | tsTranslate }}
- {{ confirmButtonText || 'shared.modals.confirm' | translate }}
+ {{ confirmButtonText || 'shared.modals.confirm' | tsTranslate }}
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
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 | Nederlands
-
+
なき | 日本語
+
Outsourced | 日本語
@@ -62,6 +62,14 @@
狐Kon | 简体中文
+
+
+
Raphiiko | Nederlands
+
+
about.projectLinks
diff --git a/src/app/views/dashboard-view/views/auto-invite-request-accept-view/auto-invite-request-accept-view.component.html b/src/app/views/dashboard-view/views/auto-invite-request-accept-view/auto-invite-request-accept-view.component.html
index 347b53c6..a2ace93c 100644
--- a/src/app/views/dashboard-view/views/auto-invite-request-accept-view/auto-invite-request-accept-view.component.html
+++ b/src/app/views/dashboard-view/views/auto-invite-request-accept-view/auto-invite-request-accept-view.component.html
@@ -93,6 +93,7 @@
p.id !== player.id);
- await this.updateConfig({ playerIds: this.playerList.map((p) => p.id) });
+ this.modalService
+ .addModal(ConfirmModalComponent, {
+ title: 'auto-invite-request-accept.removeModal.title',
+ message: {
+ string: 'auto-invite-request-accept.removeModal.message',
+ values: { name: player.displayName },
+ },
+ })
+ .subscribe(async (data) => {
+ if (data.confirmed) {
+ this.playerList = this.playerList.filter((p) => p.id !== player.id);
+ await this.updateConfig({ playerIds: this.playerList.map((p) => p.id) });
+ }
+ });
}
async clearPlayers() {
diff --git a/src/app/views/dashboard-view/views/gpu-automations-view/gpu-automations-view.component.html b/src/app/views/dashboard-view/views/gpu-automations-view/gpu-automations-view.component.html
index 87323b41..db4af3f3 100644
--- a/src/app/views/dashboard-view/views/gpu-automations-view/gpu-automations-view.component.html
+++ b/src/app/views/dashboard-view/views/gpu-automations-view/gpu-automations-view.component.html
@@ -20,7 +20,14 @@
(click)="activeTab = 'POWER_LIMITS'"
>
- error_outline
+
+ error_outline
+
gpu-automations.tabs.powerLimiting
@@ -30,7 +37,14 @@
(click)="activeTab = 'MSI_AFTERBURNER'"
>
- error_outline
+
+ error_outline
+
gpu-automations.tabs.msiAfterburner
diff --git a/src/app/views/dashboard-view/views/gpu-automations-view/msi-afterburner-pane/msi-afterburner-pane.component.html b/src/app/views/dashboard-view/views/gpu-automations-view/msi-afterburner-pane/msi-afterburner-pane.component.html
index 59b539a7..defa4ccf 100644
--- a/src/app/views/dashboard-view/views/gpu-automations-view/msi-afterburner-pane/msi-afterburner-pane.component.html
+++ b/src/app/views/dashboard-view/views/gpu-automations-view/msi-afterburner-pane/msi-afterburner-pane.component.html
@@ -1,7 +1,7 @@
-
+
gpu-automations.msiAfterburner.executable.title
diff --git a/src/app/views/dashboard-view/views/overview-view/overview-view.component.ts b/src/app/views/dashboard-view/views/overview-view/overview-view.component.ts
index e286bd96..711347ab 100644
--- a/src/app/views/dashboard-view/views/overview-view/overview-view.component.ts
+++ b/src/app/views/dashboard-view/views/overview-view/overview-view.component.ts
@@ -14,9 +14,7 @@ import { OscService } from '../../../../services/osc.service';
export class OverviewViewComponent implements OnInit, OnDestroy {
destroy$: Subject
= new Subject();
sleepModeActive = false;
- wew = false;
quaternion: [number, number, number, number] = [0, 0, 0, 0];
- credentials: any = {};
constructor(private sleep: SleepService, public openvr: OpenVRService, public osc: OscService) {}
diff --git a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.html b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.html
index e24f0594..83c6cb09 100644
--- a/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.html
+++ b/src/app/views/dashboard-view/views/settings-view/settings-debug-tab/settings-debug-tab.component.html
@@ -43,3 +43,56 @@ settings.debug.language.title
settings.debug.sleepingPose.title
+
+
settings.debug.sleepingDetection.title
+
+ Loading Report...
+
+
+ Start Time
+ {{ report.startTime }}
+ Last Log
+ {{ report.lastLog }}
+
+
+ Distance
+
+ Rotation
+
+
+
+ Last 10s
+ {{ report.distanceInLast10Seconds }}m
+ Last 10s
+ {{ report.rotationInLast10Seconds }}°
+
+
+ Last 1m
+ {{ report.distanceInLast1Minute }}m
+ Last 1m
+ {{ report.rotationInLast1Minute }}°
+
+
+ Last 5m
+ {{ report.distanceInLast5Minutes }}m
+ Last 5m
+ {{ report.rotationInLast5Minutes }}°
+
+
+ Last 10m
+ {{ report.distanceInLast10Minutes }}m
+ Last 10m
+ {{ report.rotationInLast10Minutes }}°
+
+
+ Last 15m
+ {{ report.distanceInLast15Minutes }}m
+ Last 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.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.reset
+
+
settings.vrchat.OSC.description
+
+
+
+ settings.vrchat.OSC.receivingHost.title
+ settings.vrchat.OSC.receivingHost.description
+
+
+
+
+
+
+
+ error
+
+
{{
+ 'settings.vrchat.OSC.errors.' + oscReceivingHostError | translate
+ }}
+
+
+
+
+ settings.vrchat.OSC.receivingPort.title
+ settings.vrchat.OSC.receivingPort.description
+
+
+
+
+
+
+
+ error
+
+
{{
+ 'settings.vrchat.OSC.errors.' + oscReceivingPortError | translate
+ }}
+
+
+
+
+ settings.vrchat.OSC.sendingHost.title
+ settings.vrchat.OSC.sendingHost.description
+
+
+
+
+
+
+
+ error
+
+
{{
+ 'settings.vrchat.OSC.errors.' + oscSendingHostError | translate
+ }}
+
+
+
+
+ settings.vrchat.OSC.sendingPort.title
+ settings.vrchat.OSC.sendingPort.description
+
+
+
+
+
+
+
+ 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 @@
- sleep-detection.modals.enableAtBatteryPercentage.optionController
+ sleep-detection.modals.enableAtBatteryPercentage.Controller
- sleep-detection.modals.enableAtBatteryPercentage.optionTracker
+ sleep-detection.modals.enableAtBatteryPercentage.GenericTracker
100) {
- el.setCustomValidity('The percentage has to be between 0% and 100%.');
+ el.setCustomValidity(
+ this.translate.instant(
+ 'sleep-detection.modals.enableAtBatteryPercentage.errors.thresholdOutOfRange'
+ )
+ );
return;
} else if (!isInteger(threshold)) {
- el.setCustomValidity('The percentage value has to be a whole number.');
+ el.setCustomValidity(
+ this.translate.instant(
+ 'sleep-detection.modals.enableAtBatteryPercentage.errors.thresholdNotInteger'
+ )
+ );
return;
} else {
el.setCustomValidity('');
diff --git a/src/app/views/dashboard-view/views/sleep-detection-view/device-poweron-disable-sleepmode-modal/device-power-on-disable-sleep-mode-modal.component.html b/src/app/views/dashboard-view/views/sleep-detection-view/device-poweron-disable-sleepmode-modal/device-power-on-disable-sleep-mode-modal.component.html
index c020b2e8..a7a28bf6 100644
--- a/src/app/views/dashboard-view/views/sleep-detection-view/device-poweron-disable-sleepmode-modal/device-power-on-disable-sleep-mode-modal.component.html
+++ b/src/app/views/dashboard-view/views/sleep-detection-view/device-poweron-disable-sleepmode-modal/device-power-on-disable-sleep-mode-modal.component.html
@@ -9,7 +9,7 @@
- sleep-detection.modals.disableOnDevicePowerOn.optionController
+ sleep-detection.modals.disableOnDevicePowerOn.Controller
- sleep-detection.modals.disableOnDevicePowerOn.optionTracker
+ sleep-detection.modals.disableOnDevicePowerOn.GenericTracker
sleep-detection.title
-
sleep-detection.description
-
-
-
-
sleep-detection.enableAutomations.title
+
+
sleep-detection.enableAutomations.title
+
sleep-detection.enableAutomations.description
+
+
+
+
+ sleep-detection.enableAutomations.onMotionDetection.title
+ warning_amber
+
+
+
+
+
+ settings
+
+
+
+
+
+
+
-
-