Compare commits

...

59 Commits

Author SHA1 Message Date
22bbcaa2df Delete .env.example 2026-03-30 02:26:55 +08:00
c7a5b50223 bump version numbers 2026-03-27 14:53:15 +08:00
8a8e77125a system tray icon 2026-03-27 14:51:25 +08:00
a945526808 app icon 2026-03-27 14:42:45 +08:00
743af7adb6 new splash screen 2026-03-26 03:59:37 +08:00
cba5cf1980 brief optimization of markup for new font 2026-03-26 03:30:15 +08:00
5e1009616c departuremono.com 2026-03-26 02:03:25 +08:00
d55d7e90f0 Updated neko reposition UI 2026-03-25 15:54:29 +08:00
5d2f2241f3 bump version number 2026-03-25 02:25:40 +08:00
cb0b366e96 leverage lock w and r (clean up after AI) 2026-03-25 02:23:06 +08:00
53248243e3 minor cursor handling refactor improvemnts 2026-03-25 02:14:55 +08:00
e7f9633fcc prevented select highlight & default context menus 2026-03-24 23:48:34 +08:00
96d1ead1a9 make neko view in scene config look better 2026-03-24 23:40:58 +08:00
4aa2b00d17 added app version 2026-03-24 23:24:42 +08:00
75ab799a7f scene configuration, neko opacity & scale 2026-03-24 22:52:49 +08:00
4093b0eb0c iconified app menu tab UI 2026-03-22 02:33:27 +08:00
aa9d1f54a1 accelerators sservice 2026-03-22 02:10:10 +08:00
a72430e65f custom interactivity hotkey 2026-03-21 22:28:14 +08:00
8f4aab3d87 bump version number 2026-03-21 03:22:12 +08:00
a83f74494b stop tiling WMs from tiling scene window 2026-03-21 03:21:43 +08:00
f4021a1d2a Refactored window management solution & implemented macOS accessory mode 2026-03-21 03:07:11 +08:00
16811a8243 macOS accessory mode 2026-03-21 00:24:00 +08:00
680fd3c617 debug mode 2026-03-21 00:00:10 +08:00
5165bc2c16 made msg pop smaller & longer & shadows 2026-03-20 19:50:01 +08:00
db972c8387 message interaction 2026-03-20 19:35:19 +08:00
c47b8d464d bump version number 2026-03-19 14:16:33 +08:00
df02a9189d update default API endpoint address string 2026-03-19 14:07:15 +08:00
Adam C
3d68a7d494 Add TAURI_SIGNING_PRIVATE_KEY_PASSWORD to release.yml 2026-03-18 12:29:15 +08:00
1137f57610 new pubkey with password 2026-03-18 03:17:53 +08:00
Adam C
5bf4f62e7f Add pnpm setup step to release workflow
Added setup step for pnpm version 10 in the release workflow.
2026-03-18 03:03:44 +08:00
Adam C
dd6cd9b7d6 Drop tauri-action version
tauri-actions repo says v1 but docs say v0??
2026-03-18 02:53:29 +08:00
4334891887 Tauri GitHub Actions 2026-03-18 02:47:34 +08:00
f8e88dfba6 Tauri Updater attempt hopefully works first try 2026-03-18 02:20:54 +08:00
aa2ccf6c3f cargo fmt 2026-03-18 02:04:31 +08:00
5e0f5f19f0 welcome screen refinements 2026-03-18 01:25:32 +08:00
2a485f9a0b improved splash screen action 2026-03-17 22:09:50 +08:00
3cc4f5366d SSO auth (1) 2026-03-17 15:08:39 +08:00
905ba5abc0 Separated pet name component from pet menu 2026-03-16 16:40:57 +08:00
e99f8a7608 added pet name display 2026-03-14 15:09:44 +08:00
0e305f821c tied new pet menu to rust local backend 2026-03-14 11:44:51 +08:00
68c8635497 exposed user to pet-menu 2026-03-13 21:24:18 +08:00
5d01d69c53 updated scene page to reference to the right pet menu 2026-03-13 21:01:29 +08:00
4404045033 moved createPetActions into pet menu component 2026-03-13 20:58:15 +08:00
fc883cff18 removed pet menu conditional rendering to allow onDestroy 2026-03-13 20:46:04 +08:00
083c62efa0 break down pet menu component & remove pet menu for user's own doll 2026-03-13 20:35:47 +08:00
475484abea scaffolded pet menu 2026-03-13 19:26:10 +08:00
5eb25bd026 hide user's doll when no doll is deployed 2026-03-13 18:06:46 +08:00
2f0c967bc0 lifecycle refactor into session service 2026-03-11 16:47:40 +08:00
da93c2e4a4 client config rename for frontend 2026-03-10 16:17:18 +08:00
858858ab48 Rust service refactor: app data, session windows & client config pt 2 2026-03-10 15:46:49 +08:00
341dd48132 Rust service refactor: auth, scene & client config 2026-03-10 14:24:06 +08:00
3437bc5746 Rust service refactor: friends & some other 2026-03-10 13:27:12 +08:00
e38697faa9 added friends' neko to scene page 2026-03-10 12:59:06 +08:00
a4d8601297 cleaned up unused rust types 2026-03-10 03:39:59 +08:00
ceaa1257bf move retrieval of sprite url from svelte to rust 2026-03-09 19:34:57 +08:00
02f1119254 user's own doll color scheme in neko 2026-03-09 19:04:53 +08:00
d582ea7fe8 replace RecolorOptions with DollColorSchemeDto 2026-03-09 18:00:05 +08:00
03ae3e0829 moved user status data validation from front to backend 2026-03-09 12:45:34 +08:00
69cfebee3d moved friend cursor data aggregation from frontend to backend 2026-03-09 12:30:29 +08:00
178 changed files with 5727 additions and 2171 deletions

