Compare commits

..

2 Commits

184 changed files with 2602 additions and 5733 deletions

View File

@@ -1,67 +0,0 @@
name: 'publish'
on:
push:
branches:
- release
# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm based macs (M1 and above).
args: '--target aarch64-apple-darwin'
# - platform: 'macos-latest' # for Intel based macs.
# args: '--target x86_64-apple-darwin'
# - platform: 'ubuntu-22.04'
# args: ''
- platform: 'windows-latest'
args: ''
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: setup pnpm
uses: pnpm/action-setup@v5
with:
version: 10
- name: setup node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
# Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# - name: install dependencies (ubuntu only)
# if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
# run: |
# sudo apt-get update
# sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: install frontend dependencies
run: pnpm install # change this to npm, pnpm or bun depending on which one you use.
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
releaseName: 'v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: false
prerelease: false
args: ${{ matrix.args }}

View File

@@ -1,6 +1,6 @@
{
"name": "friendolls-desktop",
"version": "0.1.4",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
@@ -16,7 +16,6 @@
"@tailwindcss/vite": "^4.1.17",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-updater": "~2.10.0",
"tailwindcss": "^4.1.17"
},
"devDependencies": {

15
pnpm-lock.yaml generated
View File

@@ -17,9 +17,6 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.2
'@tauri-apps/plugin-updater':
specifier: ~2.10.0
version: 2.10.0
tailwindcss:
specifier: ^4.1.17
version: 4.1.17
@@ -470,9 +467,6 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.9.0':
resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==}
@@ -550,9 +544,6 @@ packages:
'@tauri-apps/plugin-opener@2.5.2':
resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==}
'@tauri-apps/plugin-updater@2.10.0':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -1146,8 +1137,6 @@ snapshots:
tailwindcss: 4.1.17
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.9.0': {}
'@tauri-apps/cli-darwin-arm64@2.9.4':
@@ -1201,10 +1190,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.9.0
'@tauri-apps/plugin-updater@2.10.0':
dependencies:
'@tauri-apps/api': 2.10.1
'@types/cookie@0.6.0': {}
'@types/estree@1.0.8': {}

1
src-tauri/.env.example Normal file
View File

@@ -0,0 +1 @@
API_BASE_URL=http://127.0.0.1:3000

255
src-tauri/Cargo.lock generated
View File

