Compare commits
60 Commits
291e0311e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 22bbcaa2df | |||
| c7a5b50223 | |||
| 8a8e77125a | |||
| a945526808 | |||
| 743af7adb6 | |||
| cba5cf1980 | |||
| 5e1009616c | |||
| d55d7e90f0 | |||
| 5d2f2241f3 | |||
| cb0b366e96 | |||
| 53248243e3 | |||
| e7f9633fcc | |||
| 96d1ead1a9 | |||
| 4aa2b00d17 | |||
| 75ab799a7f | |||
| 4093b0eb0c | |||
| aa9d1f54a1 | |||
| a72430e65f | |||
| 8f4aab3d87 | |||
| a83f74494b | |||
| f4021a1d2a | |||
| 16811a8243 | |||
| 680fd3c617 | |||
| 5165bc2c16 | |||
| db972c8387 | |||
| c47b8d464d | |||
| df02a9189d | |||
|
|
3d68a7d494 | ||
| 1137f57610 | |||
|
|
5bf4f62e7f | ||
|
|
dd6cd9b7d6 | ||
| 4334891887 | |||
| f8e88dfba6 | |||
| aa2ccf6c3f | |||
| 5e0f5f19f0 | |||
| 2a485f9a0b | |||
| 3cc4f5366d | |||
| 905ba5abc0 | |||
| e99f8a7608 | |||
| 0e305f821c | |||
| 68c8635497 | |||
| 5d01d69c53 | |||
| 4404045033 | |||
| fc883cff18 | |||
| 083c62efa0 | |||
| 475484abea | |||
| 5eb25bd026 | |||
| 2f0c967bc0 | |||
| da93c2e4a4 | |||
| 858858ab48 | |||
| 341dd48132 | |||
| 3437bc5746 | |||
| e38697faa9 | |||
| a4d8601297 | |||
| ceaa1257bf | |||
| 02f1119254 | |||
| d582ea7fe8 | |||
| 03ae3e0829 | |||
| 69cfebee3d | |||
| 23c778a0bb |
67
.github/workflows/release.yml
vendored
Normal 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 }}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "friendolls-desktop",
|
"name": "friendolls-desktop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.4",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-updater": "~2.10.0",
|
||||||
"tailwindcss": "^4.1.17"
|
"tailwindcss": "^4.1.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
15
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.2
|
version: 2.5.2
|
||||||
|
'@tauri-apps/plugin-updater':
|
||||||
|
specifier: ~2.10.0
|
||||||
|
version: 2.10.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.17
|
specifier: ^4.1.17
|
||||||
version: 4.1.17
|
version: 4.1.17
|
||||||
@@ -467,6 +470,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
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':
|
'@tauri-apps/api@2.9.0':
|
||||||
resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==}
|
resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==}
|
||||||
|
|
||||||
@@ -544,6 +550,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-opener@2.5.2':
|
'@tauri-apps/plugin-opener@2.5.2':
|
||||||
resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==}
|
resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==}
|
||||||
|
|
||||||
|
'@tauri-apps/plugin-updater@2.10.0':
|
||||||
|
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
|
||||||
|
|
||||||
'@types/cookie@0.6.0':
|
'@types/cookie@0.6.0':
|
||||||
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
|
||||||
|
|
||||||
@@ -1137,6 +1146,8 @@ snapshots:
|
|||||||
tailwindcss: 4.1.17
|
tailwindcss: 4.1.17
|
||||||
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
|
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/api@2.9.0': {}
|
||||||
|
|
||||||
'@tauri-apps/cli-darwin-arm64@2.9.4':
|
'@tauri-apps/cli-darwin-arm64@2.9.4':
|
||||||
@@ -1190,6 +1201,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.0
|
'@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/cookie@0.6.0': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
API_BASE_URL=http://127.0.0.1:3000
|
|
||||||
255
src-tauri/Cargo.lock
generated
@@ -72,6 +72,15 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "ashpd"
|
name = "ashpd"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -801,6 +810,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -1134,6 +1154,17 @@ dependencies = [
|
|||||||
"rustc_version",
|
"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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1209,7 +1240,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "friendolls-desktop"
|
name = "friendolls-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"device_query",
|
"device_query",
|
||||||
@@ -1238,11 +1269,13 @@ dependencies = [
|
|||||||
"strum",
|
"strum",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-macros",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-positioner",
|
"tauri-plugin-positioner",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"tauri-specta",
|
"tauri-specta",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1496,8 +1529,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1507,9 +1542,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1841,6 +1878,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1911,9 +1949,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ico"
|
name = "ico"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png",
|
||||||
@@ -2301,6 +2339,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"libc",
|
"libc",
|
||||||
|
"redox_syscall",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2369,12 @@ version = "0.4.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lua-src"
|
name = "lua-src"
|
||||||
version = "550.0.0"
|
version = "550.0.0"
|
||||||
@@ -2435,6 +2480,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2839,6 +2890,18 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"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]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2989,6 +3052,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3380,6 +3457,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.42"
|
version = "1.0.42"
|
||||||
@@ -3618,6 +3750,8 @@ dependencies = [
|
|||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3625,6 +3759,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3634,6 +3769,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3754,6 +3890,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
|
checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -3766,6 +3903,7 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
|
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4504,6 +4642,17 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"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]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -4586,9 +4735,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-codegen"
|
name = "tauri-codegen"
|
||||||
version = "2.5.1"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
|
checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -4613,9 +4762,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-macros"
|
name = "tauri-macros"
|
||||||
version = "2.5.1"
|
version = "2.5.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
|
checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4744,6 +4893,38 @@ dependencies = [
|
|||||||
"tauri-plugin",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.1"
|
version = "2.9.1"
|
||||||
@@ -4826,9 +5007,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.8.0"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
|
checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"brotli",
|
"brotli",
|
||||||
@@ -4987,6 +5168,21 @@ dependencies = [
|
|||||||
"zerovec",
|
"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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.48.0"
|
version = "1.48.0"
|
||||||
@@ -5717,6 +5913,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webkit2gtk"
|
name = "webkit2gtk"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -5761,6 +5967,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -6487,6 +6702,16 @@ version = "0.13.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
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]]
|
[[package]]
|
||||||
name = "xkbcommon"
|
name = "xkbcommon"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -6683,6 +6908,18 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"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]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.8.0"
|
version = "5.8.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "friendolls-desktop"
|
name = "friendolls-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.4"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -50,9 +50,11 @@ enigo = { version = "0.6.1", features = ["wayland"] }
|
|||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde", "async"] }
|
mlua = { version = "0.11", default-features = false, features = ["lua54", "vendored", "serde", "async"] }
|
||||||
petpet = "2.4.3"
|
petpet = "2.4.3"
|
||||||
|
tauri-macros = "2.5.5"
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
tauri-plugin-positioner = "2"
|
tauri-plugin-positioner = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6.3"
|
objc2 = "0.6.3"
|
||||||
objc2-app-kit = { version = "0.3.2", features = [
|
objc2-app-kit = { version = "0.3.2", features = [
|
||||||
|
|||||||
14
src-tauri/capabilities/desktop.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"identifier": "desktop-capability",
|
||||||
|
"platforms": [
|
||||||
|
"macOS",
|
||||||
|
"windows",
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"updater:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.7 KiB |
@@ -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>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/icon.tray.png
Normal file
|
After Width: | Height: | Size: 287 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
51
src-tauri/src/assets/auth-cancelled.html
Normal 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>
|
||||||
51
src-tauri/src/assets/auth-failed.html
Normal 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>
|
||||||
51
src-tauri/src/assets/auth-success.html
Normal 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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::get_app_handle;
|
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::auth::get_session_token;
|
||||||
|
use crate::services::session::construct_user_session;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
lock_r,
|
lock_r,
|
||||||
models::app_data::UserData,
|
models::{app_data::UserData, app_state::{AppState, NekoPosition}},
|
||||||
services::presence_modules::models::ModuleMetadata,
|
services::{
|
||||||
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
|
app_data::{init_app_data_scoped, AppDataRefreshScope},
|
||||||
|
app_state,
|
||||||
|
friends,
|
||||||
|
neko_positions,
|
||||||
|
presence_modules::models::ModuleMetadata,
|
||||||
|
sprite,
|
||||||
|
},
|
||||||
|
state::FDOLL,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -26,3 +33,47 @@ pub fn get_modules() -> Result<Vec<ModuleMetadata>, String> {
|
|||||||
let guard = lock_r!(FDOLL);
|
let guard = lock_r!(FDOLL);
|
||||||
Ok(guard.modules.metadatas.clone())
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,45 +8,16 @@ pub async fn logout_and_restart() -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn login(email: String, password: String) -> Result<(), String> {
|
pub async fn start_google_auth() -> Result<(), String> {
|
||||||
auth::login_and_init_session(&email, &password)
|
auth::start_browser_login("google")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn register(
|
pub async fn start_discord_auth() -> Result<(), String> {
|
||||||
email: String,
|
auth::start_browser_login("discord")
|
||||||
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(¤t_password, &new_password)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
#[specta::specta]
|
|
||||||
pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> {
|
|
||||||
auth::reset_password(&old_password, &new_password)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
lock_w,
|
lock_w,
|
||||||
services::client_config_manager::{
|
services::client_config::{load_app_config, open_config_window, save_app_config, AppConfig},
|
||||||
load_app_config, open_config_manager_window, save_app_config, AppConfig,
|
|
||||||
},
|
|
||||||
state::FDOLL,
|
state::FDOLL,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +27,6 @@ pub fn save_client_config(config: AppConfig) -> Result<(), String> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn open_client_config_manager() -> Result<(), String> {
|
pub async fn open_client_config() -> Result<(), String> {
|
||||||
open_config_manager_window().map_err(|e| e.to_string())
|
open_config_window().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
commands::{is_active_doll, refresh_app_data, refresh_app_data_conditionally},
|
||||||
models::dolls::{CreateDollDto, DollDto, UpdateDollDto},
|
models::dolls::{CreateDollDto, DollDto, UpdateDollDto},
|
||||||
remotes::{
|
remotes::{dolls::DollsRemote, user::UserRemote},
|
||||||
dolls::DollsRemote,
|
services::app_data::AppDataRefreshScope,
|
||||||
user::UserRemote,
|
|
||||||
},
|
|
||||||
state::AppDataRefreshScope,
|
|
||||||
commands::{refresh_app_data, refresh_app_data_conditionally, is_active_doll},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -53,7 +50,8 @@ pub async fn update_doll(id: String, dto: UpdateDollDto) -> Result<DollDto, Stri
|
|||||||
refresh_app_data_conditionally(
|
refresh_app_data_conditionally(
|
||||||
&[AppDataRefreshScope::Dolls],
|
&[AppDataRefreshScope::Dolls],
|
||||||
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
|
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -72,7 +70,8 @@ pub async fn delete_doll(id: String) -> Result<(), String> {
|
|||||||
refresh_app_data_conditionally(
|
refresh_app_data_conditionally(
|
||||||
&[AppDataRefreshScope::Dolls],
|
&[AppDataRefreshScope::Dolls],
|
||||||
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
|
is_active.then_some(&[AppDataRefreshScope::User, AppDataRefreshScope::Friends]),
|
||||||
).await;
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::remotes::friends::FriendRemote;
|
use crate::commands::refresh_app_data;
|
||||||
use crate::models::friends::{
|
use crate::models::friends::{
|
||||||
FriendRequestResponseDto, FriendshipResponseDto, SendFriendRequestDto, UserBasicDto,
|
FriendRequestResponseDto, FriendshipResponseDto, SendFriendRequestDto, UserBasicDto,
|
||||||
};
|
};
|
||||||
use crate::state::AppDataRefreshScope;
|
use crate::remotes::friends::FriendRemote;
|
||||||
use crate::commands::refresh_app_data;
|
use crate::services::app_data::AppDataRefreshScope;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ pub mod config;
|
|||||||
pub mod dolls;
|
pub mod dolls;
|
||||||
pub mod friends;
|
pub mod friends;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod sprite;
|
|
||||||
pub mod petpet;
|
pub mod petpet;
|
||||||
|
pub mod sprite;
|
||||||
|
|
||||||
use crate::lock_r;
|
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;
|
use tauri::async_runtime;
|
||||||
|
|
||||||
/// Helper to execute a mutation operation and refresh app data scopes in the background.
|
/// Helper to execute a mutation operation and refresh app data scopes in the background.
|
||||||
|
|||||||
@@ -3,60 +3,7 @@ use std::time::Duration;
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::{
|
use crate::{models::health::HealthError, remotes::health::HealthRemote};
|
||||||
models::health::HealthError,
|
|
||||||
remotes::health::HealthRemote,
|
|
||||||
services::{
|
|
||||||
close_all_windows,
|
|
||||||
health_manager::open_health_manager_window,
|
|
||||||
health_monitor::{start_health_monitor, stop_health_monitor},
|
|
||||||
scene::open_scene_window,
|
|
||||||
ws::client::{clear_ws_client, establish_websocket_connection},
|
|
||||||
},
|
|
||||||
state::{
|
|
||||||
auth::{start_background_token_refresh, stop_background_token_refresh},
|
|
||||||
clear_app_data, init_app_data_scoped, AppDataRefreshScope,
|
|
||||||
},
|
|
||||||
system_tray::update_system_tray,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Connects the user profile and opens the scene window.
|
|
||||||
pub async fn construct_user_session() {
|
|
||||||
connect_user_profile().await;
|
|
||||||
close_all_windows();
|
|
||||||
open_scene_window();
|
|
||||||
update_system_tray(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Disconnects the user profile and closes the scene window.
|
|
||||||
pub async fn destruct_user_session() {
|
|
||||||
disconnect_user_profile().await;
|
|
||||||
close_all_windows();
|
|
||||||
update_system_tray(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initializes the user profile and establishes a WebSocket connection.
|
|
||||||
async fn connect_user_profile() {
|
|
||||||
init_app_data_scoped(AppDataRefreshScope::All).await;
|
|
||||||
establish_websocket_connection().await;
|
|
||||||
start_background_token_refresh().await;
|
|
||||||
start_health_monitor().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears the user profile and WebSocket connection.
|
|
||||||
async fn disconnect_user_profile() {
|
|
||||||
stop_health_monitor();
|
|
||||||
stop_background_token_refresh();
|
|
||||||
clear_app_data();
|
|
||||||
clear_ws_client().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Destructs the user session and show health manager window
|
|
||||||
/// with error message, offering troubleshooting options.
|
|
||||||
pub async fn handle_disasterous_failure(error_message: Option<String>) {
|
|
||||||
destruct_user_session().await;
|
|
||||||
open_health_manager_window(error_message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pings the server's health endpoint a maximum of
|
/// Pings the server's health endpoint a maximum of
|
||||||
/// three times with a backoff of 500ms between
|
/// three times with a backoff of 500ms between
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
init::{
|
init::{lifecycle::validate_server_health, tracing::init_logging},
|
||||||
lifecycle::{construct_user_session, handle_disasterous_failure, validate_server_health},
|
|
||||||
tracing::init_logging,
|
|
||||||
},
|
|
||||||
services::{
|
services::{
|
||||||
|
app_update::update_app,
|
||||||
auth::get_session_token,
|
auth::get_session_token,
|
||||||
cursor::init_cursor_tracking,
|
cursor::init_cursor_tracking,
|
||||||
presence_modules::init_modules,
|
presence_modules::init_modules,
|
||||||
scene::{close_splash_window, open_splash_window},
|
scene::{close_splash_window, open_splash_window},
|
||||||
|
session::{construct_user_session, handle_disastrous_failure},
|
||||||
welcome::open_welcome_window,
|
welcome::open_welcome_window,
|
||||||
},
|
},
|
||||||
state::init_app_state,
|
state::init_app_state,
|
||||||
@@ -22,13 +21,14 @@ pub mod tracing;
|
|||||||
pub async fn launch_app() {
|
pub async fn launch_app() {
|
||||||
init_logging();
|
init_logging();
|
||||||
open_splash_window();
|
open_splash_window();
|
||||||
|
update_app().await;
|
||||||
init_app_state();
|
init_app_state();
|
||||||
init_system_tray();
|
init_system_tray();
|
||||||
init_cursor_tracking().await;
|
init_cursor_tracking().await;
|
||||||
init_modules();
|
init_modules();
|
||||||
|
|
||||||
if let Err(err) = validate_server_health().await {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use crate::{
|
use crate::services::{
|
||||||
commands::app_state::get_modules,
|
doll_editor::open_doll_editor_window,
|
||||||
services::{
|
scene::{get_scene_interactive, set_pet_menu_state, set_scene_interactive},
|
||||||
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::{quit_app, restart_app, retry_connection};
|
||||||
use commands::app_state::{get_app_data, refresh_app_data};
|
use commands::app_state::{
|
||||||
use commands::auth::{change_password, login, logout_and_restart, register, reset_password};
|
get_active_doll_sprite_base64, get_app_data, get_app_state, get_neko_positions,
|
||||||
use commands::config::{get_client_config, open_client_config_manager, save_client_config};
|
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::{
|
use commands::dolls::{
|
||||||
create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll,
|
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,
|
search_users, send_friend_request, sent_friend_requests, unfriend,
|
||||||
};
|
};
|
||||||
use commands::interaction::send_interaction_cmd;
|
use commands::interaction::send_interaction_cmd;
|
||||||
use commands::sprite::recolor_gif_base64;
|
|
||||||
use commands::petpet::encode_pet_doll_gif_base64;
|
use commands::petpet::encode_pet_doll_gif_base64;
|
||||||
|
use commands::sprite::recolor_gif_base64;
|
||||||
use specta_typescript::Typescript;
|
use specta_typescript::Typescript;
|
||||||
use tauri::async_runtime;
|
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::{
|
use crate::services::app_events::{
|
||||||
AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, FriendActiveDollChanged,
|
ActiveDollSpriteChanged, AppDataRefreshed, AppStateChanged, AuthFlowUpdated, CreateDoll,
|
||||||
FriendCursorPositionUpdated, FriendDisconnected, FriendRequestAccepted,
|
EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendDisconnected,
|
||||||
FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged,
|
||||||
InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged,
|
InteractionDeliveryFailed, InteractionReceived, NekoPositionsUpdated,
|
||||||
SetInteractionOverlay, Unfriended, UserStatusChanged,
|
SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged,
|
||||||
};
|
};
|
||||||
|
|
||||||
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
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)
|
.error_handling(ErrorHandlingMode::Throw)
|
||||||
.commands(collect_commands![
|
.commands(collect_commands![
|
||||||
get_app_data,
|
get_app_data,
|
||||||
|
get_app_state,
|
||||||
|
get_neko_positions,
|
||||||
|
get_active_doll_sprite_base64,
|
||||||
|
get_friend_active_doll_sprites_base64,
|
||||||
refresh_app_data,
|
refresh_app_data,
|
||||||
list_friends,
|
list_friends,
|
||||||
search_users,
|
search_users,
|
||||||
@@ -88,37 +93,41 @@ pub fn run() {
|
|||||||
retry_connection,
|
retry_connection,
|
||||||
get_client_config,
|
get_client_config,
|
||||||
save_client_config,
|
save_client_config,
|
||||||
open_client_config_manager,
|
open_client_config,
|
||||||
open_doll_editor_window,
|
open_doll_editor_window,
|
||||||
get_scene_interactive,
|
get_scene_interactive,
|
||||||
set_scene_interactive,
|
set_scene_interactive,
|
||||||
set_pet_menu_state,
|
set_pet_menu_state,
|
||||||
login,
|
start_google_auth,
|
||||||
register,
|
start_discord_auth,
|
||||||
change_password,
|
|
||||||
reset_password,
|
|
||||||
logout_and_restart,
|
logout_and_restart,
|
||||||
send_interaction_cmd,
|
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![
|
.events(collect_events![
|
||||||
CursorMoved,
|
|
||||||
SceneInteractiveChanged,
|
SceneInteractiveChanged,
|
||||||
AppDataRefreshed,
|
AppDataRefreshed,
|
||||||
|
AppStateChanged,
|
||||||
|
NekoPositionsUpdated,
|
||||||
|
ActiveDollSpriteChanged,
|
||||||
SetInteractionOverlay,
|
SetInteractionOverlay,
|
||||||
EditDoll,
|
EditDoll,
|
||||||
CreateDoll,
|
CreateDoll,
|
||||||
UserStatusChanged,
|
UserStatusChanged,
|
||||||
FriendCursorPositionUpdated,
|
|
||||||
FriendDisconnected,
|
FriendDisconnected,
|
||||||
FriendActiveDollChanged,
|
FriendActiveDollChanged,
|
||||||
|
FriendActiveDollSpritesUpdated,
|
||||||
FriendUserStatusChanged,
|
FriendUserStatusChanged,
|
||||||
InteractionReceived,
|
InteractionReceived,
|
||||||
InteractionDeliveryFailed,
|
InteractionDeliveryFailed,
|
||||||
FriendRequestReceived,
|
FriendRequestReceived,
|
||||||
FriendRequestAccepted,
|
FriendRequestAccepted,
|
||||||
FriendRequestDenied,
|
FriendRequestDenied,
|
||||||
Unfriended
|
Unfriended,
|
||||||
|
AuthFlowUpdated
|
||||||
]);
|
]);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -127,6 +136,7 @@ pub fn run() {
|
|||||||
.expect("Failed to export TypeScript bindings");
|
.expect("Failed to export TypeScript bindings");
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_positioner::init())
|
.plugin(tauri_plugin_positioner::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
|||||||
39
src-tauri/src/models/app_state.rs
Normal 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,
|
||||||
|
}
|
||||||
@@ -19,6 +19,20 @@ pub struct UserStatusPayload {
|
|||||||
pub state: UserStatusState,
|
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)]
|
#[derive(Clone, Serialize, Deserialize, Debug, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FriendUserStatusPayload {
|
pub struct FriendUserStatusPayload {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod app_data;
|
pub mod app_data;
|
||||||
|
pub mod app_state;
|
||||||
pub mod dolls;
|
pub mod dolls;
|
||||||
pub mod event_payloads;
|
pub mod event_payloads;
|
||||||
pub mod friends;
|
pub mod friends;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::{lock_r, services::auth::with_auth, state::FDOLL, models::dolls::*};
|
|
||||||
use crate::models::remote_error::RemoteError;
|
use crate::models::remote_error::RemoteError;
|
||||||
|
use crate::{lock_r, models::dolls::*, services::auth::with_auth, state::FDOLL};
|
||||||
|
|
||||||
pub struct DollsRemote {
|
pub struct DollsRemote {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -19,7 +19,8 @@ impl DollsRemote {
|
|||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.clone(),
|
.clone(),
|
||||||
client: guard
|
client: guard
|
||||||
.network.clients
|
.network
|
||||||
|
.clients
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.http_client
|
.http_client
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::{lock_r, services::auth::with_auth, state::FDOLL, models::friends::*};
|
|
||||||
use crate::models::remote_error::RemoteError;
|
use crate::models::remote_error::RemoteError;
|
||||||
|
use crate::{lock_r, models::friends::*, services::auth::with_auth, state::FDOLL};
|
||||||
|
|
||||||
pub struct FriendRemote {
|
pub struct FriendRemote {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -19,7 +19,8 @@ impl FriendRemote {
|
|||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.clone(),
|
.clone(),
|
||||||
client: guard
|
client: guard
|
||||||
.network.clients
|
.network
|
||||||
|
.clients
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.http_client
|
.http_client
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
||||||
use crate::{lock_r, state::FDOLL, models::health::*};
|
use crate::{lock_r, models::health::*, state::FDOLL};
|
||||||
|
|
||||||
pub struct HealthRemote {
|
pub struct HealthRemote {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -18,7 +18,8 @@ impl HealthRemote {
|
|||||||
.ok_or(HealthError::ConfigMissing("api_base_url"))?;
|
.ok_or(HealthError::ConfigMissing("api_base_url"))?;
|
||||||
|
|
||||||
let client = guard
|
let client = guard
|
||||||
.network.clients
|
.network
|
||||||
|
.clients
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.http_client.clone())
|
.map(|c| c.http_client.clone())
|
||||||
.ok_or(HealthError::ConfigMissing("http_client"))?;
|
.ok_or(HealthError::ConfigMissing("http_client"))?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use reqwest::{Client, Error};
|
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 struct UserRemote {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -18,7 +18,8 @@ impl UserRemote {
|
|||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.clone(),
|
.clone(),
|
||||||
client: guard
|
client: guard
|
||||||
.network.clients
|
.network
|
||||||
|
.clients
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("App configuration error")
|
.expect("App configuration error")
|
||||||
.http_client
|
.http_client
|
||||||
|
|||||||
307
src-tauri/src/services/accelerators.rs
Normal 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))
|
||||||
|
}
|
||||||
70
src-tauri/src/services/app_data/display.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src-tauri/src/services/app_data/mod.rs
Normal 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};
|
||||||
179
src-tauri/src/services/app_data/refresh.rs
Normal 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(|_| {});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ use tauri_specta::Event;
|
|||||||
use crate::{
|
use crate::{
|
||||||
models::{
|
models::{
|
||||||
app_data::UserData,
|
app_data::UserData,
|
||||||
|
app_state::AppState,
|
||||||
event_payloads::{
|
event_payloads::{
|
||||||
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
|
FriendActiveDollChangedPayload, FriendDisconnectedPayload,
|
||||||
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
|
FriendRequestAcceptedPayload, FriendRequestDeniedPayload, FriendRequestReceivedPayload,
|
||||||
@@ -12,12 +13,25 @@ use crate::{
|
|||||||
},
|
},
|
||||||
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
|
interaction::{InteractionDeliveryFailedDto, InteractionPayloadDto},
|
||||||
},
|
},
|
||||||
services::{cursor::CursorPositions, ws::OutgoingFriendCursorPayload},
|
services::{friends::FriendActiveDollSpritesDto, neko_positions::NekoPositionsDto},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
#[tauri_specta(event_name = "cursor-position")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub struct CursorMoved(pub CursorPositions);
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "scene-interactive")]
|
#[tauri_specta(event_name = "scene-interactive")]
|
||||||
@@ -27,6 +41,14 @@ pub struct SceneInteractiveChanged(pub bool);
|
|||||||
#[tauri_specta(event_name = "app-data-refreshed")]
|
#[tauri_specta(event_name = "app-data-refreshed")]
|
||||||
pub struct AppDataRefreshed(pub UserData);
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "set-interaction-overlay")]
|
#[tauri_specta(event_name = "set-interaction-overlay")]
|
||||||
pub struct SetInteractionOverlay(pub bool);
|
pub struct SetInteractionOverlay(pub bool);
|
||||||
@@ -44,8 +66,8 @@ pub struct CreateDoll;
|
|||||||
pub struct UserStatusChanged(pub UserStatusPayload);
|
pub struct UserStatusChanged(pub UserStatusPayload);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "friend-cursor-position")]
|
#[tauri_specta(event_name = "neko-positions")]
|
||||||
pub struct FriendCursorPositionUpdated(pub OutgoingFriendCursorPayload);
|
pub struct NekoPositionsUpdated(pub NekoPositionsDto);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "friend-disconnected")]
|
#[tauri_specta(event_name = "friend-disconnected")]
|
||||||
@@ -55,6 +77,10 @@ pub struct FriendDisconnected(pub FriendDisconnectedPayload);
|
|||||||
#[tauri_specta(event_name = "friend-active-doll-changed")]
|
#[tauri_specta(event_name = "friend-active-doll-changed")]
|
||||||
pub struct FriendActiveDollChanged(pub FriendActiveDollChangedPayload);
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "friend-user-status")]
|
#[tauri_specta(event_name = "friend-user-status")]
|
||||||
pub struct FriendUserStatusChanged(pub FriendUserStatusPayload);
|
pub struct FriendUserStatusChanged(pub FriendUserStatusPayload);
|
||||||
@@ -82,3 +108,7 @@ pub struct FriendRequestDenied(pub FriendRequestDeniedPayload);
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[tauri_specta(event_name = "unfriended")]
|
#[tauri_specta(event_name = "unfriended")]
|
||||||
pub struct Unfriended(pub UnfriendedPayload);
|
pub struct Unfriended(pub UnfriendedPayload);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
|
#[tauri_specta(event_name = "auth-flow-updated")]
|
||||||
|
pub struct AuthFlowUpdated(pub AuthFlowUpdatedPayload);
|
||||||
|
|||||||
@@ -1,41 +1,29 @@
|
|||||||
use tauri::Manager;
|
use tracing::error;
|
||||||
use tracing::{error, info};
|
|
||||||
|
|
||||||
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 static APP_MENU_WINDOW_LABEL: &str = "app_menu";
|
||||||
|
|
||||||
pub fn open_app_menu_window() {
|
pub fn open_app_menu_window() {
|
||||||
let app_handle = get_app_handle();
|
let mut config = WindowConfig::regular_ui(APP_MENU_WINDOW_LABEL, "/app-menu", "Friendolls");
|
||||||
let existing_webview_window = app_handle.get_window(APP_MENU_WINDOW_LABEL);
|
config.width = 400.0;
|
||||||
|
config.height = 550.0;
|
||||||
|
config.resizable = true;
|
||||||
|
|
||||||
if let Some(window) = existing_webview_window {
|
match ensure_window(&config, true, false) {
|
||||||
window.show().unwrap();
|
Ok(EnsureWindowResult::Created(_)) => {}
|
||||||
return;
|
Ok(EnsureWindowResult::Existing(_)) => {}
|
||||||
}
|
Err(EnsureWindowError::MissingParent(parent_label)) => {
|
||||||
|
error!(
|
||||||
match tauri::WebviewWindowBuilder::new(
|
"Failed to build {} window due to missing parent '{}': impossible state",
|
||||||
app_handle,
|
APP_MENU_WINDOW_LABEL, parent_label
|
||||||
APP_MENU_WINDOW_LABEL,
|
);
|
||||||
tauri::WebviewUrl::App("/app-menu".into()),
|
|
||||||
)
|
|
||||||
.title("Friendolls")
|
|
||||||
.inner_size(400.0, 550.0)
|
|
||||||
.resizable(true)
|
|
||||||
.maximizable(false)
|
|
||||||
.decorations(true)
|
|
||||||
.transparent(false)
|
|
||||||
.shadow(true)
|
|
||||||
.visible(true)
|
|
||||||
.skip_taskbar(false)
|
|
||||||
.always_on_top(false)
|
|
||||||
.visible_on_all_workspaces(false)
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
info!("{} window builder succeeded", APP_MENU_WINDOW_LABEL);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(EnsureWindowError::ShowExisting(e))
|
||||||
|
| Err(EnsureWindowError::SetParent(e))
|
||||||
|
| Err(EnsureWindowError::Build(e)) => {
|
||||||
error!("Failed to build {} window: {}", APP_MENU_WINDOW_LABEL, e);
|
error!("Failed to build {} window: {}", APP_MENU_WINDOW_LABEL, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src-tauri/src/services/app_state.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src-tauri/src/services/app_update.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
160
src-tauri/src/services/auth/api.rs
Normal 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(())
|
||||||
|
}
|
||||||
397
src-tauri/src/services/auth/flow.rs
Normal 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(())
|
||||||
|
}
|
||||||
8
src-tauri/src/services/auth/mod.rs
Normal 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};
|
||||||
63
src-tauri/src/services/auth/session.rs
Normal 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
|
||||||
|
}
|
||||||
225
src-tauri/src/services/auth/storage.rs
Normal 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(())
|
||||||
|
}
|
||||||
93
src-tauri/src/services/client_config/mod.rs
Normal 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";
|
||||||
@@ -1,38 +1,15 @@
|
|||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use specta::Type;
|
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use thiserror::Error;
|
use tracing::warn;
|
||||||
use tracing::{error, warn};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug, Type)]
|
use super::{AppConfig, ClientConfigError};
|
||||||
pub struct AppConfig {
|
|
||||||
pub api_base_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum ClientConfigError {
|
|
||||||
#[error("failed to resolve app config dir: {0}")]
|
|
||||||
ResolvePath(tauri::Error),
|
|
||||||
#[error("io error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
#[error("failed to parse client config: {0}")]
|
|
||||||
Parse(#[from] serde_json::Error),
|
|
||||||
#[error("failed to run on main thread: {0}")]
|
|
||||||
Dispatch(#[from] tauri::Error),
|
|
||||||
#[error("failed to build client config manager window: {0}")]
|
|
||||||
Window(tauri::Error),
|
|
||||||
#[error("failed to show client config manager window: {0}")]
|
|
||||||
ShowWindow(tauri::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
|
|
||||||
const CONFIG_FILENAME: &str = "client_config.json";
|
const 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> {
|
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
|
||||||
let dir = app_handle
|
let dir = app_handle
|
||||||
@@ -71,12 +48,14 @@ fn sanitize(mut config: AppConfig) -> AppConfig {
|
|||||||
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
|
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
|
||||||
.map(|v| strip_trailing_slash(&v));
|
.map(|v| strip_trailing_slash(&v));
|
||||||
|
|
||||||
config
|
config.normalized()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_app_config() -> AppConfig {
|
pub fn default_app_config() -> AppConfig {
|
||||||
AppConfig {
|
AppConfig {
|
||||||
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
|
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)
|
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
src-tauri/src/services/client_config/window.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,10 @@ use tokio::sync::mpsc;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
get_app_handle,
|
|
||||||
lock_r,
|
lock_r,
|
||||||
services::app_events::CursorMoved,
|
services::{neko_positions, ws::report_cursor_data},
|
||||||
state::FDOLL,
|
state::FDOLL,
|
||||||
};
|
};
|
||||||
use tauri_specta::Event as _;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -64,8 +62,7 @@ pub fn normalized_to_absolute(normalized: &CursorPosition) -> CursorPosition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize cursor tracking. Broadcasts cursor
|
/// Initialize cursor tracking.
|
||||||
/// position changes via `cursor-position` event.
|
|
||||||
pub async fn init_cursor_tracking() {
|
pub async fn init_cursor_tracking() {
|
||||||
info!("start_cursor_tracking called");
|
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);
|
let (tx, mut rx) = mpsc::channel::<CursorPositions>(100);
|
||||||
|
|
||||||
// Spawn the consumer task
|
// 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.
|
// It runs independently of the device event loop.
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
info!("Cursor event consumer started");
|
info!("Cursor event consumer started");
|
||||||
let app_handle = get_app_handle();
|
|
||||||
|
|
||||||
while let Some(positions) = rx.recv().await {
|
while let Some(positions) = rx.recv().await {
|
||||||
let mapped_for_ws = positions.mapped.clone();
|
let mapped_for_ws = positions.mapped.clone();
|
||||||
|
|
||||||
// 1. WebSocket reporting
|
// 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
|
// 2. Update unified neko positions projection
|
||||||
if let Err(e) = CursorMoved(positions).emit(app_handle) {
|
neko_positions::update_self_cursor(positions);
|
||||||
error!("Failed to emit cursor position event: {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
warn!("Cursor event consumer stopped (channel closed)");
|
warn!("Cursor event consumer stopped (channel closed)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use tracing::{error, info};
|
|||||||
use crate::{
|
use crate::{
|
||||||
get_app_handle,
|
get_app_handle,
|
||||||
services::app_events::{CreateDoll, EditDoll, SetInteractionOverlay},
|
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";
|
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
|
// Check if the window already exists
|
||||||
let existing_window = app_handle.get_webview_window(&window_label);
|
let url_path = if let Some(ref id) = doll_id {
|
||||||
if let Some(window) = existing_window {
|
format!("/doll-editor?id={}", encode_query_value(id))
|
||||||
// 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)
|
|
||||||
} else {
|
} else {
|
||||||
"/doll-editor".to_string()
|
"/doll-editor".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut builder = tauri::WebviewWindowBuilder::new(
|
let has_existing_window = app_handle.get_webview_window(&window_label).is_some();
|
||||||
app_handle,
|
let parent_window = app_handle.get_webview_window(APP_MENU_WINDOW_LABEL);
|
||||||
&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);
|
|
||||||
|
|
||||||
// Set parent if app menu exists
|
// Set parent if app menu exists
|
||||||
// Also disable interaction with parent while child is open
|
// 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;
|
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)
|
// 1. Disable parent interaction immediately (Windows only)
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
set_window_interaction(&parent, false);
|
set_window_interaction(parent, false);
|
||||||
|
|
||||||
// 2. Setup Focus Trap (macOS only)
|
// 2. Setup Focus Trap (macOS only)
|
||||||
#[cfg(target_os = "macos")]
|
#[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();
|
let app_handle_clone = get_app_handle().clone();
|
||||||
|
|
||||||
// Emit event to show overlay
|
// 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);
|
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);
|
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() {
|
let mut config = WindowConfig::regular_ui(window_label.as_str(), url_path, "Doll Editor");
|
||||||
Ok(window) => {
|
config.width = 300.0;
|
||||||
info!("{} window builder succeeded", window_label);
|
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
|
// 3. Setup cleanup hook: When this child window is destroyed, re-enable the parent
|
||||||
let app_handle_clone = get_app_handle().clone();
|
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)]
|
// #[cfg(debug_assertions)]
|
||||||
// window.open_devtools();
|
// 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);
|
error!("Failed to build {} window: {}", window_label, e);
|
||||||
// If build failed, revert
|
// 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")]
|
#[cfg(target_os = "windows")]
|
||||||
set_window_interaction(&parent, true);
|
set_window_interaction(&parent, true);
|
||||||
|
|
||||||
|
|||||||
116
src-tauri/src/services/friends/active_doll_sprites.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||