67
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
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.0",
"version": "0.1.4",
"description": "",
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"@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,6 +17,9 @@ 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
@@ -467,6 +470,9 @@ 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==}
@@ -544,6 +550,9 @@ 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==}
@@ -1137,6 +1146,8 @@ 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':
@@ -1190,6 +1201,10 @@ 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': {}

View File

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

255
src-tauri/Cargo.lock generated
View File

@@ -72,6 +72,15 @@ 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"
@@ -801,6 +810,17 @@ 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"
@@ -1134,6 +1154,17 @@ 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"
@@ -1209,7 +1240,7 @@ dependencies = [
[[package]]
name = "friendolls-desktop"
version = "0.1.0"
version = "0.1.4"
dependencies = [
"base64 0.22.1",
"device_query",
@@ -1238,11 +1269,13 @@ 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",
@@ -1496,8 +1529,10 @@ 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]]
@@ -1507,9 +1542,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1841,6 +1878,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1911,9 +1949,9 @@ dependencies = [
[[package]]
name = "ico"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
@@ -2301,6 +2339,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags 2.10.0",
"libc",
"redox_syscall",
]
[[package]]
@@ -2330,6 +2369,12 @@ 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"
@@ -2435,6 +2480,12 @@ 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"
@@ -2839,6 +2890,18 @@ 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"
@@ -2989,6 +3052,20 @@ 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"
@@ -3380,6 +3457,61 @@ 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"
@@ -3618,6 +3750,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@@ -3625,6 +3759,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3634,6 +3769,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -3754,6 +3890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@@ -3766,6 +3903,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
@@ -4504,6 +4642,17 @@ 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"
@@ -4586,9 +4735,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.5.1"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -4613,9 +4762,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.5.1"
version = "2.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -4744,6 +4893,38 @@ 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"
@@ -4826,9 +5007,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.8.0"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
dependencies = [
"anyhow",
"brotli",
@@ -4987,6 +5168,21 @@ 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"
@@ -5717,6 +5913,16 @@ 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"
@@ -5761,6 +5967,15 @@ 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"
@@ -6487,6 +6702,16 @@ 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"
@@ -6683,6 +6908,18 @@ 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.0"
version = "0.1.4"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -50,9 +50,11 @@ 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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,5 @@
<?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.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,4 @@
<?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: 85 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,51 @@
<!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

@@ -0,0 +1,51 @@
<!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

@@ -0,0 +1,51 @@
<!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,6 +1,7 @@
use crate::get_app_handle;
use crate::init::lifecycle::{construct_user_session, validate_server_health};
use crate::init::lifecycle::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,8 +1,15 @@
use crate::{
lock_r,
models::app_data::UserData,
services::presence_modules::models::ModuleMetadata,
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
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,
};
#[tauri::command]
@@ -26,3 +33,47 @@ pub fn get_modules() -> Result<Vec<ModuleMetadata>, String> {
let guard = lock_r!(FDOLL);
Ok(guard.modules.metadatas.clone())
}
#[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);
}