@@ -72,15 +72,6 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ashpd"
version = "0.11.0"
@@ -810,17 +801,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -1154,17 +1134,6 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.5"
@@ -1240,7 +1209,7 @@ dependencies = [
[[package]]
name = "friendolls-desktop"
version = "0.1.4"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"device_query",
@@ -1269,13 +1238,11 @@ dependencies = [
"strum",
"tauri",
"tauri-build",
"tauri-macros",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-opener",
"tauri-plugin-positioner",
"tauri-plugin-process",
"tauri-plugin-updater",
"tauri-specta",
"thiserror 1.0.69",
"tokio",
@@ -1529,10 +1496,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1542,11 +1507,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1878,7 +1841,6 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1949,9 +1911,9 @@ dependencies = [
[[package]]
name = "ico"
version = "0.5.0"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
@@ -2339,7 +2301,6 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags 2.10.0",
"libc",
"redox_syscall",
]
[[package]]
@@ -2369,12 +2330,6 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lua-src"
version = "550.0.0"
@@ -2480,12 +2435,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2890,18 +2839,6 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-quartz-core"
version = "0.2.2"
@@ -3052,20 +2989,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.17",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3457,61 +3380,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.42"
@@ -3750,8 +3618,6 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3759,7 +3625,6 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3769,7 +3634,6 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3890,7 +3754,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3903,7 +3766,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
@@ -4642,17 +4504,6 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "tar"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4735,9 +4586,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.5.5"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -4762,9 +4613,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.5"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -4893,38 +4744,6 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.17",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@@ -5007,9 +4826,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.8.3"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
dependencies = [
"anyhow",
"brotli",
@@ -5168,21 +4987,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.48.0"
@@ -5913,16 +5717,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -5967,15 +5761,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.0"
@@ -6702,16 +6487,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xkbcommon"
version = "0.9.0"
@@ -6908,18 +6683,6 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.12.0",
"memchr",
]
[[package]]
name = "zvariant"
version = "5.8.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "friendolls-desktop"
version = "0.1.4"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -50,11 +50,9 @@ enigo = { version = "0.6.1", features = ["wayland"] }
lazy_static = "1.5.0"
mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde", "async"] }
petpet = "2.4.3"
tauri-macros = "2.5.5"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-positioner = "2"
tauri-plugin-updater = "2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
objc2-app-kit = { version = "0.3.2", features = [

View File

@@ -1,14 +0,0 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,51 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Cancelled</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #fff0cf, transparent 45%),
linear-gradient(180deg, #fffdf7 0%, #f8f2e6 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #55411d;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 80px rgba(120, 95, 35, 0.14);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #7a6237;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign-in cancelled</h1>
<p>You can close this tab and return to Friendolls to try again.</p>
</div>
</body>
</html>

View File

@@ -1,51 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Failed</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #ffd8d8, transparent 45%),
linear-gradient(180deg, #fff8f8 0%, #fceeee 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #4d2323;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 80px rgba(135, 57, 57, 0.14);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #7c4a4a;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign-in failed</h1>
<p>Friendolls could not complete the browser handshake. Return to the app and try again.</p>
</div>
</body>
</html>

View File

@@ -1,51 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Friendolls Sign-in Complete</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at top, #d8f8ff, transparent 45%),
linear-gradient(180deg, #f8feff 0%, #eef8fb 100%);
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #24414b;
}
.card {
width: min(420px, calc(100vw - 32px));
padding: 32px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 80px rgba(55, 113, 130, 0.18);
text-align: center;
}
h1 {
margin: 0 0 10px;
font-size: 28px;
}
p {
margin: 0;
line-height: 1.5;
color: #4b6973;
}
</style>
</head>
<body>
<div class="card">
<h1>Signed in</h1>
<p>You can close this browser tab and return to Friendolls.</p>
</div>
</body>
</html>

View File

@@ -1,7 +1,6 @@
use crate::get_app_handle;
use crate::init::lifecycle::validate_server_health;
use crate::init::lifecycle::{construct_user_session, validate_server_health};
use crate::services::auth::get_session_token;
use crate::services::session::construct_user_session;
use tracing::info;
#[tauri::command]

View File

@@ -1,15 +1,8 @@
use crate::{
lock_r,
models::{app_data::UserData, app_state::{AppState, NekoPosition}},
services::{
app_data::{init_app_data_scoped, AppDataRefreshScope},
app_state,
friends,
neko_positions,
presence_modules::models::ModuleMetadata,
sprite,
},
state::FDOLL,
models::app_data::UserData,
services::{presence_modules::models::ModuleMetadata, presence_state::PresenceStateSnapshot},
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
};
#[tauri::command]
@@ -36,44 +29,6 @@ pub fn get_modules() -> Result<Vec<ModuleMetadata>, String> {
#[tauri::command]
#[specta::specta]
pub fn get_active_doll_sprite_base64() -> Result<Option<String>, String> {
sprite::get_active_doll_sprite_base64()
}
#[tauri::command]
#[specta::specta]
pub fn get_friend_active_doll_sprites_base64() -> Result<friends::FriendActiveDollSpritesDto, String>
{
friends::sync_active_doll_sprites_from_app_data();
Ok(friends::get_active_doll_sprites_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn get_app_state() -> Result<AppState, String> {
Ok(app_state::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn get_neko_positions() -> Result<neko_positions::NekoPositionsDto, String> {
Ok(neko_positions::get_snapshot())
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
app_state::set_scene_setup_nekos_position(nekos_position);
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {
app_state::set_scene_setup_nekos_opacity(nekos_opacity);
}
#[tauri::command]
#[specta::specta]
pub fn set_scene_setup_nekos_scale(nekos_scale: f32) {
app_state::set_scene_setup_nekos_scale(nekos_scale);
pub fn get_presence_state() -> Result<PresenceStateSnapshot, String> {
Ok(crate::services::presence_state::get_presence_state_snapshot())
}

View File

@@ -8,16 +8,45 @@ pub async fn logout_and_restart() -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn start_google_auth() -> Result<(), String> {
auth::start_browser_login("google")
pub async fn login(email: String, password: String) -> Result<(), String> {
auth::login_and_init_session(&email, &password)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn start_discord_auth() -> Result<(), String> {
auth::start_browser_login("discord")
pub async fn register(
email: String,
password: String,
name: Option<String>,
username: Option<String>,
) -> Result<String, String> {
auth::register(
&email,
&password,
name.as_deref(),
username.as_deref(),
)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn change_password(
current_password: String,
new_password: String,
) -> Result<(), String> {
auth::change_password(&current_password, &new_password)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> {
auth::reset_password(&old_password, &new_password)
.await
.map_err(|e| e.to_string())
}

View File

@@ -1,6 +1,8 @@
use crate::{
lock_w,
services::client_config::{load_app_config, open_config_window, save_app_config, AppConfig},
services::client_config_manager::{
load_app_config, open_config_manager_window, save_app_config, AppConfig,
},
state::FDOLL,
};
@@ -27,6 +29,6 @@ pub fn save_client_config(config: AppConfig) -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn open_client_config() -> Result<(), String> {
open_config_window().map_err(|e| e.to_string())
pub async fn open_client_config_manager() -> Result<(), String> {
open_config_manager_window().map_err(|e| e.to_string())
}

View File

@@ -1,9 +1,16 @@
use crate::{
commands::{is_active_doll, refresh_app_data, refresh_app_data_conditionally},
get_app_handle,
models::dolls::{CreateDollDto, DollDto, UpdateDollDto},
remotes::{dolls::DollsRemote, user::UserRemote},
services::app_data::AppDataRefreshScope,
remotes::{
dolls::DollsRemote,
user::UserRemote,
},
state::AppDataRefreshScope,
commands::{refresh_app_data, refresh_app_data_conditionally, is_active_doll},
};
use crate::commands::scene::get_user_active_doll;
use crate::services::app_events::UserActiveDollUpdated;
use tauri_specta::Event as _;
#[tauri::command]
#[specta::specta]
@@ -50,8 +57,7 @@ pub async fn update_doll(id: String, dto: UpdateDollDto) -> Result<DollDto, Stri
refresh_app_data_conditionally(
&[AppDataRefreshScope::Dolls],
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
)
.await;
).await;
Ok(result)
}
@@ -70,8 +76,7 @@ pub async fn delete_doll(id: String) -> Result<(), String> {
refresh_app_data_conditionally(
&[AppDataRefreshScope::Dolls],
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
)
.await;
).await;
Ok(())
}
@@ -86,6 +91,9 @@ pub async fn set_active_doll(doll_id: String) -> Result<(), String> {
refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await;
let active_doll = get_user_active_doll().ok().flatten();
let _ = UserActiveDollUpdated(active_doll).emit(get_app_handle());
Ok(())
}
@@ -99,5 +107,8 @@ pub async fn remove_active_doll() -> Result<(), String> {
refresh_app_data(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]).await;
let active_doll = get_user_active_doll().ok().flatten();
let _ = UserActiveDollUpdated(active_doll).emit(get_app_handle());
Ok(())
}

View File

@@ -1,9 +1,9 @@
use crate::commands::refresh_app_data;
use crate::remotes::friends::FriendRemote;
use crate::models::friends::{
FriendRequestResponseDto, FriendshipResponseDto, SendFriendRequestDto, UserBasicDto,
};
use crate::remotes::friends::FriendRemote;
use crate::services::app_data::AppDataRefreshScope;
use crate::state::AppDataRefreshScope;
use crate::commands::refresh_app_data;
#[tauri::command]
#[specta::specta]

View File

@@ -5,14 +5,12 @@ pub mod config;
pub mod dolls;
pub mod friends;
pub mod interaction;
pub mod petpet;
pub mod sprite;
pub mod petpet;
pub mod scene;
use crate::lock_r;
use crate::{
services::app_data::{init_app_data_scoped, AppDataRefreshScope},
state::FDOLL,
};
use crate::state::{init_app_data_scoped, AppDataRefreshScope, FDOLL};
use tauri::async_runtime;
/// Helper to execute a mutation operation and refresh app data scopes in the background.

View File

@@ -0,0 +1,32 @@
use crate::{
lock_r,
models::{dolls::DollDto, scene::SceneFriendNeko},
state::FDOLL,
};
#[tauri::command]
#[specta::specta]
pub fn get_user_active_doll() -> Result<Option<DollDto>, String> {
let guard = lock_r!(FDOLL);
let Some(user) = &guard.user_data.user else {
return Ok(None);
};
let Some(active_doll_id) = &user.active_doll_id else {
return Ok(None);
};
Ok(guard.user_data.dolls.as_ref().and_then(|dolls| {
dolls
.iter()
.find(|doll| doll.id == *active_doll_id)
.cloned()
}))
}
#[tauri::command]
#[specta::specta]
pub fn get_scene_friends() -> Result<Vec<SceneFriendNeko>, String> {
Ok(crate::services::scene_friends::get_scene_friends_snapshot())
}

View File

@@ -3,7 +3,60 @@ use std::time::Duration;
use tokio::time::sleep;
use tracing::warn;
use crate::{models::health::HealthError, remotes::health::HealthRemote};
use crate::{
models::health::HealthError,
remotes::health::HealthRemote,
services::{
close_all_windows,
health_manager::open_health_manager_window,
health_monitor::{start_health_monitor, stop_health_monitor},
scene::open_scene_window,
ws::client::{clear_ws_client, establish_websocket_connection},
},
state::{
auth::{start_background_token_refresh, stop_background_token_refresh},
clear_app_data, init_app_data_scoped, AppDataRefreshScope,
},
system_tray::update_system_tray,
};
/// Connects the user profile and opens the scene window.
pub async fn construct_user_session() {
connect_user_profile().await;
close_all_windows();
open_scene_window();
update_system_tray(true);
}
/// Disconnects the user profile and closes the scene window.
pub async fn destruct_user_session() {
disconnect_user_profile().await;
close_all_windows();
update_system_tray(false);
}
/// Initializes the user profile and establishes a WebSocket connection.
async fn connect_user_profile() {
init_app_data_scoped(AppDataRefreshScope::All).await;
establish_websocket_connection().await;
start_background_token_refresh().await;
start_health_monitor().await;
}
/// Clears the user profile and WebSocket connection.
async fn disconnect_user_profile() {
stop_health_monitor();
stop_background_token_refresh();
clear_app_data();
clear_ws_client().await;
}
/// Destructs the user session and show health manager window
/// with error message, offering troubleshooting options.
pub async fn handle_disasterous_failure(error_message: Option<String>) {
destruct_user_session().await;
open_health_manager_window(error_message);
}
/// Pings the server's health endpoint a maximum of
/// three times with a backoff of 500ms between

View File

@@ -1,12 +1,13 @@
use crate::{
init::{lifecycle::validate_server_health, tracing::init_logging},
init::{
lifecycle::{construct_user_session, handle_disasterous_failure, validate_server_health},
tracing::init_logging,
},
services::{
app_update::update_app,
auth::get_session_token,
cursor::init_cursor_tracking,
presence_modules::init_modules,
scene::{close_splash_window, open_splash_window},
session::{construct_user_session, handle_disastrous_failure},
welcome::open_welcome_window,
},
state::init_app_state,
@@ -21,14 +22,13 @@ pub mod tracing;
pub async fn launch_app() {
init_logging();
open_splash_window();
update_app().await;
init_app_state();
init_system_tray();
init_cursor_tracking().await;
init_modules();
if let Err(err) = validate_server_health().await {
handle_disastrous_failure(Some(err.to_string())).await;
handle_disasterous_failure(Some(err.to_string())).await;
return;
}

View File

@@ -1,15 +1,15 @@
use crate::services::{
doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
use crate::{
commands::app_state::{get_modules, get_presence_state},
services::{
doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
},
};
use commands::scene::{get_scene_friends, get_user_active_doll};
use commands::app::{quit_app, restart_app, retry_connection};
use commands::app_state::{
get_active_doll_sprite_base64, get_app_data, get_app_state, get_neko_positions,
get_friend_active_doll_sprites_base64, get_modules, refresh_app_data,
set_scene_setup_nekos_opacity, set_scene_setup_nekos_position, set_scene_setup_nekos_scale,
};
use commands::auth::{logout_and_restart, start_discord_auth, start_google_auth};
use commands::config::{get_client_config, open_client_config, save_client_config};
use commands::app_state::{get_app_data, refresh_app_data};
use commands::auth::{change_password, login, logout_and_restart, register, reset_password};
use commands::config::{get_client_config, open_client_config_manager, save_client_config};
use commands::dolls::{
create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll,
};
@@ -18,18 +18,19 @@ use commands::friends::{
search_users, send_friend_request, sent_friend_requests, unfriend,
};
use commands::interaction::send_interaction_cmd;
use commands::petpet::encode_pet_doll_gif_base64;
use commands::sprite::recolor_gif_base64;
use commands::petpet::encode_pet_doll_gif_base64;
use specta_typescript::Typescript;
use tauri::async_runtime;
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
use tauri_specta::{Builder as SpectaBuilder, ErrorHandlingMode, collect_commands, collect_events};
use crate::services::app_events::{
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged,
FriendCursorPositionUpdated, FriendDisconnected, FriendRequestAccepted,
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged, PresenceStateUpdated,
SceneFriendsUpdated,
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
SetInteractionOverlay, Unfriended, UserActiveDollUpdated, UserStatusChanged,
};
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -66,10 +67,6 @@ pub fn run() {
.error_handling(ErrorHandlingMode::Throw)
.commands(collect_commands![
get_app_data,
get_app_state,
get_neko_positions,
get_active_doll_sprite_base64,
get_friend_active_doll_sprites_base64,
refresh_app_data,
list_friends,
search_users,
@@ -93,41 +90,43 @@ pub fn run() {
retry_connection,
get_client_config,
save_client_config,
open_client_config,
open_client_config_manager,
open_doll_editor_window,
get_scene_interactive,
set_scene_interactive,
set_pet_menu_state,
start_google_auth,
start_discord_auth,
get_user_active_doll,
get_scene_friends,
login,
register,
change_password,
reset_password,
logout_and_restart,
send_interaction_cmd,
get_modules,
set_scene_setup_nekos_position,
set_scene_setup_nekos_opacity,
set_scene_setup_nekos_scale
get_presence_state
])
.events(collect_events![
CursorMoved,
SceneInteractiveChanged,
AppDataRefreshed,
AppStateChanged,
NekoPositionsUpdated,
ActiveDollSpriteChanged,
SetInteractionOverlay,
EditDoll,
CreateDoll,
UserStatusChanged,
UserActiveDollUpdated,
FriendCursorPositionUpdated,
FriendDisconnected,
FriendActiveDollChanged,
FriendActiveDollSpritesUpdated,
FriendUserStatusChanged,
PresenceStateUpdated,
SceneFriendsUpdated,
InteractionReceived,
InteractionDeliveryFailed,
FriendRequestReceived,
FriendRequestAccepted,
FriendRequestDenied,
Unfriended,
AuthFlowUpdated
Unfriended
]);
#[cfg(debug_assertions)]
@@ -136,7 +135,6 @@ pub fn run() {
.expect("Failed to export TypeScript bindings");
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_positioner::init())
.plugin(tauri_plugin_dialog::init())

View File

@@ -1,39 +0,0 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "kebab-case")]
pub enum NekoPosition {
TopLeft,
Top,
TopRight,
Left,
Right,
BottomLeft,
Bottom,
BottomRight,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SceneSetup {
pub nekos_position: Option<NekoPosition>,
pub nekos_opacity: f32,
pub nekos_scale: f32,
}
impl Default for SceneSetup {
fn default() -> Self {
Self {
nekos_position: None,
nekos_opacity: 1.0,
nekos_scale: 1.0,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type, Default)]
#[serde(rename_all = "camelCase")]
pub struct AppState {
pub scene_setup: SceneSetup,
}

View File

@@ -1,34 +1,34 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DollColorSchemeDto {
pub outline: String,
pub body: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DollConfigurationDto {
pub color_scheme: DollColorSchemeDto,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateDollDto {
pub name: String,
pub configuration: Option<DollConfigurationDto>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UpdateDollDto {
pub name: Option<String>,
pub configuration: Option<DollConfigurationDto>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DollDto {
pub id: String,

View File

@@ -5,34 +5,20 @@ use super::dolls::DollDto;
use super::friends::UserBasicDto;
use crate::services::presence_modules::models::PresenceStatus;
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[derive(Clone, Serialize, Deserialize, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UserStatusState {
Idle,
Resting,
}
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[derive(Clone, Serialize, Deserialize, Debug, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UserStatusPayload {
pub presence_status: PresenceStatus,
pub state: UserStatusState,
}
impl UserStatusPayload {
pub fn has_presence_content(&self) -> bool {
self.presence_status
.title
.as_ref()
.is_some_and(|title| !title.trim().is_empty())
|| self
.presence_status
.subtitle
.as_ref()
.is_some_and(|subtitle| !subtitle.trim().is_empty())
}
}
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendUserStatusPayload {

View File

@@ -1,9 +1,9 @@
pub mod app_data;
pub mod app_state;
pub mod dolls;
pub mod event_payloads;
pub mod friends;
pub mod health;
pub mod interaction;
pub mod remote_error;
pub mod scene;
pub mod user;

View File

@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use crate::{models::dolls::DollDto, services::cursor::CursorPositions};
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
#[serde(rename_all = "camelCase")]
pub struct SceneFriendNeko {
pub id: String,
pub position: CursorPositions,
pub active_doll: DollDto,
}

View File

@@ -1,7 +1,7 @@
use reqwest::Client;
use crate::{lock_r, services::auth::with_auth, state::FDOLL, models::dolls::*};
use crate::models::remote_error::RemoteError;
use crate::{lock_r, models::dolls::*, services::auth::with_auth, state::FDOLL};
pub struct DollsRemote {
pub base_url: String,
@@ -19,8 +19,7 @@ impl DollsRemote {
.expect("App configuration error")
.clone(),
client: guard
.network
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -1,7 +1,7 @@
use reqwest::Client;
use crate::{lock_r, services::auth::with_auth, state::FDOLL, models::friends::*};
use crate::models::remote_error::RemoteError;
use crate::{lock_r, models::friends::*, services::auth::with_auth, state::FDOLL};
pub struct FriendRemote {
pub base_url: String,
@@ -19,8 +19,7 @@ impl FriendRemote {
.expect("App configuration error")
.clone(),
client: guard
.network
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -1,6 +1,6 @@
use reqwest::Client;
use crate::{lock_r, models::health::*, state::FDOLL};
use crate::{lock_r, state::FDOLL, models::health::*};
pub struct HealthRemote {
pub base_url: String,
@@ -18,8 +18,7 @@ impl HealthRemote {
.ok_or(HealthError::ConfigMissing("api_base_url"))?;
let client = guard
.network
.clients
.network.clients
.as_ref()
.map(|c| c.http_client.clone())
.ok_or(HealthError::ConfigMissing("http_client"))?;

View File

@@ -1,6 +1,6 @@
use reqwest::{Client, Error};
use crate::{lock_r, models::user::*, services::auth::with_auth, state::FDOLL};
use crate::{lock_r, services::auth::with_auth, state::FDOLL, models::user::*};
pub struct UserRemote {
pub base_url: String,
@@ -18,8 +18,7 @@ impl UserRemote {
.expect("App configuration error")
.clone(),
client: guard
.network
.clients
.network.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -1,307 +0,0 @@
use device_query::Keycode;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Type)]
#[serde(rename_all = "snake_case")]
pub enum AcceleratorAction {
SceneInteractivity,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Type)]
pub struct KeyboardAccelerator {
#[serde(default)]
pub modifiers: Vec<AcceleratorModifier>,
#[serde(default)]
pub key: Option<AcceleratorKey>,
}
impl KeyboardAccelerator {
pub fn normalized(mut self) -> Self {
self.modifiers.sort_unstable();
self.modifiers.dedup();
if self.modifiers.is_empty() {
return Self::default();
}
self
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Type)]
#[serde(rename_all = "snake_case")]
pub enum AcceleratorModifier {
Cmd,
Alt,
Ctrl,
Shift,
}
impl Default for KeyboardAccelerator {
fn default() -> Self {
#[cfg(target_os = "macos")]
{
Self {
modifiers: vec![AcceleratorModifier::Cmd],
key: None,
}
}
#[cfg(not(target_os = "macos"))]
{
Self {
modifiers: vec![AcceleratorModifier::Alt],
key: None,
}
}
}
}
pub fn default_accelerator_for_action(action: AcceleratorAction) -> KeyboardAccelerator {
match action {
AcceleratorAction::SceneInteractivity => KeyboardAccelerator::default(),
}
}
pub fn default_accelerators() -> std::collections::BTreeMap<AcceleratorAction, KeyboardAccelerator>
{
let mut map = std::collections::BTreeMap::new();
map.insert(
AcceleratorAction::SceneInteractivity,
default_accelerator_for_action(AcceleratorAction::SceneInteractivity),
);
map
}
pub fn normalize_accelerators(
mut accelerators: std::collections::BTreeMap<AcceleratorAction, KeyboardAccelerator>,
) -> std::collections::BTreeMap<AcceleratorAction, KeyboardAccelerator> {
for value in accelerators.values_mut() {
*value = value.clone().normalized();
}
for action in [AcceleratorAction::SceneInteractivity] {
accelerators
.entry(action)
.or_insert_with(|| default_accelerator_for_action(action));
}
accelerators
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum AcceleratorKey {
A,
B,
C,
D,
E,
F,
G,
H,
I,
J,
K,
L,
M,
N,
O,
P,
Q,
R,
S,
T,
U,
V,
W,
X,
Y,
Z,
Num0,
Num1,
Num2,
Num3,
Num4,
Num5,
Num6,
Num7,
Num8,
Num9,
F1,
F2,
F3,
F4,
F5,
F6,
F7,
F8,
F9,
F10,
F11,
F12,
Enter,
Space,
Escape,
Tab,
Backspace,
Delete,
Insert,
Home,
End,
PageUp,
PageDown,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
Minus,
Equal,
LeftBracket,
RightBracket,
BackSlash,
Semicolon,
Apostrophe,
Comma,
Dot,
Slash,
Grave,
}
fn contains_any(keys: &[Keycode], candidates: &[Keycode]) -> bool {
candidates.iter().any(|candidate| keys.contains(candidate))
}
fn has_modifier(keys: &[Keycode], modifier: AcceleratorModifier) -> bool {
match modifier {
AcceleratorModifier::Cmd => contains_any(
keys,
&[
Keycode::Command,
Keycode::RCommand,
Keycode::LMeta,
Keycode::RMeta,
],
),
AcceleratorModifier::Alt => contains_any(
keys,
&[
Keycode::LAlt,
Keycode::RAlt,
Keycode::LOption,
Keycode::ROption,
],
),
AcceleratorModifier::Ctrl => contains_any(keys, &[Keycode::LControl, Keycode::RControl]),
AcceleratorModifier::Shift => contains_any(keys, &[Keycode::LShift, Keycode::RShift]),
}
}
fn keycodes_for(key: AcceleratorKey) -> &'static [Keycode] {
match key {
AcceleratorKey::A => &[Keycode::A],
AcceleratorKey::B => &[Keycode::B],
AcceleratorKey::C => &[Keycode::C],
AcceleratorKey::D => &[Keycode::D],
AcceleratorKey::E => &[Keycode::E],
AcceleratorKey::F => &[Keycode::F],
AcceleratorKey::G => &[Keycode::G],
AcceleratorKey::H => &[Keycode::H],
AcceleratorKey::I => &[Keycode::I],
AcceleratorKey::J => &[Keycode::J],
AcceleratorKey::K => &[Keycode::K],
AcceleratorKey::L => &[Keycode::L],
AcceleratorKey::M => &[Keycode::M],
AcceleratorKey::N => &[Keycode::N],
AcceleratorKey::O => &[Keycode::O],
AcceleratorKey::P => &[Keycode::P],
AcceleratorKey::Q => &[Keycode::Q],
AcceleratorKey::R => &[Keycode::R],
AcceleratorKey::S => &[Keycode::S],
AcceleratorKey::T => &[Keycode::T],
AcceleratorKey::U => &[Keycode::U],
AcceleratorKey::V => &[Keycode::V],
AcceleratorKey::W => &[Keycode::W],
AcceleratorKey::X => &[Keycode::X],
AcceleratorKey::Y => &[Keycode::Y],
AcceleratorKey::Z => &[Keycode::Z],
AcceleratorKey::Num0 => &[Keycode::Key0],
AcceleratorKey::Num1 => &[Keycode::Key1],
AcceleratorKey::Num2 => &[Keycode::Key2],
AcceleratorKey::Num3 => &[Keycode::Key3],
AcceleratorKey::Num4 => &[Keycode::Key4],
AcceleratorKey::Num5 => &[Keycode::Key5],
AcceleratorKey::Num6 => &[Keycode::Key6],
AcceleratorKey::Num7 => &[Keycode::Key7],
AcceleratorKey::Num8 => &[Keycode::Key8],
AcceleratorKey::Num9 => &[Keycode::Key9],
AcceleratorKey::F1 => &[Keycode::F1],
AcceleratorKey::F2 => &[Keycode::F2],
AcceleratorKey::F3 => &[Keycode::F3],
AcceleratorKey::F4 => &[Keycode::F4],
AcceleratorKey::F5 => &[Keycode::F5],
AcceleratorKey::F6 => &[Keycode::F6],
AcceleratorKey::F7 => &[Keycode::F7],
AcceleratorKey::F8 => &[Keycode::F8],
AcceleratorKey::F9 => &[Keycode::F9],
AcceleratorKey::F10 => &[Keycode::F10],
AcceleratorKey::F11 => &[Keycode::F11],
AcceleratorKey::F12 => &[Keycode::F12],
AcceleratorKey::Enter => &[Keycode::Enter, Keycode::NumpadEnter],
AcceleratorKey::Space => &[Keycode::Space],
AcceleratorKey::Escape => &[Keycode::Escape],
AcceleratorKey::Tab => &[Keycode::Tab],
AcceleratorKey::Backspace => &[Keycode::Backspace],
AcceleratorKey::Delete => &[Keycode::Delete],
AcceleratorKey::Insert => &[Keycode::Insert],
AcceleratorKey::Home => &[Keycode::Home],
AcceleratorKey::End => &[Keycode::End],
AcceleratorKey::PageUp => &[Keycode::PageUp],
AcceleratorKey::PageDown => &[Keycode::PageDown],
AcceleratorKey::ArrowUp => &[Keycode::Up],
AcceleratorKey::ArrowDown => &[Keycode::Down],
AcceleratorKey::ArrowLeft => &[Keycode::Left],
AcceleratorKey::ArrowRight => &[Keycode::Right],
AcceleratorKey::Minus => &[Keycode::Minus],
AcceleratorKey::Equal => &[Keycode::Equal],
AcceleratorKey::LeftBracket => &[Keycode::LeftBracket],
AcceleratorKey::RightBracket => &[Keycode::RightBracket],
AcceleratorKey::BackSlash => &[Keycode::BackSlash],
AcceleratorKey::Semicolon => &[Keycode::Semicolon],
AcceleratorKey::Apostrophe => &[Keycode::Apostrophe],
AcceleratorKey::Comma => &[Keycode::Comma],
AcceleratorKey::Dot => &[Keycode::Dot],
AcceleratorKey::Slash => &[Keycode::Slash],
AcceleratorKey::Grave => &[Keycode::Grave],
}
}
fn has_key(keys: &[Keycode], key: AcceleratorKey) -> bool {
contains_any(keys, keycodes_for(key))
}
fn pressed_modifiers(keys: &[Keycode]) -> Vec<AcceleratorModifier> {
let mut modifiers = Vec::new();
for modifier in [
AcceleratorModifier::Cmd,
AcceleratorModifier::Alt,
AcceleratorModifier::Ctrl,
AcceleratorModifier::Shift,
] {
if has_modifier(keys, modifier) {
modifiers.push(modifier);
}
}
modifiers
}
pub fn is_accelerator_active(keys: &[Keycode], accelerator: &KeyboardAccelerator) -> bool {
if pressed_modifiers(keys) != accelerator.modifiers {
return false;
}
accelerator.key.map_or(true, |key| has_key(keys, key))
}

View File

@@ -1,70 +0,0 @@
use crate::{get_app_handle, lock_w, state::FDOLL};
use tracing::{info, warn};
pub fn update_display_dimensions_for_scene_state() {
let app_handle = get_app_handle();
let mut guard = lock_w!(FDOLL);
let primary_monitor = {
let mut retry_count = 0;
let max_retries = 3;
loop {
match app_handle.primary_monitor() {
Ok(Some(monitor)) => {
info!("Primary monitor acquired for state initialization");
break Some(monitor);
}
Ok(None) => {
retry_count += 1;
if retry_count >= max_retries {
warn!(
"No primary monitor found after {} retries during state init",
max_retries
);
break None;
}
warn!(
"Primary monitor not available during state init, retrying... ({}/{})",
retry_count, max_retries
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(error) => {
retry_count += 1;
if retry_count >= max_retries {
warn!("Failed to get primary monitor during state init: {}", error);
break None;
}
warn!(
"Error getting primary monitor during state init, retrying... ({}/{}): {}",
retry_count, max_retries, error
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
};
if let Some(monitor) = primary_monitor {
let monitor_dimensions = monitor.size();
let monitor_scale_factor = monitor.scale_factor();
let logical_monitor_dimensions: tauri::LogicalSize<i32> =
monitor_dimensions.to_logical(monitor_scale_factor);
guard.user_data.scene.display.screen_width = logical_monitor_dimensions.width;
guard.user_data.scene.display.screen_height = logical_monitor_dimensions.height;
guard.user_data.scene.display.monitor_scale_factor = monitor_scale_factor;
guard.user_data.scene.grid_size = 600;
info!(
"Initialized global AppData with screen dimensions: {}x{}, scale: {}, grid: {}",
logical_monitor_dimensions.width,
logical_monitor_dimensions.height,
monitor_scale_factor,
guard.user_data.scene.grid_size
);
} else {
warn!("Could not initialize screen dimensions in global state - no monitor found");
}
}

View File

@@ -1,5 +0,0 @@
mod display;
mod refresh;
pub use display::update_display_dimensions_for_scene_state;
pub use refresh::{clear_app_data, init_app_data_scoped, AppDataRefreshScope};

View File

@@ -1,179 +0,0 @@
use std::{collections::HashSet, sync::LazyLock};
use tauri_plugin_dialog::MessageDialogBuilder;
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_specta::Event as _;
use tokio::sync::Mutex;
use tracing::warn;
use crate::{
get_app_handle, lock_r, lock_w,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::{
app_events::{ActiveDollSpriteChanged, AppDataRefreshed},
friends, neko_positions, sprite,
},
state::FDOLL,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AppDataRefreshScope {
All,
User,
Friends,
Dolls,
}
static REFRESH_IN_FLIGHT: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
static REFRESH_PENDING: LazyLock<Mutex<HashSet<AppDataRefreshScope>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
pub async fn init_app_data_scoped(scope: AppDataRefreshScope) {
loop {
{
let mut in_flight = REFRESH_IN_FLIGHT.lock().await;
if in_flight.contains(&scope) {
let mut pending = REFRESH_PENDING.lock().await;
pending.insert(scope);
return;
}
in_flight.insert(scope);
}
let result: Result<(), ()> = async {
let user_remote = UserRemote::new();
let friend_remote = FriendRemote::new();
let dolls_remote = DollsRemote::new();
if matches!(scope, AppDataRefreshScope::All | AppDataRefreshScope::User) {
match user_remote.get_user(None).await {
Ok(user) => {
let mut guard = lock_w!(FDOLL);
guard.user_data.user = Some(user);
drop(guard);
neko_positions::sync_from_app_data();
}
Err(error) => {
warn!("Failed to fetch user profile: {}", error);
show_refresh_error_dialog(
"Network Error",
"Failed to fetch user profile. You may be offline.",
);
return Err(());
}
}
}
if matches!(
scope,
AppDataRefreshScope::All | AppDataRefreshScope::Friends
) {
match friend_remote.get_friends().await {
Ok(friends_list) => {
let mut guard = lock_w!(FDOLL);
guard.user_data.friends = Some(friends_list);
drop(guard);
friends::sync_from_app_data();
}
Err(error) => {
warn!("Failed to fetch friends list: {}", error);
show_refresh_error_dialog(
"Network Error",
"Failed to fetch friends list. You may be offline.",
);
return Err(());
}
}
}
if matches!(scope, AppDataRefreshScope::All | AppDataRefreshScope::Dolls) {
match dolls_remote.get_dolls().await {
Ok(dolls) => {
let mut guard = lock_w!(FDOLL);
guard.user_data.dolls = Some(dolls);
}
Err(error) => {
warn!("Failed to fetch dolls list: {}", error);
show_refresh_error_dialog(
"Network Error",
"Failed to fetch dolls list. You may be offline.",
);
return Err(());
}
}
}
emit_refresh_events(scope);
Ok(())
}
.await;
{
let mut in_flight = REFRESH_IN_FLIGHT.lock().await;
in_flight.remove(&scope);
}
let rerun = {
let mut pending = REFRESH_PENDING.lock().await;
pending.remove(&scope)
};
if rerun {
continue;
}
if result.is_err() {
return;
}
break;
}
}
pub fn clear_app_data() {
let mut guard = lock_w!(FDOLL);
guard.user_data.dolls = None;
guard.user_data.user = None;
guard.user_data.friends = None;
drop(guard);
friends::clear();
}
fn emit_refresh_events(scope: AppDataRefreshScope) {
let guard = lock_r!(FDOLL);
let app_data = guard.user_data.clone();
drop(guard);
if let Err(error) = AppDataRefreshed(app_data).emit(get_app_handle()) {
warn!("Failed to emit app-data-refreshed event: {}", error);
show_refresh_error_dialog(
"Sync Error",
"Could not broadcast refreshed data to the UI. Some data may be stale.",
);
}
if matches!(
scope,
AppDataRefreshScope::All | AppDataRefreshScope::User | AppDataRefreshScope::Dolls
) {
match sprite::get_active_doll_sprite_base64() {
Ok(sprite_b64) => {
if let Err(error) = ActiveDollSpriteChanged(sprite_b64).emit(get_app_handle()) {
warn!("Failed to emit active-doll-sprite-changed event: {}", error);
}
}
Err(error) => {
warn!("Failed to generate active doll sprite: {}", error);
}
}
}
}
fn show_refresh_error_dialog(title: &str, message: &str) {
let handle = get_app_handle();
MessageDialogBuilder::new(handle.dialog().clone(), title, message)
.kind(MessageDialogKind::Error)
.show(|_| {});
}

View File

@@ -5,33 +5,24 @@ use tauri_specta::Event;
use crate::{
models::{
app_data::UserData,
app_state::AppState,
dolls::DollDto,
event_payloads::{
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
FriendUserStatusPayload, UnfriendedPayload, UserStatusPayload,
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
scene::SceneFriendNeko,
},
services::{
cursor::CursorPositions, presence_state::PresenceStateSnapshot,
ws::OutgoingFriendCursorPayload,
},
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "snake_case")]
pub enum AuthFlowStatus {
Started,
Succeeded,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct AuthFlowUpdatedPayload {
pub provider: String,
pub status: AuthFlowStatus,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-interactive")]
@@ -41,14 +32,6 @@ pub struct SceneInteractiveChanged(pub bool);
#[tauri_specta(event_name = "app-data-refreshed")]
pub struct AppDataRefreshed(pub UserData);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "app-state-changed")]
pub struct AppStateChanged(pub AppState);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "active-doll-sprite-changed")]
pub struct ActiveDollSpriteChanged(pub Option<String>);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "set-interaction-overlay")]
pub struct SetInteractionOverlay(pub bool);
@@ -66,8 +49,12 @@ pub struct CreateDoll;
pub struct UserStatusChanged(pub UserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "neko-positions")]
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
#[tauri_specta(event_name = "user-active-doll-updated")]
pub struct UserActiveDollUpdated(pub Option<DollDto>);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-position")]
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]
@@ -77,14 +64,18 @@ pub struct FriendDisconnected(pub FriendDisconnectedPayload);
#[tauri_specta(event_name = "friend-active-doll-changed")]
pub struct FriendActiveDollChanged(pub FriendActiveDollChangedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-active-doll-sprites-updated")]
pub struct FriendActiveDollSpritesUpdated(pub FriendActiveDollSpritesDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-user-status")]
pub struct FriendUserStatusChanged(pub FriendUserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "scene-friends-updated")]
pub struct SceneFriendsUpdated(pub Vec<SceneFriendNeko>);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "presence-state-updated")]
pub struct PresenceStateUpdated(pub PresenceStateSnapshot);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "interaction-received")]
pub struct InteractionReceived(pub InteractionPayloadDto);
@@ -108,7 +99,3 @@ pub struct FriendRequestDenied(pub FriendRequestDeniedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "unfriended")]
pub struct Unfriended(pub UnfriendedPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "auth-flow-updated")]
pub struct AuthFlowUpdated(pub AuthFlowUpdatedPayload);

View File

@@ -1,29 +1,41 @@
use tracing::error;
use tauri::Manager;
use tracing::{error, info};
use crate::services::window_manager::{
ensure_window, EnsureWindowError, EnsureWindowResult, WindowConfig,
};
use crate::get_app_handle;
pub static APP_MENU_WINDOW_LABEL: &str = "app_menu";
pub fn open_app_menu_window() {
let mut config = WindowConfig::regular_ui(APP_MENU_WINDOW_LABEL, "/app-menu", "Friendolls");
config.width = 400.0;
config.height = 550.0;
config.resizable = true;
let app_handle = get_app_handle();
let existing_webview_window = app_handle.get_window(APP_MENU_WINDOW_LABEL);
match ensure_window(&config, true, false) {
Ok(EnsureWindowResult::Created(_)) => {}
Ok(EnsureWindowResult::Existing(_)) => {}
Err(EnsureWindowError::MissingParent(parent_label)) => {
error!(
"Failed to build {} window due to missing parent '{}': impossible state",
APP_MENU_WINDOW_LABEL, parent_label
);
if let Some(window) = existing_webview_window {
window.show().unwrap();
return;
}
match tauri::WebviewWindowBuilder::new(
app_handle,
APP_MENU_WINDOW_LABEL,
tauri::WebviewUrl::App("/app-menu".into()),
)
.title("Friendolls")
.inner_size(400.0, 550.0)
.resizable(true)
.maximizable(false)
.decorations(true)
.transparent(false)
.shadow(true)
.visible(true)
.skip_taskbar(false)
.always_on_top(false)
.visible_on_all_workspaces(false)
.build()
{
Ok(_) => {
info!("{} window builder succeeded", APP_MENU_WINDOW_LABEL);
}
Err(EnsureWindowError::ShowExisting(e))
| Err(EnsureWindowError::SetParent(e))
| Err(EnsureWindowError::Build(e)) => {
Err(e) => {
error!("Failed to build {} window: {}", APP_MENU_WINDOW_LABEL, e);
}
}

View File

@@ -1,44 +0,0 @@
use std::sync::{Arc, LazyLock, RwLock};
use tauri_specta::Event as _;
use tracing::warn;
use crate::{
get_app_handle, lock_r, lock_w,
models::app_state::{AppState, NekoPosition},
services::{app_events::AppStateChanged, neko_positions},
};
static APP_STATE: LazyLock<Arc<RwLock<AppState>>> =
LazyLock::new(|| Arc::new(RwLock::new(AppState::default())));
pub fn get_snapshot() -> AppState {
let guard = lock_r!(APP_STATE);
guard.clone()
}
pub fn set_scene_setup_nekos_position(nekos_position: Option<NekoPosition>) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_position = nekos_position;
emit_snapshot(&guard);
drop(guard);
neko_positions::refresh_from_scene_setup();
}
pub fn set_scene_setup_nekos_opacity(nekos_opacity: f32) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_opacity = nekos_opacity.clamp(0.1, 1.0);
emit_snapshot(&guard);
}
pub fn set_scene_setup_nekos_scale(nekos_scale: f32) {
let mut guard = lock_w!(APP_STATE);
guard.scene_setup.nekos_scale = nekos_scale.clamp(0.5, 2.0);
emit_snapshot(&guard);
}
fn emit_snapshot(app_state: &AppState) {
if let Err(error) = AppStateChanged(app_state.clone()).emit(get_app_handle()) {
warn!("Failed to emit app-state-changed event: {}", error);
}
}

View File

@@ -1,48 +0,0 @@
use tauri_plugin_updater::UpdaterExt;
use tracing::{error, info};
use crate::get_app_handle;
pub async fn update_app() {
let app = get_app_handle();
if let Some(update) = match match app.updater() {
Ok(it) => it,
Err(err) => {
error!("failed to get updater: {err:?}");
return;
}
}
.check()
.await
{
Ok(it) => it,
Err(err) => {
error!("failed to check for update: {err:?}");
return;
}
} {
let mut downloaded = 0;
match update
.download_and_install(
|chunk_length, content_length| {
downloaded += chunk_length;
println!("downloaded {downloaded} from {content_length:?}");
},
|| {
info!("download finished");
},
)
.await
{
Ok(it) => it,
Err(err) => {
error!("failed to install update: {err:?}");
return;
}
};
info!("update installed");
app.restart();
}
}

View File

@@ -0,0 +1,504 @@
use crate::get_app_handle;
use crate::init::lifecycle::construct_user_session;
use crate::services::scene::close_splash_window;
use crate::services::welcome::close_welcome_window;
use crate::state::auth::get_auth_pass_with_refresh;
use crate::{lock_r, lock_w, state::FDOLL};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
use keyring::Entry;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tracing::{error, info};
const SERVICE_NAME: &str = "friendolls";
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Keyring error: {0}")]
KeyringError(#[from] keyring::Error),
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("JSON serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Invalid app configuration")]
InvalidConfig,
#[error("Failed to refresh token")]
RefreshFailed,
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthPass {
pub access_token: String,
pub expires_in: u64,
pub issued_at: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct LoginResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "expiresIn")]
expires_in: u64,
}
#[derive(Debug, Deserialize)]
struct RegisterResponse {
id: String,
}
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
email: &'a str,
password: &'a str,
}
#[derive(Debug, Serialize)]
struct RegisterRequest<'a> {
email: &'a str,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<&'a str>,
}
#[derive(Debug, Serialize)]
struct ChangePasswordRequest<'a> {
#[serde(rename = "currentPassword")]
current_password: &'a str,
#[serde(rename = "newPassword")]
new_password: &'a str,
}
#[derive(Debug, Serialize)]
struct ResetPasswordRequest<'a> {
#[serde(rename = "oldPassword")]
old_password: &'a str,
#[serde(rename = "newPassword")]
new_password: &'a str,
}
fn build_auth_pass(access_token: String, expires_in: u64) -> Result<AuthPass, AuthError> {
let issued_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| AuthError::RefreshFailed)?
.as_secs();
Ok(AuthPass {
access_token,
expires_in,
issued_at: Some(issued_at),
})
}
pub async fn get_session_token() -> Option<AuthPass> {
get_auth_pass_with_refresh().await
}
pub async fn get_access_token() -> Option<String> {
get_session_token().await.map(|pass| pass.access_token)
}
pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> {
let json = serde_json::to_string(auth_pass)?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(json.as_bytes())
.map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?;
let compressed = encoder
.finish()
.map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?;
let encoded = URL_SAFE_NO_PAD.encode(&compressed);
#[cfg(target_os = "windows")]
{
const CHUNK_SIZE: usize = 1200;
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(CHUNK_SIZE)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect();
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
count_entry.set_password(&chunks.len().to_string())?;
for (i, chunk) in chunks.iter().enumerate() {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
entry.set_password(chunk)?;
}
}
#[cfg(not(target_os = "windows"))]
{
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
entry.set_password(&encoded)?;
}
Ok(())
}
pub fn load_auth_pass() -> Result<Option<AuthPass>, AuthError> {
#[cfg(target_os = "windows")]
let encoded = {
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
let chunk_count = match count_entry.get_password() {
Ok(count_str) => match count_str.parse::<usize>() {
Ok(count) => count,
Err(_) => {
error!("Invalid chunk count in keyring");
return Ok(None);
}
},
Err(keyring::Error::NoEntry) => {
info!("No auth pass found in keyring");
return Ok(None);
}
Err(e) => {
error!("Failed to load chunk count from keyring");
return Err(AuthError::KeyringError(e));
}
};
let mut encoded = String::new();
for i in 0..chunk_count {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
match entry.get_password() {
Ok(chunk) => encoded.push_str(&chunk),
Err(e) => {
error!("Failed to load chunk {} from keyring", i);
return Err(AuthError::KeyringError(e));
}
}
}
encoded
};
#[cfg(not(target_os = "windows"))]
let encoded = {
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
match entry.get_password() {
Ok(pass) => pass,
Err(keyring::Error::NoEntry) => {
info!("No auth pass found in keyring");
return Ok(None);
}
Err(e) => {
error!("Failed to load auth pass from keyring");
return Err(AuthError::KeyringError(e));
}
}
};
let compressed = match URL_SAFE_NO_PAD.decode(&encoded) {
Ok(c) => c,
Err(e) => {
error!("Failed to base64 decode auth pass from keyring: {}", e);
return Ok(None);
}
};
let mut decoder = GzDecoder::new(&compressed[..]);
let mut json = String::new();
if let Err(e) = decoder.read_to_string(&mut json) {
error!("Failed to decompress auth pass from keyring: {}", e);
return Ok(None);
}
let auth_pass: AuthPass = match serde_json::from_str(&json) {
Ok(v) => v,
Err(_e) => {
error!("Failed to decode auth pass from keyring");
return Ok(None);
}
};
Ok(Some(auth_pass))
}
pub fn clear_auth_pass() -> Result<(), AuthError> {
#[cfg(target_os = "windows")]
{
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
let chunk_count = match count_entry.get_password() {
Ok(count_str) => count_str.parse::<usize>().unwrap_or(0),
Err(_) => 0,
};
for i in 0..chunk_count {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
let _ = entry.delete_credential();
}
let _ = count_entry.delete_credential();
}
#[cfg(not(target_os = "windows"))]
{
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
let _ = entry.delete_credential();
}
Ok(())
}
pub fn logout() -> Result<(), AuthError> {
info!("Logging out user");
lock_w!(FDOLL).auth.auth_pass = None;
clear_auth_pass()?;
Ok(())
}
pub async fn logout_and_restart() -> Result<(), AuthError> {
logout()?;
let app_handle = get_app_handle();
app_handle.restart();
}
pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = get_access_token().await {
request.header("Authorization", format!("Bearer {}", token))
} else {
request
}
}
pub async fn login(email: &str, password: &str) -> Result<AuthPass, AuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.network.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(AuthError::InvalidConfig);
}
(
guard.app_config.clone(),
clients.unwrap().http_client.clone(),
)
};
let base_url = app_config
.api_base_url
.as_ref()
.ok_or(AuthError::InvalidConfig)?;
let url = format!("{}/auth/login", base_url);
let response = http_client
.post(url)
.json(&LoginRequest { email, password })
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AuthError::RequestFailed(format!(
"Status: {}, Body: {}",
status, error_text
)));
}
let login_response: LoginResponse = response.json().await?;
let auth_pass = build_auth_pass(login_response.access_token, login_response.expires_in)?;
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(&auth_pass)?;
Ok(auth_pass)
}
pub async fn register(
email: &str,
password: &str,
name: Option<&str>,
username: Option<&str>,
) -> Result<String, AuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.network.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(AuthError::InvalidConfig);
}
(
guard.app_config.clone(),
clients.unwrap().http_client.clone(),
)
};
let base_url = app_config
.api_base_url
.as_ref()
.ok_or(AuthError::InvalidConfig)?;
let url = format!("{}/auth/register", base_url);
let response = http_client
.post(url)
.json(&RegisterRequest {
email,
password,
name,
username,
})
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AuthError::RequestFailed(format!(
"Status: {}, Body: {}",
status, error_text
)));
}
let register_response: RegisterResponse = response.json().await?;
Ok(register_response.id)
}
pub async fn change_password(
current_password: &str,
new_password: &str,
) -> Result<(), AuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.network.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(AuthError::InvalidConfig);
}
(
guard.app_config.clone(),
clients.unwrap().http_client.clone(),
)
};
let base_url = app_config
.api_base_url
.as_ref()
.ok_or(AuthError::InvalidConfig)?;
let url = format!("{}/auth/change-password", base_url);
let response = with_auth(
http_client.post(url).json(&ChangePasswordRequest {
current_password,
new_password,
}),
)
.await
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AuthError::RequestFailed(format!(
"Status: {}, Body: {}",
status, error_text
)));
}
Ok(())
}
pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), AuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
let clients = guard.network.clients.as_ref();
if clients.is_none() {
error!("Clients not initialized yet!");
return Err(AuthError::InvalidConfig);
}
(
guard.app_config.clone(),
clients.unwrap().http_client.clone(),
)
};
let base_url = app_config
.api_base_url
.as_ref()
.ok_or(AuthError::InvalidConfig)?;
let url = format!("{}/auth/reset-password", base_url);
let response = with_auth(
http_client.post(url).json(&ResetPasswordRequest {
old_password,
new_password,
}),
)
.await
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AuthError::RequestFailed(format!(
"Status: {}, Body: {}",
status, error_text
)));
}
Ok(())
}
pub async fn refresh_token(access_token: &str) -> Result<AuthPass, AuthError> {
let (app_config, http_client) = {
let guard = lock_r!(FDOLL);
(
guard.app_config.clone(),
guard
.network
.clients
.as_ref()
.expect("clients present")
.http_client
.clone(),
)
};
let base_url = app_config
.api_base_url
.as_ref()
.ok_or(AuthError::InvalidConfig)?;
let url = format!("{}/auth/refresh", base_url);
let response = http_client
.post(url)
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
error!("Token refresh failed with status {}: {}", status, error_text);
return Err(AuthError::RefreshFailed);
}
let refresh_response: LoginResponse = response.json().await?;
let auth_pass = build_auth_pass(refresh_response.access_token, refresh_response.expires_in)?;
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(&auth_pass)?;
Ok(auth_pass)
}
pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), AuthError> {
login(email, password).await?;
close_welcome_window();
tauri::async_runtime::spawn(async {
construct_user_session().await;
close_splash_window();
});
Ok(())
}

View File

@@ -1,160 +0,0 @@
use serde::{Deserialize, Serialize};
use tracing::error;
use crate::{lock_r, lock_w, state::FDOLL};
use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass};
#[derive(Debug, Deserialize)]
pub struct StartSsoResponse {
pub state: String,
#[serde(rename = "authorizeUrl")]
pub authorize_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "expiresIn")]
expires_in: u64,
#[serde(rename = "refreshToken")]
refresh_token: String,
#[serde(rename = "refreshExpiresIn")]
refresh_expires_in: u64,
}
#[derive(Debug, Serialize)]
struct StartSsoRequest<'a> {
provider: &'a str,
#[serde(rename = "redirectUri")]
redirect_uri: &'a str,
}
#[derive(Debug, Serialize)]
struct ExchangeSsoCodeRequest<'a> {
code: &'a str,
}
#[derive(Debug, Serialize)]
struct RefreshTokenRequest<'a> {
#[serde(rename = "refreshToken")]
refresh_token: &'a str,
}
#[derive(Debug, Serialize)]
struct LogoutRequest<'a> {
#[serde(rename = "refreshToken")]
refresh_token: &'a str,
}
fn auth_http_context() -> Result<(String, reqwest::Client), AuthError> {
let guard = lock_r!(FDOLL);
let clients = guard.network.clients.as_ref().ok_or_else(|| {
error!("Clients not initialized yet!");
AuthError::InvalidConfig
})?;
let base_url = guard
.app_config
.api_base_url
.clone()
.ok_or(AuthError::InvalidConfig)?;
Ok((base_url, clients.http_client.clone()))
}
async fn ensure_success(response: reqwest::Response) -> Result<reqwest::Response, AuthError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
Err(AuthError::RequestFailed(format!(
"Status: {}, Body: {}",
status, error_text
)))
}
pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = super::session::get_access_token().await {
request.header("Authorization", format!("Bearer {}", token))
} else {
request
}
}
pub async fn start_sso(provider: &str, redirect_uri: &str) -> Result<StartSsoResponse, AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/sso/start", base_url))
.json(&StartSsoRequest {
provider,
redirect_uri,
})
.send()
.await?;
ensure_success(response)
.await?
.json()
.await
.map_err(AuthError::from)
}
pub async fn exchange_sso_code(code: &str) -> Result<AuthPass, AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/sso/exchange", base_url))
.json(&ExchangeSsoCodeRequest { code })
.send()
.await?;
let token_response: TokenResponse = ensure_success(response).await?.json().await?;
build_auth_pass(
token_response.access_token,
token_response.expires_in,
token_response.refresh_token,
token_response.refresh_expires_in,
)
}
pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/refresh", base_url))
.json(&RefreshTokenRequest { refresh_token })
.send()
.await?;
let token_response: TokenResponse = ensure_success(response).await?.json().await?;
let auth_pass = build_auth_pass(
token_response.access_token,
token_response.expires_in,
token_response.refresh_token,
token_response.refresh_expires_in,
)?;
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(&auth_pass)?;
Ok(auth_pass)
}
pub async fn logout_remote(refresh_token: &str) -> Result<(), AuthError> {
let (base_url, http_client) = auth_http_context()?;
let response = http_client
.post(format!("{}/auth/logout", base_url))
.json(&LogoutRequest { refresh_token })
.send()
.await?;
ensure_success(response).await?;
Ok(())
}
pub fn persist_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> {
lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone());
save_auth_pass(auth_pass)?;
Ok(())
}

View File

@@ -1,397 +0,0 @@
use tauri_plugin_opener::OpenerExt;
use tauri_specta::Event as _;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
use crate::get_app_handle;
use crate::services::app_events::{AuthFlowStatus, AuthFlowUpdated, AuthFlowUpdatedPayload};
use crate::state::{begin_auth_flow, clear_auth_flow_state, is_auth_flow_active};
use crate::{lock_r, state::FDOLL};
use super::api::{exchange_sso_code, persist_auth_pass, start_sso};
use super::storage::AuthError;
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
static AUTH_CANCELLED_HTML: &str = include_str!("../../assets/auth-cancelled.html");
static AUTH_FAILED_HTML: &str = include_str!("../../assets/auth-failed.html");
pub struct OAuthCallbackParams {
pub state: String,
pub result: OAuthCallbackResult,
}
pub enum OAuthCallbackResult {
Code(String),
Error { message: String, cancelled: bool },
}
struct PendingOAuthCallback {
stream: TcpStream,
params: OAuthCallbackParams,
}
pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
let (flow_id, cancel_token) = begin_auth_flow();
let bind_addr = "127.0.0.1:0";
let std_listener = std::net::TcpListener::bind(bind_addr)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
std_listener
.set_nonblocking(true)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let local_addr = std_listener
.local_addr()
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let redirect_uri = format!("http://127.0.0.1:{}/callback", local_addr.port());
let start_response = match start_sso(provider, &redirect_uri).await {
Ok(response) => response,
Err(err) => {
clear_auth_flow_state(flow_id);
return Err(err);
}
};
let listener = TcpListener::from_std(std_listener)
.map_err(|e| AuthError::ServerBindError(e.to_string()))?;
let expected_state = start_response.state.clone();
let auth_url = match start_response.authorize_url.clone() {
Some(authorize_url) => authorize_url,
None => match build_authorize_url(provider, &start_response.state) {
Ok(url) => url,
Err(err) => {
clear_auth_flow_state(flow_id);
return Err(err);
}
},
};
let provider_name = provider.to_string();
if let Err(err) = get_app_handle().opener().open_url(auth_url, None::<&str>) {
clear_auth_flow_state(flow_id);
emit_auth_flow_event(
provider,
AuthFlowStatus::Failed,
Some("Friendolls could not open your browser for sign-in.".to_string()),
);
return Err(err.into());
}
emit_auth_flow_event(provider, AuthFlowStatus::Started, None);
tauri::async_runtime::spawn(async move {
match listen_for_callback(listener, cancel_token.clone()).await {
Ok(mut callback) => {
if !is_auth_flow_active(flow_id) {
let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML).await;
return;
}
if callback.params.state != expected_state {
error!("SSO state mismatch");
if let Err(err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some("Sign-in verification failed. Please try again.".to_string()),
);
clear_auth_flow_state(flow_id);
return;
}
match callback.params.result {
OAuthCallbackResult::Code(code) => {
let auth_pass = match exchange_sso_code(&code).await {
Ok(auth_pass) => auth_pass,
Err(err) => {
error!("Failed to exchange SSO code: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML)
.await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Friendolls could not complete sign-in. Please try again."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
return;
}
};
if !is_auth_flow_active(flow_id) {
let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML)
.await;
return;
}
if let Err(err) = persist_auth_pass(&auth_pass) {
error!("Failed to persist SSO auth pass: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Friendolls could not complete sign-in. Please try again."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
return;
}
if let Err(err) = super::session::finish_login_session().await {
error!("Failed to finalize desktop login session: {}", err);
if let Err(write_err) =
write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await
{
warn!("Failed to write auth failure response: {}", write_err);
}
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(
"Signed in, but Friendolls could not open your session."
.to_string(),
),
);
clear_auth_flow_state(flow_id);
} else {
if let Err(err) =
write_html_response(&mut callback.stream, AUTH_SUCCESS_HTML).await
{
warn!("Failed to write auth success response: {}", err);
}
emit_auth_flow_event(&provider_name, AuthFlowStatus::Succeeded, None);
clear_auth_flow_state(flow_id);
}
}
OAuthCallbackResult::Error { message, cancelled } => {
let response_html = if cancelled {
AUTH_CANCELLED_HTML
} else {
AUTH_FAILED_HTML
};
if let Err(err) =
write_html_response(&mut callback.stream, response_html).await
{
warn!("Failed to write auth callback response: {}", err);
}
emit_auth_flow_event(
&provider_name,
if cancelled {
AuthFlowStatus::Cancelled
} else {
AuthFlowStatus::Failed
},
Some(message),
);
clear_auth_flow_state(flow_id);
}
}
}
Err(AuthError::Cancelled) => {
info!("Auth flow cancelled");
if is_auth_flow_active(flow_id) {
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Cancelled,
Some("Sign-in was cancelled.".to_string()),
);
clear_auth_flow_state(flow_id);
}
}
Err(err) => {
error!("Auth callback listener failed: {}", err);
if is_auth_flow_active(flow_id) {
emit_auth_flow_event(
&provider_name,
AuthFlowStatus::Failed,
Some(auth_flow_error_message(&err)),
);
clear_auth_flow_state(flow_id);
}
}
}
});
Ok(())
}
fn build_authorize_url(provider: &str, state: &str) -> Result<String, AuthError> {
let base_url = lock_r!(FDOLL)
.app_config
.api_base_url
.clone()
.ok_or(AuthError::InvalidConfig)?;
let mut parsed = url::Url::parse(&base_url)
.map_err(|e| AuthError::RequestFailed(format!("Invalid API base URL: {}", e)))?;
let existing_path = parsed.path().trim_end_matches('/');
parsed.set_path(&format!("{}/auth/sso/{}", existing_path, provider));
let query = url::form_urlencoded::Serializer::new(String::new())
.append_pair("state", state)
.finish();
parsed.set_query(Some(&query));
Ok(parsed.to_string())
}
async fn listen_for_callback(
listener: TcpListener,
cancel_token: CancellationToken,
) -> Result<PendingOAuthCallback, AuthError> {
let timeout = tokio::time::Duration::from_secs(300);
let start = tokio::time::Instant::now();
loop {
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(AuthError::CallbackTimeout);
}
let remaining = timeout - elapsed;
let accepted = tokio::select! {
_ = cancel_token.cancelled() => return Err(AuthError::Cancelled),
result = tokio::time::timeout(remaining, listener.accept()) => result,
};
let (mut stream, _) = match accepted {
Ok(Ok(value)) => value,
Ok(Err(err)) => {
warn!("Accept error in auth callback listener: {}", err);
continue;
}
Err(_) => return Err(AuthError::CallbackTimeout),
};
if let Some(params) = parse_callback(&mut stream).await? {
return Ok(PendingOAuthCallback { stream, params });
}
}
}
async fn parse_callback(stream: &mut TcpStream) -> Result<Option<OAuthCallbackParams>, AuthError> {
let mut buffer = [0; 4096];
let bytes_read = match stream.read(&mut buffer).await {
Ok(value) if value > 0 => value,
_ => return Ok(None),
};
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let first_line = request.lines().next().unwrap_or_default();
let mut parts = first_line.split_whitespace();
match (parts.next(), parts.next()) {
(Some("GET"), Some(path)) if path.starts_with("/callback") => {
let parsed = url::Url::parse(&format!("http://localhost{}", path))
.map_err(|e| AuthError::RequestFailed(e.to_string()))?;
let params: std::collections::HashMap<_, _> =
parsed.query_pairs().into_owned().collect();
let state = params
.get("state")
.cloned()
.ok_or_else(|| AuthError::MissingParameter("state".to_string()))?;
if let Some(error_code) = params.get("error") {
let message = oauth_error_message(error_code, params.get("error_description"));
return Ok(Some(OAuthCallbackParams {
state,
result: OAuthCallbackResult::Error {
message,
cancelled: is_oauth_cancellation(error_code),
},
}));
}
let code = params
.get("code")
.cloned()
.ok_or_else(|| AuthError::MissingParameter("code".to_string()))?;
Ok(Some(OAuthCallbackParams {
state,
result: OAuthCallbackResult::Code(code),
}))
}
(Some("GET"), Some("/health")) => {
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.await?;
Ok(None)
}
_ => {
stream
.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
.await?;
Ok(None)
}
}
}
fn emit_auth_flow_event(provider: &str, status: AuthFlowStatus, message: Option<String>) {
if let Err(err) = AuthFlowUpdated(AuthFlowUpdatedPayload {
provider: provider.to_string(),
status,
message,
})
.emit(get_app_handle())
{
warn!("Failed to emit auth flow event: {}", err);
}
}
fn auth_flow_error_message(err: &AuthError) -> String {
match err {
AuthError::Cancelled => "Sign-in was cancelled.".to_string(),
AuthError::CallbackTimeout => "Sign-in timed out. Please try again.".to_string(),
AuthError::MissingParameter(_) => {
"Friendolls did not receive a complete sign-in response. Please try again.".to_string()
}
_ => "Friendolls could not complete sign-in. Please try again.".to_string(),
}
}
fn oauth_error_message(error_code: &str, description: Option<&String>) -> String {
if let Some(description) = description.filter(|description| !description.is_empty()) {
return description.clone();
}
if is_oauth_cancellation(error_code) {
"Sign-in was cancelled.".to_string()
} else {
"The sign-in provider reported an error. Please try again.".to_string()
}
}
fn is_oauth_cancellation(error_code: &str) -> bool {
matches!(
error_code,
"access_denied" | "user_cancelled" | "authorization_cancelled"
)
}
async fn write_html_response(stream: &mut TcpStream, html: &str) -> Result<(), AuthError> {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
html.len(),
html
);
stream.write_all(response.as_bytes()).await?;
stream.flush().await?;
Ok(())
}