View File

@@ -8,45 +8,16 @@ pub async fn logout_and_restart() -> Result<(), String> {
#[tauri::command]
#[specta::specta]
pub async fn login(email: String, password: String) -> Result<(), String> {
auth::login_and_init_session(&email, &password)
pub async fn start_google_auth() -> Result<(), String> {
auth::start_browser_login("google")
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
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)
pub async fn start_discord_auth() -> Result<(), String> {
auth::start_browser_login("discord")
.await
.map_err(|e| e.to_string())
}

View File

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

View File

@@ -1,11 +1,8 @@
use crate::{
commands::{is_active_doll, refresh_app_data, refresh_app_data_conditionally},
models::dolls::{CreateDollDto, DollDto, UpdateDollDto},
remotes::{
dolls::DollsRemote,
user::UserRemote,
},
state::AppDataRefreshScope,
commands::{refresh_app_data, refresh_app_data_conditionally, is_active_doll},
remotes::{dolls::DollsRemote, user::UserRemote},
services::app_data::AppDataRefreshScope,
};
#[tauri::command]
@@ -53,7 +50,8 @@ 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)
}
@@ -72,7 +70,8 @@ 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(())
}

View File

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

View File

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

View File

@@ -3,60 +3,7 @@ use std::time::Duration;
use tokio::time::sleep;
use tracing::warn;
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);
}
use crate::{models::health::HealthError, remotes::health::HealthRemote};
/// Pings the server's health endpoint a maximum of
/// three times with a backoff of 500ms between

View File