View File

@@ -1,8 +0,0 @@
mod api;
mod flow;
mod session;
mod storage;
pub use api::{refresh_token, with_auth};
pub use session::{get_access_token, get_session_token, logout_and_restart, start_browser_login};
pub use storage::{clear_auth_pass, load_auth_pass, AuthPass};

View File

@@ -1,63 +0,0 @@
use tokio::time::{timeout, Duration};
use tracing::info;
use crate::get_app_handle;
use crate::services::{
scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window,
};
use crate::state::auth::get_auth_pass_with_refresh;
use crate::{lock_w, state::FDOLL};
use super::storage::{clear_auth_pass, AuthError, AuthPass};
pub async fn get_session_token() -> Option<AuthPass> {
get_auth_pass_with_refresh().await
}
pub async fn get_access_token() -> Option<String> {
get_session_token().await.map(|pass| pass.access_token)
}
pub async fn logout() -> Result<(), AuthError> {
info!("Logging out user");
let refresh_token = lock_w!(FDOLL)
.auth
.auth_pass
.take()
.and_then(|pass| pass.refresh_token);
clear_auth_pass()?;
if let Some(refresh_token) = refresh_token {
match timeout(
Duration::from_secs(5),
super::api::logout_remote(&refresh_token),
)
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => info!("Failed to revoke refresh token on server: {}", err),
Err(_) => info!("Timed out while revoking refresh token on server"),
}
}
Ok(())
}
pub async fn logout_and_restart() -> Result<(), AuthError> {
logout().await?;
let app_handle = get_app_handle();
app_handle.restart();
}
pub async fn finish_login_session() -> Result<(), AuthError> {
close_welcome_window();
tauri::async_runtime::spawn(async {
construct_user_session().await;
close_splash_window();
});
Ok(())
}
pub async fn start_browser_login(provider: &str) -> Result<(), AuthError> {
super::flow::start_browser_auth_flow(provider).await
}

View File

@@ -1,225 +0,0 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use flate2::{read::GzDecoder, write::GzEncoder, Compression};
use keyring::Entry;
use serde::{Deserialize, Serialize};
use std::io::{Read, Write};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tracing::{error, info};
const SERVICE_NAME: &str = "friendolls";
#[derive(Debug, Error)]
pub enum AuthError {
#[error("Keyring error: {0}")]
KeyringError(#[from] keyring::Error),
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("JSON serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Invalid app configuration")]
InvalidConfig,
#[error("Failed to refresh token")]
RefreshFailed,
#[error("Request failed: {0}")]
RequestFailed(String),
#[error("Missing callback parameter: {0}")]
MissingParameter(String),
#[error("Authentication flow cancelled")]
Cancelled,
#[error("Callback timeout - no response received")]
CallbackTimeout,
#[error("Server binding failed: {0}")]
ServerBindError(String),
#[error("Failed to open auth portal: {0}")]
OpenPortalFailed(#[from] tauri_plugin_opener::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthPass {
pub access_token: String,
pub expires_in: u64,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub refresh_expires_in: Option<u64>,
pub issued_at: Option<u64>,
}
pub(crate) fn build_auth_pass(
access_token: String,
expires_in: u64,
refresh_token: String,
refresh_expires_in: u64,
) -> Result<AuthPass, AuthError> {
let issued_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| AuthError::RefreshFailed)?
.as_secs();
Ok(AuthPass {
access_token,
expires_in,
refresh_token: Some(refresh_token),
refresh_expires_in: Some(refresh_expires_in),
issued_at: Some(issued_at),
})
}
pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> {
let json = serde_json::to_string(auth_pass)?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
encoder
.write_all(json.as_bytes())
.map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?;
let compressed = encoder
.finish()
.map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?;
let encoded = URL_SAFE_NO_PAD.encode(&compressed);
#[cfg(target_os = "windows")]
{
const CHUNK_SIZE: usize = 1200;
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(CHUNK_SIZE)
.map(|chunk| std::str::from_utf8(chunk).expect("base64 chunk is valid utf-8"))
.collect();
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
count_entry.set_password(&chunks.len().to_string())?;
for (i, chunk) in chunks.iter().enumerate() {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
entry.set_password(chunk)?;
}
}
#[cfg(not(target_os = "windows"))]
{
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
entry.set_password(&encoded)?;
}
Ok(())
}
pub fn load_auth_pass() -> Result<Option<AuthPass>, AuthError> {
#[cfg(target_os = "windows")]
let encoded = {
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
let chunk_count = match count_entry.get_password() {
Ok(count_str) => match count_str.parse::<usize>() {
Ok(count) => count,
Err(_) => {
error!("Invalid chunk count in keyring");
return Ok(None);
}
},
Err(keyring::Error::NoEntry) => {
info!("No auth pass found in keyring");
return Ok(None);
}
Err(e) => {
error!("Failed to load chunk count from keyring");
return Err(AuthError::KeyringError(e));
}
};
let mut encoded = String::new();
for i in 0..chunk_count {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
match entry.get_password() {
Ok(chunk) => encoded.push_str(&chunk),
Err(e) => {
error!("Failed to load chunk {} from keyring", i);
return Err(AuthError::KeyringError(e));
}
}
}
encoded
};
#[cfg(not(target_os = "windows"))]
let encoded = {
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
match entry.get_password() {
Ok(pass) => pass,
Err(keyring::Error::NoEntry) => {
info!("No auth pass found in keyring");
return Ok(None);
}
Err(e) => {
error!("Failed to load auth pass from keyring");
return Err(AuthError::KeyringError(e));
}
}
};
let compressed = match URL_SAFE_NO_PAD.decode(&encoded) {
Ok(c) => c,
Err(e) => {
error!("Failed to base64 decode auth pass from keyring: {}", e);
return Ok(None);
}
};
let mut decoder = GzDecoder::new(&compressed[..]);
let mut json = String::new();
if let Err(e) = decoder.read_to_string(&mut json) {
error!("Failed to decompress auth pass from keyring: {}", e);
return Ok(None);
}
let auth_pass: AuthPass = match serde_json::from_str(&json) {
Ok(v) => v,
Err(_) => {
error!("Failed to decode auth pass from keyring");
return Ok(None);
}
};
if auth_pass.refresh_token.is_none() || auth_pass.refresh_expires_in.is_none() {
info!("Loaded legacy auth pass without refresh token support");
}
Ok(Some(auth_pass))
}
pub fn clear_auth_pass() -> Result<(), AuthError> {
#[cfg(target_os = "windows")]
{
let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?;
let chunk_count = match count_entry.get_password() {
Ok(count_str) => count_str.parse::<usize>().unwrap_or(0),
Err(_) => 0,
};
for i in 0..chunk_count {
let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?;
let _ = entry.delete_credential();
}
let _ = count_entry.delete_credential();
}
#[cfg(not(target_os = "windows"))]
{
let entry = Entry::new(SERVICE_NAME, "auth_pass")?;
let _ = entry.delete_credential();
}
Ok(())
}