@@ -1,13 +1,12 @@
use crate::{
init::{
lifecycle::{construct_user_session, handle_disasterous_failure, validate_server_health},
tracing::init_logging,
},
init::{lifecycle::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,
@@ -22,13 +21,14 @@ 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_disasterous_failure(Some(err.to_string())).await;
handle_disastrous_failure(Some(err.to_string())).await;
return;
}

View File

@@ -1,14 +1,15 @@
use crate::{
commands::app_state::get_modules,
services::{
doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
},
use crate::services::{
doll_editor::open_doll_editor_window,
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
};
use commands::app::{quit_app, restart_app, retry_connection};
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::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::dolls::{
create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll,
};
@@ -17,18 +18,18 @@ use commands::friends::{
search_users, send_friend_request, sent_friend_requests, unfriend,
};
use commands::interaction::send_interaction_cmd;
use commands::sprite::recolor_gif_base64;
use commands::petpet::encode_pet_doll_gif_base64;
use commands::sprite::recolor_gif_base64;
use specta_typescript::Typescript;
use tauri::async_runtime;
use tauri_specta::{Builder as SpectaBuilder, ErrorHandlingMode, collect_commands, collect_events};
use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode};
use crate::services::app_events::{
AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged,
FriendCursorPositionUpdated, FriendDisconnected, FriendRequestAccepted,
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
SetInteractionOverlay, Unfriended, UserStatusChanged,
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
};
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
@@ -65,6 +66,10 @@ 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,
@@ -88,37 +93,41 @@ pub fn run() {
retry_connection,
get_client_config,
save_client_config,
open_client_config_manager,
open_client_config,
open_doll_editor_window,
get_scene_interactive,
set_scene_interactive,
set_pet_menu_state,
login,
register,
change_password,
reset_password,
start_google_auth,
start_discord_auth,
logout_and_restart,
send_interaction_cmd,
get_modules
get_modules,
set_scene_setup_nekos_position,
set_scene_setup_nekos_opacity,
set_scene_setup_nekos_scale
])
.events(collect_events![
CursorMoved,
SceneInteractiveChanged,
AppDataRefreshed,
AppStateChanged,
NekoPositionsUpdated,
ActiveDollSpriteChanged,
SetInteractionOverlay,
EditDoll,
CreateDoll,
UserStatusChanged,
FriendCursorPositionUpdated,
FriendDisconnected,
FriendActiveDollChanged,
FriendActiveDollSpritesUpdated,
FriendUserStatusChanged,
InteractionReceived,
InteractionDeliveryFailed,
FriendRequestReceived,
FriendRequestAccepted,
FriendRequestDenied,
Unfriended
Unfriended,
AuthFlowUpdated
]);
#[cfg(debug_assertions)]
@@ -127,6 +136,7 @@ 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

@@ -0,0 +1,39 @@
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

@@ -19,6 +19,20 @@ pub struct UserStatusPayload {
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,4 +1,5 @@
pub mod app_data;
pub mod app_state;
pub mod dolls;
pub mod event_payloads;
pub mod friends;

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,7 +19,8 @@ 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,7 +19,8 @@ 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, state::FDOLL, models::health::*};
use crate::{lock_r, models::health::*, state::FDOLL};
pub struct HealthRemote {
pub base_url: String,
@@ -18,7 +18,8 @@ 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, services::auth::with_auth, state::FDOLL, models::user::*};
use crate::{lock_r, models::user::*, services::auth::with_auth, state::FDOLL};
pub struct UserRemote {
pub base_url: String,
@@ -18,7 +18,8 @@ impl UserRemote {
.expect("App configuration error")
.clone(),
client: guard
.network.clients
.network
.clients
.as_ref()
.expect("App configuration error")
.http_client

View File

@@ -0,0 +1,307 @@
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

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,5 @@
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

@@ -0,0 +1,179 @@
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,6 +5,7 @@ use tauri_specta::Event;
use crate::{
models::{
app_data::UserData,
app_state::AppState,
event_payloads::{
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
@@ -12,12 +13,25 @@ use crate::{
},
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
},
services::{cursor::CursorPositions, ws::OutgoingFriendCursorPayload},
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
};
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "cursor-position")]
pub struct CursorMoved(pub CursorPositions);
#[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 = "scene-interactive")]
@@ -27,6 +41,14 @@ 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);
@@ -44,8 +66,8 @@ pub struct CreateDoll;
pub struct UserStatusChanged(pub UserStatusPayload);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-cursor-position")]
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
#[tauri_specta(event_name = "neko-positions")]
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[tauri_specta(event_name = "friend-disconnected")]
@@ -55,6 +77,10 @@ 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);
@@ -82,3 +108,7 @@ 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,41 +1,29 @@
use tauri::Manager;
use tracing::{error, info};
use tracing::error;
use crate::get_app_handle;
use crate::services::window_manager::{
ensure_window, EnsureWindowError, EnsureWindowResult, WindowConfig,
};
pub static APP_MENU_WINDOW_LABEL: &str = "app_menu";
pub fn open_app_menu_window() {
let app_handle = get_app_handle();
let existing_webview_window = app_handle.get_window(APP_MENU_WINDOW_LABEL);
let mut config = WindowConfig::regular_ui(APP_MENU_WINDOW_LABEL, "/app-menu", "Friendolls");
config.width = 400.0;
config.height = 550.0;
config.resizable = true;
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);
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
);
}
Err(e) => {
Err(EnsureWindowError::ShowExisting(e))
| Err(EnsureWindowError::SetParent(e))
| Err(EnsureWindowError::Build(e)) => {
error!("Failed to build {} window: {}", APP_MENU_WINDOW_LABEL, e);
}
}

View File

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,48 @@
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

@@ -1,504 +0,0 @@
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

@@ -0,0 +1,160 @@
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

@@ -0,0 +1,397 @@
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

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,225 @@
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

@@ -0,0 +1,93 @@
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,38 +1,15 @@
use std::{fs, path::PathBuf};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::Manager;
use thiserror::Error;
use tracing::{error, warn};
use tracing::warn;
use url::Url;
use crate::get_app_handle;
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
pub struct AppConfig {
pub api_base_url: Option<String>,
}
use super::{AppConfig, ClientConfigError};
#[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.fdolls.adamcv.com";
const DEFAULT_API_BASE_URL: &str = "https://api.friendolls.adamcv.com";
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
let dir = app_handle
@@ -71,12 +48,14 @@ fn sanitize(mut config: AppConfig) -> AppConfig {
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
.map(|v| strip_trailing_slash(&v));
config
config.normalized()
}
pub fn default_app_config() -> AppConfig {
AppConfig {
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
debug_mode: false,
..AppConfig::default()
}
}
@@ -126,48 +105,3 @@ 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))
}
}
}

View File

@@ -0,0 +1,52 @@
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

@@ -8,12 +8,10 @@ use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::{
get_app_handle,
lock_r,
services::app_events::CursorMoved,
services::{neko_positions, ws::report_cursor_data},
state::FDOLL,
};
use tauri_specta::Event as _;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -64,8 +62,7 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
}
}
/// Initialize cursor tracking. Broadcasts cursor
/// position changes via `cursor-position` event.
/// Initialize cursor tracking.
pub async fn init_cursor_tracking() {
info!("start_cursor_tracking called");
@@ -93,22 +90,19 @@ async fn init_cursor_tracking_i() -> Result<(), String> {
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
// Spawn the consumer task
// This task handles WebSocket reporting and local broadcasting.
// This task handles WebSocket reporting and local position projection updates.
// It runs independently of the device event loop.
tauri::async_runtime::spawn(async move {
info!("Cursor event consumer started");
let app_handle = get_app_handle();
while let Some(positions) = rx.recv().await {
let mapped_for_ws = positions.mapped.clone();
// 1. WebSocket reporting
crate::services::ws::report_cursor_data(mapped_for_ws).await;
report_cursor_data(mapped_for_ws).await;
// 2. Broadcast to local windows
if let Err(e) = CursorMoved(positions).emit(app_handle) {
error!("Failed to emit cursor position event: {:?}", e);
}
// 2. Update unified neko positions projection
neko_positions::update_self_cursor(positions);
}
warn!("Cursor event consumer stopped (channel closed)");
});

View File

@@ -7,6 +7,9 @@ use tracing::{error, info};
use crate::{
get_app_handle,
services::app_events::{CreateDoll, EditDoll, SetInteractionOverlay},
services::window_manager::{
encode_query_value, ensure_window, EnsureWindowError, EnsureWindowResult, WindowConfig,
},
};
static APP_MENU_WINDOW_LABEL: &str = "app_menu";
@@ -71,55 +74,14 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
};
// Check if the window already exists
let existing_window = app_handle.get_webview_window(&window_label);
if let Some(window) = existing_window {
// If it exists, we might want to reload it with new params or just focus it
if let Err(e) = window.set_focus() {
error!("Failed to focus existing doll editor window: {}", e);
}
// Ensure overlay is active on parent (redundancy for safety)
#[cfg(target_os = "macos")]
if let Some(parent) = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL) {
if let Err(e) = SetInteractionOverlay(true).emit(&parent) {
error!("Failed to ensure interaction overlay on parent: {}", e);
}
}
// Emit event to update context
if let Some(id) = doll_id {
if let Err(e) = EditDoll(id).emit(&window) {
error!("Failed to emit edit-doll event: {}", e);
}
} else if let Err(e) = CreateDoll.emit(&window) {
error!("Failed to emit create-doll event: {}", e);
}
return;
}
let url_path = if let Some(id) = doll_id {
format!("/doll-editor?id={}", id)
let url_path = if let Some(ref id) = doll_id {
format!("/doll-editor?id={}", encode_query_value(id))
} else {
"/doll-editor".to_string()
};
let mut builder = tauri::WebviewWindowBuilder::new(
app_handle,
&window_label,
tauri::WebviewUrl::App(url_path.into()),
)
.title("Doll Editor")
.inner_size(300.0, 400.0)
.resizable(false)
.maximizable(false)
.decorations(true)
.transparent(false)
.shadow(true)
.visible(true)
.skip_taskbar(false)
.always_on_top(true) // Helper window, nice to stay on top
.visible_on_all_workspaces(false);
let has_existing_window = app_handle.get_webview_window(&window_label).is_some();
let parent_window = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL);
// Set parent if app menu exists
// Also disable interaction with parent while child is open
@@ -129,10 +91,11 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
let mut parent_focus_listener_id: Option<u32> = None;
if let Some(parent) = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL) {
if !has_existing_window {
if let Some(parent) = &parent_window {
// 1. Disable parent interaction immediately (Windows only)
#[cfg(target_os = "windows")]
set_window_interaction(&parent, false);
set_window_interaction(parent, false);
// 2. Setup Focus Trap (macOS only)
#[cfg(target_os = "macos")]
@@ -141,7 +104,7 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
let app_handle_clone = get_app_handle().clone();
// Emit event to show overlay
if let Err(e) = SetInteractionOverlay(true).emit(&parent) {
if let Err(e) = SetInteractionOverlay(true).emit(parent) {
error!("Failed to emit set-interaction-overlay event: {}", e);
}
@@ -159,32 +122,22 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
});
parent_focus_listener_id = Some(id);
}
match builder.parent(&parent) {
Ok(b) => builder = b,
Err(e) => {
error!("Failed to set parent for doll editor window: {}", e);
// If we fail, revert changes
#[cfg(target_os = "windows")]
set_window_interaction(&parent, true);
#[cfg(target_os = "macos")]
{
if let Some(id) = parent_focus_listener_id {
parent.unlisten(id);
}
// Remove overlay if we failed
let _ = SetInteractionOverlay(false).emit(&parent);
}
return;
}
};
}
}
match builder.build() {
Ok(window) => {
info!("{} window builder succeeded", window_label);
let mut config = WindowConfig::regular_ui(window_label.as_str(), url_path, "Doll Editor");
config.width = 300.0;
config.height = 400.0;
config.always_on_top = true;
config.parent_label = if !has_existing_window && parent_window.is_some() {
Some(APP_MENU_WINDOW_LABEL)
} else {
None
};
config.require_parent = false;
match ensure_window(&config, true, true) {
Ok(EnsureWindowResult::Created(window)) => {
// 3. Setup cleanup hook: When this child window is destroyed, re-enable the parent
let app_handle_clone = get_app_handle().clone();
@@ -223,10 +176,35 @@ pub async fn open_doll_editor_window(doll_id: Option<String>) {
// #[cfg(debug_assertions)]
// window.open_devtools();
}
Err(e) => {
Ok(EnsureWindowResult::Existing(window)) => {
#[cfg(target_os = "macos")]
if let Some(parent) = parent_window {
if let Err(e) = SetInteractionOverlay(true).emit(&parent) {
error!("Failed to ensure interaction overlay on parent: {}", e);
}
}
if let Some(id) = doll_id {
if let Err(e) = EditDoll(id).emit(&window) {
error!("Failed to emit edit-doll event: {}", e);
}
} else if let Err(e) = CreateDoll.emit(&window) {
error!("Failed to emit create-doll event: {}", e);
}
}
Err(EnsureWindowError::ShowExisting(e)) => {
error!("Failed to show existing {} window: {}", window_label, e);
}
Err(EnsureWindowError::MissingParent(parent_label)) => {
error!(
"Failed to create {} due to missing parent '{}': impossible state",
window_label, parent_label
);
}
Err(EnsureWindowError::SetParent(e)) | Err(EnsureWindowError::Build(e)) => {
error!("Failed to build {} window: {}", window_label, e);
// If build failed, revert
if let Some(parent) = get_app_handle().get_webview_window(APP_MENU_WINDOW_LABEL) {
if let Some(parent) = parent_window {
#[cfg(target_os = "windows")]
set_window_interaction(&parent, true);

View File

@@ -0,0 +1,116 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, RwLock},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event as _;
use crate::{
get_app_handle, lock_r, lock_w,
models::{dolls::DollDto, friends::FriendshipResponseDto},
services::{app_events::FriendActiveDollSpritesUpdated, sprite},
state::FDOLL,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
#[serde(transparent)]
pub struct FriendActiveDollSpritesDto(pub HashMap<String, String>);
static FRIEND_ACTIVE_DOLL_SPRITES: LazyLock<Arc<RwLock<HashMap<String, String>>>> =
LazyLock::new(|| Arc::new(RwLock::new(HashMap::new())));
pub fn sync_from_app_data() {
let friends = {
let guard = lock_r!(FDOLL);
guard.user_data.friends.clone().unwrap_or_default()
};
let next = build_sprites(&friends);
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
*projection = next;
emit_snapshot(&projection);
}
pub fn clear() {
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
projection.clear();
emit_snapshot(&projection);
}
pub fn remove_friend(user_id: &str) {
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
if projection.remove(user_id).is_some() {
emit_snapshot(&projection);
}
}
pub fn set_active_doll(user_id: &str, doll: Option<&DollDto>) {
let mut projection = lock_w!(FRIEND_ACTIVE_DOLL_SPRITES);
match doll {
Some(doll) => match sprite::encode_doll_sprite_base64(doll) {
Ok(sprite_b64) => {
projection.insert(user_id.to_string(), sprite_b64);
emit_snapshot(&projection);
}
Err(err) => {
tracing::warn!(
"Failed to generate active doll sprite for friend {}: {}",
user_id,
err
);
if projection.remove(user_id).is_some() {
emit_snapshot(&projection);
}
}
},
None => {
if projection.remove(user_id).is_some() {
emit_snapshot(&projection);
}
}
}
}
pub fn get_snapshot() -> FriendActiveDollSpritesDto {
let projection = lock_r!(FRIEND_ACTIVE_DOLL_SPRITES);
FriendActiveDollSpritesDto(projection.clone())
}
fn build_sprites(friends: &[FriendshipResponseDto]) -> HashMap<String, String> {
friends
.iter()
.filter_map(|friendship| {
let friend = friendship.friend.as_ref()?;
let doll = friend.active_doll.as_ref()?;
match sprite::encode_doll_sprite_base64(doll) {
Ok(sprite_b64) => Some((friend.id.clone(), sprite_b64)),
Err(err) => {
tracing::warn!(
"Failed to generate active doll sprite for friend {}: {}",
friend.id,
err
);
None
}
}
})
.collect()
}
fn emit_snapshot(sprites: &HashMap<String, String>) {
let payload = FriendActiveDollSpritesDto(sprites.clone());
if let Err(err) = FriendActiveDollSpritesUpdated(payload).emit(get_app_handle()) {
tracing::warn!("Failed to emit friend active doll sprites update: {}", err);
}
}

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