View File

@@ -1,93 +0,0 @@
mod store;
mod window;
use std::collections::BTreeMap;
use serde::{Deserialize, Deserializer, Serialize};
use specta::Type;
use thiserror::Error;
pub use store::{load_app_config, save_app_config};
pub use window::open_config_window;
pub use crate::services::accelerators::{
default_accelerator_for_action, default_accelerators, normalize_accelerators,
AcceleratorAction, KeyboardAccelerator,
};
#[derive(Serialize, Clone, Debug, Type)]
pub struct AppConfig {
pub api_base_url: Option<String>,
pub debug_mode: bool,
#[serde(default)]
pub accelerators: BTreeMap<AcceleratorAction, KeyboardAccelerator>,
}
impl AppConfig {
pub fn normalized(mut self) -> Self {
self.accelerators = normalize_accelerators(self.accelerators);
self
}
pub fn accelerator_for(&self, action: AcceleratorAction) -> KeyboardAccelerator {
self.accelerators
.get(&action)
.cloned()
.unwrap_or_else(|| default_accelerator_for_action(action))
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
api_base_url: None,
debug_mode: false,
accelerators: default_accelerators(),
}
}
}
impl<'de> Deserialize<'de> for AppConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct AppConfigSerde {
api_base_url: Option<String>,
#[serde(default)]
debug_mode: bool,
#[serde(default)]
accelerators: BTreeMap<AcceleratorAction, KeyboardAccelerator>,
}
let value = AppConfigSerde::deserialize(deserializer)?;
Ok(Self {
api_base_url: value.api_base_url,
debug_mode: value.debug_mode,
accelerators: value.accelerators,
}
.normalized())
}
}
#[derive(Debug, Error)]
pub enum ClientConfigError {
#[error("failed to resolve app config dir: {0}")]
ResolvePath(tauri::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse client config: {0}")]
Parse(#[from] serde_json::Error),
#[error("failed to run on main thread: {0}")]
Dispatch(#[from] tauri::Error),
#[error("failed to build client config window: {0}")]
Window(tauri::Error),
#[error("failed to show client config window: {0}")]
ShowWindow(tauri::Error),
#[error("missing required parent window: {0}")]
MissingParent(String),
}
pub static CLIENT_CONFIG_WINDOW_LABEL: &str = "client_config";

View File

@@ -1,52 +0,0 @@
use tracing::error;
use crate::services::window_manager::{
ensure_window, EnsureWindowError, EnsureWindowResult, WindowConfig,
};
use super::{ClientConfigError, CLIENT_CONFIG_WINDOW_LABEL};
#[tauri::command]
pub fn open_config_window() -> Result<(), ClientConfigError> {
let mut config = WindowConfig::regular_ui(
CLIENT_CONFIG_WINDOW_LABEL,
"/client-config",
"Advanced Configuration",
);
config.width = 300.0;
config.height = 420.0;
config.visible = false;
match ensure_window(&config, true, true) {
Ok(EnsureWindowResult::Created(window)) => {
if let Err(e) = window.show() {
error!("Failed to show client config window: {}", e);
return Err(ClientConfigError::ShowWindow(e));
}
if let Err(e) = window.set_focus() {
error!("Failed to focus client config window: {e}");
}
Ok(())
}
Ok(EnsureWindowResult::Existing(_)) => Ok(()),
Err(EnsureWindowError::MissingParent(parent_label)) => {
error!(
"Missing parent '{}' for client config window: impossible state",
parent_label
);
Err(ClientConfigError::MissingParent(parent_label))
}
Err(EnsureWindowError::ShowExisting(e)) => {
error!("Failed to show client config window: {e}");
Err(ClientConfigError::ShowWindow(e))
}
Err(EnsureWindowError::SetParent(e)) => {
error!("Failed to set parent for client config window: {}", e);
Err(ClientConfigError::Window(e))
}
Err(EnsureWindowError::Build(e)) => {
error!("Failed to build client config window: {}", e);
Err(ClientConfigError::Window(e))
}
}
}

View File

@@ -1,15 +1,38 @@
use std::{fs, path::PathBuf};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::Manager;
use tracing::warn;
use thiserror::Error;
use tracing::{error, warn};
use url::Url;
use crate::get_app_handle;
use super::{AppConfig, ClientConfigError};
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
pub struct AppConfig {
pub api_base_url: Option<String>,
}
#[derive(Debug, Error)]
pub enum ClientConfigError {
#[error("failed to resolve app config dir: {0}")]
ResolvePath(tauri::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse client config: {0}")]
Parse(#[from] serde_json::Error),
#[error("failed to run on main thread: {0}")]
Dispatch(#[from] tauri::Error),
#[error("failed to build client config manager window: {0}")]
Window(tauri::Error),
#[error("failed to show client config manager window: {0}")]
ShowWindow(tauri::Error),
}
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
const CONFIG_FILENAME: &str = "client_config.json";
const DEFAULT_API_BASE_URL: &str = "https://api.friendolls.adamcv.com";
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
let dir = app_handle
@@ -48,14 +71,12 @@ fn sanitize(mut config: AppConfig) -> AppConfig {
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
.map(|v| strip_trailing_slash(&v));
config.normalized()
config
}
pub fn default_app_config() -> AppConfig {
AppConfig {
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
debug_mode: false,
..AppConfig::default()
}
}
@@ -105,3 +126,48 @@ pub fn save_app_config(config: AppConfig) -> Result<AppConfig, ClientConfigError
Ok(sanitized)
}
#[tauri::command]
pub fn open_config_manager_window() -> Result<(), ClientConfigError> {
let app_handle = get_app_handle();
let existing_webview_window = app_handle.get_window(CLIENT_CONFIG_MANAGER_WINDOW_LABEL);
if let Some(window) = existing_webview_window {
if let Err(e) = window.show() {
error!("Failed to show client config manager window: {e}");
return Err(ClientConfigError::ShowWindow(e));
}
if let Err(e) = window.set_focus() {
error!("Failed to focus client config manager window: {e}");
}
return Ok(());
}
match tauri::WebviewWindowBuilder::new(
app_handle,
CLIENT_CONFIG_MANAGER_WINDOW_LABEL,
tauri::WebviewUrl::App("/client-config-manager".into()),
)
.title("Advanced Configuration")
.inner_size(300.0, 420.0)
.resizable(false)
.maximizable(false)
.visible(false)
.build()
{
Ok(window) => {
if let Err(e) = window.show() {
error!("Failed to show client config manager window: {}", e);
return Err(ClientConfigError::ShowWindow(e));
}
if let Err(e) = window.set_focus() {
error!("Failed to focus client config manager window: {e}");
}
Ok(())
}
Err(e) => {
error!("Failed to build client config manager window: {}", e);
Err(ClientConfigError::Window(e))
}
}
}

Some files were not shown because too many files have changed in this diff Show More