4 Commits

Author SHA1 Message Date
restitux aac401e841 Add blitz feature for Dioxus Native rendering support
Build Mumble Web 2 / linux_build (push) Failing after 1m56s
Build Mumble Web 2 / macos_build (push) Successful in 3m24s
Build Mumble Web 2 / windows_build (push) Successful in 13m11s
Build Mumble Web 2 / android_build (push) Successful in 12m54s
Add an orthogonal `blitz` feature flag that switches the rendering
framework from dioxus webview to dioxus-native (Blitz). Can be combined
with `desktop` or `mobile` features. Also bumps rfd to ^0.17.0 (upstream
git moved forward), removes stale gui-level [patch.crates-io] section,
and adds xdotool to the Dockerfile for libxdo (blitz-shell dependency).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 19:40:18 -06:00
restitux d67a19c478 Create new generic config abstraction (#25)
Build Mumble Web 2 / macos_build (push) Successful in 55s
Build Mumble Web 2 / linux_build (push) Successful in 1m18s
Build Mumble Web 2 / android_build (push) Successful in 5m36s
Build Mumble Web 2 / windows_build (push) Successful in 8m4s
Build android container / android-release-builder-container-build (push) Successful in 7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 14s
This change migrates the config logic to a new generic key+value abstraction. This allows config parameters to be get and set with arbitrary string keys. Config value types can be anything that serde knows how to serialize / deserialize.

Implementations:
Desktop:
Uses a json file in a platform specific directory (pulled from etcetera). This is mostly the same as the existing code. Implemented in `native_config.rs`
Android:
Uses the same mechanism as desktop, with a different path selection that calls out to the android apis (via jni) to get the correct directory.
Web:
Uses browser local storage. Values are stored as strings instead of actual json objects to keep things simple for now. We might want to update this at some point.

Desktop support:
![2026-03-04-223906_grim.png](/attachments/18bfea3e-456c-40f3-9b14-f865c062fcb0)
```
% cat ~/.config/mumble-web2/config.json
{
  "username": "restitux-test",
  "server_url": "voip.ohea.xyz"
}%
```
Web support:
![2026-03-04-223243_grim.png](/attachments/fc55c0c0-1422-4ae8-8e43-9829c6ab7920)
Android support:
![image.png](/attachments/28d9c0f1-ef87-4561-83db-9e4916208267)
```
root@c053bdd1b4da:/# adb shell
tokay:/ $ run-as xyz.ohea.mumble_web_2
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ ls
app_textures  app_webview  cache  code_cache  files  no_backup  shared_prefs
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ ls files
config.json  oat  permission_manager.dex
tokay:/data/user/0/xyz.ohea.mumble_web_2 $ cat files/config.json
{
  "server_url": "voip.ohea.xyz",
  "username": "test"
}tokay:/data/user/0/xyz.ohea.mumble_web_2 $
```

Reviewed-on: #25
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-03-11 04:02:22 +00:00
restitux 518c50d8a4 Add builds and CI for MacOS (#26)
Build Mumble Web 2 / macos_build (push) Successful in 58s
Build Mumble Web 2 / windows_build (push) Successful in 3m6s
Build Mumble Web 2 / linux_build (push) Successful in 1m9s
Build Mumble Web 2 / android_build (push) Successful in 5m41s
Build android container / android-release-builder-container-build (push) Successful in -8s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 15s
This change adds CI to build the desktop client for MacOS. This builds the desktop client as a dmg and a .app and uploads them from the CI pipeline.

Reviewed-on: #26
Reviewed-by: Sam Sartor <cap@samsartor.com>
2026-03-11 03:26:41 +00:00
sam 847c636f41 rename gui_config to proxy_overrides (#23)
Build Mumble Web 2 / linux_build (push) Successful in 1m13s
Build Mumble Web 2 / windows_build (push) Successful in 2m39s
Build Mumble Web 2 / android_build (push) Successful in 6m18s
Build android container / android-release-builder-container-build (push) Successful in -7s
Build Mumble Web 2 release builder containers / windows-release-builder-container-build (push) Successful in 30m8s
Reviewed-on: #23
Reviewed-by: restitux <restitux@ohea.xyz>
Co-authored-by: Sam Sartor <me@samsartor.com>
Co-committed-by: Sam Sartor <me@samsartor.com>
2026-03-05 07:16:02 +00:00
35 changed files with 5039 additions and 956 deletions
+1
View File
@@ -0,0 +1 @@
target
+41
View File
@@ -42,6 +42,47 @@ jobs:
path: target/release/mumble-web2-proxy
retention-days: 5
macos_build:
runs-on: macos
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Restore Rust cache
uses: actions/cache/restore@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-
- name: Install cargo binstall
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- name: Install dioxus-cli
run: cargo binstall dioxus-cli --version 0.7.3 --no-confirm
- name: Build dioxus project
run: dx bundle --platform macos --release -p mumble-web2-gui
- name: Save Rust cache
if: always()
uses: actions/cache/save@v4
with:
path: |
~/.cargo
./target
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Upload mumble-web2-gui Artifact
uses: https://gitea.com/actions/gitea-upload-artifact@v4
with:
name: mumble-web2-gui-macos-arm64
path: gui/dist
retention-days: 5
windows_build:
runs-on: windows
steps:
Generated
+4424 -690
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ClientConfig {
pub struct ProxyOverrides {
pub proxy_url: Option<String>,
pub cert_hash: Option<Vec<u8>>,
pub any_server: bool,
+7 -9
View File
@@ -1,14 +1,12 @@
localhost:64444 {
tls internal
tls internal
# Proxy /config path to mumble-web2-proxy
reverse_proxy /config http://127.0.0.1:4400
# Proxy /config path to mumble-web2-proxy
reverse_proxy /overrides http://127.0.0.1:4400
# Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400
# Proxy root path to dx-serve
reverse_proxy http://127.0.0.1:8080
# Proxy /status path to mumble-web2-proxy
reverse_proxy /status http://127.0.0.1:4400
# Proxy root path to dx-serve
reverse_proxy http://127.0.0.1:8080
}
+33
View File
@@ -0,0 +1,33 @@
FROM archlinux:latest
# Base system + toolchain deps
RUN pacman -Sy --noconfirm archlinux-keyring && \
pacman-key --init && \
pacman-key --populate archlinux && \
pacman -Syu --noconfirm && \
pacman -S --noconfirm --needed \
base-devel git sudo xdotool
# Create non-root build user for AUR
RUN useradd -m -G wheel -s /bin/bash builder && \
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
USER builder
WORKDIR /home/builder
# Install yay from AUR
RUN git clone https://aur.archlinux.org/yay.git /home/builder/yay && \
cd /home/builder/yay && \
makepkg -si --noconfirm
# Use yay to install claude-code (or claude-code-stable)
RUN yay -S --noconfirm claude-code
# Optional: switch back to root for cleanup
USER root
RUN rm -rf /home/builder/yay && \
pacman -Scc --noconfirm
# Default working user/environment
USER builder
WORKDIR /home/builder
+1 -1
View File
@@ -20,7 +20,7 @@ services:
# volumes:
# - ..:/app
# environment:
# - MUMBLE_WEB2_GUI_CONFIG_URL=https://localhost:64444/config
# - MUMBLE_WEB2_PROXY_OVERRIDES_URL=https://localhost:64444/overrides
# stdin_open: true
# tty: true
# command: >
+3 -7
View File
@@ -68,6 +68,7 @@ etcetera = { version = "0.10.0", optional = true }
# Base Dependencies
# ================
dioxus = { version = "0.7.2" }
dioxus-native = { git = "https://github.com/DioxusLabs/blitz", rev = "e64a3d8", features = ["prelude"], optional = true }
once_cell = "1.19.0"
asynchronous-codec = { workspace = true }
futures = "^0.3.30"
@@ -106,7 +107,7 @@ crossbeam = "0.8.4"
# ====================
# rfd only supports windows, macos, linux, and wasm32. No support for Android or iOS
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos", target_arch = "wasm32"))'.dependencies]
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.16.0", default-features = false, optional = true }
rfd = { git = "https://github.com/PolyMeilex/rfd.git", version = "^0.17.0", default-features = false, optional = true }
# Android dependencies for requesting permissions
[target.'cfg(target_os = "android")'.dependencies]
@@ -114,12 +115,6 @@ android-permissions = "0.1.2"
jni = "0.21.1"
ndk-context = "0.1.1"
[patch.crates-io]
tract-hir = "=0.12.4"
tract-core = "=0.12.4"
tract-onnx = "=0.12.4"
tract-pulse = "=0.12.4"
[features]
web = [
"dioxus/web",
@@ -155,3 +150,4 @@ mobile = [
"cpal",
"dasp_ring_buffer",
]
blitz = ["dioxus-native"]
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M18 15.75q0 2.6-1.825 4.425T11.75 22t-4.425-1.825T5.5 15.75V6.5q0-1.875 1.313-3.187T10 2t3.188 1.313T14.5 6.5v8.75q0 1.15-.8 1.95t-1.95.8t-1.95-.8t-.8-1.95V6h2v9.25q0 .325.213.538t.537.212t.538-.213t.212-.537V6.5q-.025-1.05-.737-1.775T10 4t-1.775.725T7.5 6.5v9.25q-.025 1.775 1.225 3.013T11.75 20q1.75 0 2.975-1.237T16 15.75V6h2z"/></svg>

After

Width:  |  Height:  |  Size: 453 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M11 21V7h2v14zm-4-3v-8h2v8zm8 0v-8h2v8zM3 15v-2h2v2zm16 0v-2h2v2zM2 10V8h1.175q1.05 0 1.963-.525T6.6 6.05q.85-1.425 2.288-2.238T12 3t3.113.813T17.4 6.05q.55.9 1.463 1.425T20.825 8H22v2h-1.15q-1.575 0-2.963-.775T15.7 7.1q-.575-.975-1.562-1.537T12 5q-1.125 0-2.113.563T8.326 7.1q-.8 1.35-2.187 2.125T3.175 10z"/></svg>

After

Width:  |  Height:  |  Size: 431 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288M11 13h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>

After

Width:  |  Height:  |  Size: 392 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M7 18V6h2v12zm4 4V2h2v20zm-8-8v-4h2v4zm12 4V6h2v12zm4-4v-4h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 187 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M9.875 13.125Q9 12.25 9 11V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 1.25-.875 2.125T12 14t-2.125-.875M11 21v-3.075q-2.6-.35-4.3-2.325T5 11h2q0 2.075 1.463 3.538T12 16t3.538-1.463T17 11h2q0 2.625-1.7 4.6T13 17.925V21z"/></svg>

After

Width:  |  Height:  |  Size: 342 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M17.75 14.95L16.3 13.5q.35-.575.525-1.2T17 11h2q0 1.1-.325 2.088t-.925 1.862m-2.95-3L9 6.15V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 .275-.062.5t-.138.45M11 21v-3.1q-2.6-.35-4.3-2.312T5 11h2q0 2.075 1.463 3.538T12 16q.85 0 1.613-.262T15 15l1.425 1.425q-.725.575-1.588.963T13 17.9V21zm8.8 1.6L1.4 4.2l1.4-1.4l18.4 18.4z"/></svg>

After

Width:  |  Height:  |  Size: 444 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 21v-3.075l5.525-5.5q.225-.225.5-.325t.55-.1q.3 0 .575.113t.5.337l.925.925q.2.225.313.5t.112.55t-.1.563t-.325.512l-5.5 5.5zM4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13q.925 0 1.825.113t1.8.362L12 17.1V20zm16.575-4.6l.925-.975l-.925-.925l-.95.95zm-11.4-4.575Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175"/></svg>

After

Width:  |  Height:  |  Size: 480 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M3 20v-6l8-2l-8-2V4l19 8z"/></svg>

After

Width:  |  Height:  |  Size: 149 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11zm6 0V4h3v16z"/></svg>

After

Width:  |  Height:  |  Size: 161 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M5 20v-6h3v6zm6 0V9h3v11z"/></svg>

After

Width:  |  Height:  |  Size: 149 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m17.1 14.275l-1.225-1.225q.55-.65.838-1.425T17 10q0-1-.4-1.9t-1.1-1.6l1.2-1.2q.95.95 1.475 2.15T18.7 10q0 1.2-.425 2.288T17.1 14.275M14.125 11.3L10.7 7.875q.3-.175.625-.275T12 7.5q1.05 0 1.775.725T14.5 10q0 .35-.1.675t-.275.625m5.375 5.35l-1.2-1.2q1-1.125 1.5-2.537T20.3 10q0-1.65-.612-3.187T17.9 4.1l1.2-1.2q1.375 1.45 2.138 3.275T22 10q0 1.85-.638 3.563T19.5 16.65m.275 5.95L13 15.825V21h-2v-7.175L7 9.85V10q0 1 .4 1.9t1.1 1.6l-1.2 1.2q-.95-.95-1.475-2.15T5.3 10q0-.425.05-.825t.175-.825L4.25 7.075q-.275.725-.413 1.45T3.7 10q0 1.65.612 3.188T6.1 15.9l-1.2 1.2q-1.375-1.45-2.137-3.275T2 10q0-1.1.238-2.162t.712-2.063L1.4 4.225L2.8 2.8l18.4 18.4z"/></svg>

After

Width:  |  Height:  |  Size: 771 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m19.8 22.6l-3.025-3.025q-.625.4-1.325.688t-1.45.462v-2.05q.35-.125.688-.25t.637-.3L12 14.8V20l-5-5H3V9h3.2L1.4 4.2l1.4-1.4l18.4 18.4zm-.2-5.8l-1.45-1.45q.425-.775.638-1.625t.212-1.75q0-2.35-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975q0 1.325-.363 2.55T19.6 16.8m-3.35-3.35L14 11.2V7.95q1.175.55 1.838 1.65T16.5 12q0 .375-.062.738t-.188.712M12 9.2L9.4 6.6L12 4z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="M14 20.725v-2.05q2.25-.65 3.625-2.5t1.375-4.2t-1.375-4.2T14 5.275v-2.05q3.1.7 5.05 3.138T21 11.975t-1.95 5.613T14 20.725M3 15V9h4l5-5v16l-5-5zm11 1V7.95q1.175.55 1.838 1.65T16.5 12q0 1.275-.663 2.363T14 16"/></svg>

After

Width:  |  Height:  |  Size: 329 B

+29 -16
View File
@@ -173,6 +173,7 @@ a:visited {
&_box {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
background-color: var(--light-bg-color);
@@ -185,6 +186,12 @@ a:visited {
border-radius: 8px;
.material-symbols-outlined {
width: 35px;
height: 35px;
cursor: pointer;
}
input {
color: white;
background-color: var(--light-bg-color);
@@ -207,10 +214,10 @@ a:visited {
border-radius: 50%;
aspect-ratio: 1 / 1;
flex-shrink: 0;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
display: flex;
align-items: center;
justify-content: center;
padding: clamp(4px, 0.5vw, 8px);
}
.button_row {
@@ -232,9 +239,9 @@ a:visited {
min-width: 0;
flex-shrink: 1;
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
> div {
display: flex;
align-items: center;
}
}
@@ -268,19 +275,16 @@ a:visited {
border: solid rgb(255 255 255 / 0.1) clamp(1px, 0.3vw, 3px);
border-radius: clamp(4px, 0.8vw, 10px);
color: rgb(255 255 255 / 50%);
transition: all 0.5s ease-in-out;
&.is_on {
background-color: oklch(0.5 0.1381 21.71 / 20.12%);
color: oklch(0.53 0.1505 21.71 / 89.38%);
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
vertical-align: middle;
}
display: flex;
align-items: center;
justify-content: center;
}
.server {
@@ -344,7 +348,10 @@ a:visited {
.connection_status {
.material-symbols-outlined {
font-size: var(--control-icon-size);
width: 24px;
width: var(--control-icon-size);
height: 24px;
height: var(--control-icon-size);
}
.status_text {
font-size: var(--control-text-size);
@@ -356,7 +363,10 @@ a:visited {
.user_edit_button {
.material-symbols-outlined {
font-size: var(--user-icon-size);
width: 36px;
width: var(--user-icon-size);
height: 36px;
height: var(--user-icon-size);
}
}
@@ -371,7 +381,10 @@ a:visited {
.toggle_button {
.material-symbols-outlined {
font-size: var(--toggle-icon-size);
width: 28px;
width: var(--toggle-icon-size);
height: 28px;
height: var(--toggle-icon-size);
}
}
+99 -62
View File
@@ -1,12 +1,72 @@
#![allow(non_snake_case)]
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::*;
use mime_guess::Mime;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use ordermap::OrderSet;
use std::collections::{HashMap, HashSet};
use crate::imp::{Platform, PlatformInterface as _};
use crate::imp::{ConfigSystem, ConfigSystemInterface as _, Platform, PlatformInterface as _};
// Material Symbols icon component.
// On blitz builds, renders <img> with a data URI SVG containing the explicit fill color,
// since web fonts aren't available and <img> can't inherit CSS color.
// On non-blitz builds, renders the icon font span as usual.
fn icon_svg_path(name: &str) -> &'static str {
// Paths from Google Material Symbols Outlined, weight 700, FILL 1, 24px.
// Coordinate space: viewBox="0 -960 960 960"
match name {
"attach_file" => "M772-320q0 117-87 195.5T479-46q-119 0-205-78.5T188-320v-392q0-86 63.5-144T403-914q88 0 150.5 58T616-712v371q0 55-40.5 92.5T479-211q-56 0-95.5-37.5T344-341v-370h116v370q0 7 5.5 11t13.5 4q8 0 14.5-3.5T500-341v-370q0-38-29-63t-68-25q-39 0-69 24.5T304-712v392q0 69 52.5 114T480-161q71 0 123.5-45T656-320v-429h116v429Z",
"cadence" => "M417-86v-555h125v555H417ZM252-210v-308h125v308H252Zm330 0v-308h125v308H582ZM86-301v-126h126v126H86Zm661 0v-126h126v126H747ZM46-542v-125h69q37.98 0 70.99-18.5T239-737q37.96-64.01 102.17-100.5 64.2-36.5 139.02-36.5 74.81 0 138.87 36.5Q683.12-801.01 721-737q19.75 32.31 52.52 51.15Q806.29-667 844-667h70v125h-69q-72 0-133-34.5T614-672q-20.82-35.75-56.59-56.38Q521.65-749 480-749q-42 0-77.63 20.62Q366.75-707.75 346-672q-37 61-98 95.5T115-542H46Z",
"error" => "M479.77-246Q509-246 529-265.77q20-19.77 20-49t-19.77-49.73q-19.77-20.5-49-20.5T431-364.5q-20 20.5-20 49.73 0 29.23 19.77 49t49 19.77ZM417-438h126v-263H417v263Zm63 392q-91 0-169.99-34.08-78.98-34.09-137.41-92.52-58.43-58.43-92.52-137.41Q46-389 46-480q0-91 34.08-169.99 34.09-78.98 92.52-137.41 58.43-58.43 137.41-92.52Q389-914 480-914q91 0 169.99 34.08 78.98 34.09 137.41 92.52 58.43 58.43 92.52 137.41Q914-571 914-480q0 91-34.08 169.99-34.09 78.98-92.52 137.41-58.43 58.43-137.41 92.52Q571-46 480-46Z",
"graphic_eq" => "M255-215v-530h111v530H255ZM425-46v-868h110v868H425ZM86-385v-190h111v190H86Zm507 170v-530h111v530H593Zm170-170v-190h111v190H763Z",
"mic" => "M479.88-354Q414-354 368-400.08 322-446.17 322-512v-233q0-65.83 46.12-111.92 46.12-46.08 112-46.08T592-856.92q46 46.09 46 111.92v233q0 65.83-46.12 111.92-46.12 46.08-112 46.08ZM425-59v-127q-121-16-199-109.12T148-512h111q0 92 64.7 156.5T480.2-291q91.8 0 156.3-64.64Q701-420.29 701-512h111q0 124-78 217T535-186v127H425Z",
"mic_off" => "m772-347-82-82q9-18 13-37.5t4-45.5h110q0 44-11 87t-34 78ZM639-480 336-783v-11q13-44 54-76.5t95-32.5q66 0 112.5 46T644-745v233q0 9-1.5 18t-3.5 14ZM430-59v-127q-121-16-199-109t-78-217h111q0 92 64.5 156.5T485-291q43 0 81.5-15.5T634-349l80 80q-35 33-79 54.5T540-186v127H430Zm357-5L51-800l66-66 736 736-66 66Z",
"person_edit" => "M554-86v-151l227-226q12-12.18 26.67-17.59Q822.33-486 837-486q16 0 30.55 6T894-462l37 37q10.82 12 16.91 26.67Q954-383.67 954-369q0 16-5.5 30.5T931-312L705-86H554Zm-428-23v-148q0-43.3 22.7-79.6 22.69-36.3 60.3-55.4 65-32 132.96-48.5Q409.92-457 480-457q42 0 81.33 4.97Q600.67-447.05 640-436L474-270v161H126Zm721-231 27-29-37-37-28 28 38 38ZM480-497q-81 0-137.5-56.5T286-691q0-81 56.5-137T480-884q81 0 137.5 56T674-691q0 81-56.5 137.5T480-497Z",
"send" => "M89-128v-244l366-108L89-588v-244l831 352L89-128Z",
"signal_cellular_alt" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Zm246 0v-708h166v708H668Z",
"signal_cellular_alt_2_bar" => "M176-126v-208h166v208H176Zm246 0v-408h166v408H422Z",
"signal_disconnected" => "m703-385-66-66q17-24 26-52t9-57q0-38-15-73t-42-62l65-65q40 40 62.5 91.5T765-560q0 48-16.5 92.5T703-385ZM588-500 420-668q14-8 29-11.5t31-3.5q51 0 87 36t36 87q0 16-3.5 31T588-500Zm225 224-66-66q38-46 58.5-102T826-560q0-69-26.5-132.5T724-804l65-66q62 62 95.5 142T918-560q0 78-27 151t-78 133Zm16 263L543-299v202H417v-328L288-554v2q2 36 16.5 68.5T345-425l-65 65q-41-40-63-91.5T195-560q0-20 2.5-38.5T206-636l-48-48q-11 30-17.5 61t-6.5 63q0 69 26.5 132T236-316l-66 66q-60-63-94-142.5T42-560q0-51 11-100t34-94l-74-75 67-67L896-80l-67 67Z",
"volume_off" => "M802-24 L679-149q-21 12-44.5 21T585-114v-99l12-4q6-2 12-5L505-328v249L258-326H78v-308h130L22-826l66-66L869-91l-67 67Zm18-253-69-71q17-30 25.5-63.5T785-481q0-93-56-166.5T585-749v-99q130 29 213 131.5T881-481q0 57-16 108t-45 96ZM687-413 585-517v-137q51 24 83.5 70.5T701-480q0 18-3.5 35T687-413ZM505-600 367-743l138-138v281Z",
"volume_up" => "M586-114v-99q89-28 144.5-101.5T786-481q0-93-55.5-166.5T586-749v-99q130 29 212.5 131.5T881-481q0 133-82.5 235.5T586-114ZM79-326v-308h180l247-247v802L259-326H79Zm507 18v-346q51 25 83 71t32 103q0 56-32 102t-83 70Z",
_ => panic!("unknown icon: {name}"),
}
}
fn icon_data_uri(name: &str, color: &str, opacity: f64) -> String {
use base64::Engine;
let path_d = icon_svg_path(name);
let opacity_attr = if opacity < 1.0 {
format!(r#" fill-opacity="{opacity}""#)
} else {
String::new()
};
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="{color}"{opacity_attr} d="{path_d}"/></svg>"#
);
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
format!("data:image/svg+xml;base64,{b64}")
}
#[component]
fn Icon(
name: String,
#[props(default)] style: String,
#[props(default)] color: String,
#[props(default = 1.0)] opacity: f64,
) -> Element {
let fill = if color.is_empty() { "white" } else { &color };
let src = icon_data_uri(&name, fill, opacity);
rsx!(img {
class: "material-symbols-outlined",
style: "{style}",
src: "{src}",
})
}
pub type ChannelId = u32;
pub type UserId = u32;
@@ -23,7 +83,7 @@ pub enum Command {
Connect {
address: String,
username: String,
config: ClientConfig,
config: ProxyOverrides,
},
SendChat {
markdown: String,
@@ -430,17 +490,13 @@ pub fn ChatView() -> Element {
div {
span {
onclick: move |_| pick_and_send_file(&net),
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"attach_file",
Icon { name: "attach_file", color: "#ffffff", opacity: 0.5 }
}
}
div {
span {
onclick: move |_| do_send(),
class: "material-symbols-outlined",
style: "color: rgba(255, 255, 255, 0.5); font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; vertical-align: middle; font-size: 35px; user-select: none;",
"send",
Icon { name: "send", color: "#ffffff", opacity: 0.5 }
}
}
}
@@ -454,7 +510,7 @@ pub fn ChatView() -> Element {
}
#[component]
pub fn ControlView(config: Resource<ClientConfig>) -> Element {
pub fn ControlView(overrides: Resource<ProxyOverrides>) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let status = &STATE.status;
let server = STATE.server.read();
@@ -474,10 +530,10 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
let current_channel_name = server.channels_state.channels[&channel].name.clone();
let proxy_url = config
let proxy_url = overrides
.read_unchecked()
.as_ref()
.and_then(|gui_config| gui_config.proxy_url.clone());
.and_then(|overrides| overrides.proxy_url.clone());
let connecting_color = "yellow";
let connected_color = "oklch(0.55 0.1184 141.35)";
@@ -490,10 +546,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "connection_status",
style: "color: {connecting_color};",
div {
span {
class: "material-symbols-outlined",
"signal_cellular_alt_2_bar"
}
Icon { name: "signal_cellular_alt_2_bar", color: "yellow" }
span {
class: "status_text",
" Connecting"
@@ -506,10 +559,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "connection_status",
div {
style: "color: {connected_color};",
span {
class: "material-symbols-outlined",
"signal_cellular_alt"
}
Icon { name: "signal_cellular_alt", color: "#46823e" }
span {
class: "status_text",
" Connected"
@@ -526,10 +576,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "connection_status",
style: "color: {disconnected_color};",
div {
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
Icon { name: "signal_disconnected", color: "gray" }
span {
class: "status_text",
" Disconnected"
@@ -542,10 +589,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "connection_status",
style: "color: {failed_color};",
div {
span {
class: "material-symbols-outlined",
"error"
}
Icon { name: "error", color: "red" }
span {
class: "status_text",
" Failed"
@@ -567,10 +611,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
button {
class: "toggle_button",
onclick: move |_| net.send(Disconnect),
span {
class: "material-symbols-outlined",
"signal_disconnected"
}
Icon { name: "signal_disconnected", color: "#ffffff", opacity: 0.5 }
}
}
hr { style: "width: 100%;" }
@@ -579,11 +620,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
class: "button_row",
button {
class: "user_edit_button",
span {
class: "material-symbols-outlined",
style: "color: oklch(0.65 0.2245 28.06);",
"person_edit"
}
Icon { name: "person_edit", color: "#fa3f36" }
}
div {
class: "user_info",
@@ -608,8 +645,8 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
net.send(UpdateMicEffects { denoise: new_denoise })
},
match denoise() {
true => rsx!(span { class: "material-symbols-outlined", "cadence"}),
false => rsx!(span { class: "material-symbols-outlined", "graphic_eq"}),
true => rsx!(Icon { name: "cadence", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "graphic_eq", color: "#ffffff", opacity: 0.5 }),
}
}
button {
@@ -622,8 +659,8 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
disabled: mute || suppress,
onclick: move |_| net.send(SetMute { mute: !self_mute }),
match mute || suppress || self_mute {
true => rsx!(span { class: "material-symbols-outlined", "mic_off"}),
false => rsx!(span { class: "material-symbols-outlined", "mic"}),
true => rsx!(Icon { name: "mic_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "mic", color: "#ffffff", opacity: 0.5 }),
}
}
button {
@@ -636,8 +673,8 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
disabled: deaf,
onclick: move |_| net.send(SetDeaf { deaf: !self_deaf }),
match deaf || self_deaf {
true => rsx!(span { class: "material-symbols-outlined", "volume_off"}),
false => rsx!(span { class: "material-symbols-outlined", "volume_up"}),
true => rsx!(Icon { name: "volume_off", color: "#b23f43", opacity: 0.8938 }),
false => rsx!(Icon { name: "volume_up", color: "#ffffff", opacity: 0.5 }),
}
}
}
@@ -645,7 +682,7 @@ pub fn ControlView(config: Resource<ClientConfig>) -> Element {
}
#[component]
pub fn ServerView(config: Resource<ClientConfig>) -> Element {
pub fn ServerView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let server = STATE.server.read();
let Some(&UserState {
@@ -676,14 +713,14 @@ pub fn ServerView(config: Resource<ClientConfig>) -> Element {
}
div {
class: "server_control_box",
ControlView { config }
ControlView { overrides }
}
}
)
}
#[component]
pub fn LoginView(config: Resource<ClientConfig>) -> Element {
pub fn LoginView(overrides: Resource<ProxyOverrides>, user_config: ConfigSystem) -> Element {
let net: Coroutine<Command> = use_coroutine_handle();
let last_status = use_signal(|| None::<color_eyre::Result<ServerStatus>>);
@@ -695,30 +732,29 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
}
});
let mut address_input = use_signal(|| Platform::load_server_url());
let mut address_input = use_signal(|| user_config.config_get::<String>("server_url"));
let address = use_memo(move || {
if let Some(addr) = address_input() {
addr.clone()
} else {
config()
overrides()
.and_then(|c| c.proxy_url.clone())
.unwrap_or_default()
}
});
let previous_username = Platform::load_username();
let previous_username = user_config.config_get::<String>("username");
let mut username = use_signal(|| previous_username.unwrap_or(String::new()));
let do_connect = move |_| {
//let _ = set_default_username(&username.read());
let _ = Platform::set_default_username(&username.read());
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
Platform::set_default_server(&address.read());
let _ = user_config.config_set::<String>("username", &username.read());
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
user_config.config_set::<String>("server_url", &address.read());
}
net.send(Connect {
address: address.read().clone(),
username: username.read().clone(),
config: config.read().clone().unwrap_or_default(),
config: overrides.read().clone().unwrap_or_default(),
})
};
let status = &STATE.status;
@@ -763,7 +799,7 @@ pub fn LoginView(config: Resource<ClientConfig>) -> Element {
None => rsx!(),
}
}
if config.read().as_ref().is_some_and(|cfg| cfg.any_server) {
if overrides.read().as_ref().is_some_and(|cfg| cfg.any_server) {
div {
label {
for: "address-entry",
@@ -859,23 +895,24 @@ pub fn app() -> Element {
static STYLE: Asset = asset!("/assets/main.scss");
use_coroutine(|rx: UnboundedReceiver<Command>| super::network_entrypoint(rx));
let config = use_resource(|| async move {
match Platform::load_config().await {
Ok(config) => config,
Err(_) => ClientConfig::default(),
let overrides = use_resource(|| async move {
match Platform::load_proxy_overrides().await {
Ok(overrides) => overrides,
Err(_) => ProxyOverrides::default(),
}
});
let user_config = ConfigSystem::new().unwrap();
Platform::request_permissions();
rsx!(
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" }
document::Link{ rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" }
document::Link{ rel: "stylesheet", href: STYLE }
match *STATE.status.read() {
Connected => rsx!(ServerView { config }),
_ => rsx!(LoginView { config }),
Connected => rsx!(ServerView { overrides, user_config }),
_ => rsx!(LoginView { overrides, user_config }),
}
)
}
+3
View File
@@ -1,6 +1,9 @@
use crossbeam::atomic::AtomicCell;
use df::tract::{mut_slice_as_arrayviewmut, slice_as_arrayview};
use df::tract::{DfParams, DfTract, RuntimeParams};
#[cfg(feature = "blitz")]
use dioxus_native::prelude::{asset, manganis, Asset};
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::{asset, manganis, Asset};
use dioxus_asset_resolver::read_asset_bytes;
use std::cell::RefCell;
+91
View File
@@ -0,0 +1,91 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
None
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(_username: &str) -> Option<()> {
None
}
fn set_default_server(server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
super::connect::get_status(client).await
}
fn init_logging() {
use tracing::level_filters::LevelFilter;
use tracing_subscriber::filter::EnvFilter;
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::fmt()
.with_target(true)
.with_level(true)
.with_env_filter(env_filter)
.init();
}
fn request_permissions() {
request_recording_permission();
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
}
#[cfg(not(target_os = "android"))]
pub fn request_recording_permission() {}
#[cfg(target_os = "android")]
pub fn request_recording_permission() {
use android_permissions::{PermissionManager, RECORD_AUDIO};
use jni::{objects::JObject, JavaVM};
let ctx = ndk_context::android_context();
let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()).unwrap() };
let activity = unsafe { JObject::from_raw(ctx.context().cast()) };
let manager = PermissionManager::create(vm, activity).unwrap();
if !manager.check(&RECORD_AUDIO).unwrap() {
manager.request(&[&RECORD_AUDIO]).unwrap();
}
}
+4 -4
View File
@@ -8,13 +8,13 @@ use tokio::net::TcpStream;
use tokio_rustls::rustls;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::ClientConfig as RlsClientConfig;
use tokio_rustls::rustls::ClientConfig;
use tokio_rustls::rustls::DigitallySignedStruct;
use tokio_rustls::TlsConnector;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use tracing::{info, instrument};
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
#[derive(Debug)]
struct NoCertificateVerification;
@@ -73,11 +73,11 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
info!("connecting");
let config = RlsClientConfig::builder()
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();
+6 -55
View File
@@ -2,7 +2,7 @@ use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::time::Duration;
@@ -11,48 +11,27 @@ pub struct DesktopPlatform;
impl super::PlatformInterface for DesktopPlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
let config = load_config_map();
config.get("username").cloned()
}
fn load_server_url() -> Option<String> {
let config = load_config_map();
config.get("server").cloned()
}
fn set_default_username(username: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("username".to_string(), username.to_string());
save_config_map(&config).ok()
}
fn set_default_server(server: &str) -> Option<()> {
let mut config = load_config_map();
config.insert("server".to_string(), server.to_string());
save_config_map(&config).ok()
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
super::connect::network_connect(address, username, event_rx, overrides).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
@@ -78,31 +57,3 @@ impl super::PlatformInterface for DesktopPlatform {
// No-op on desktop
}
}
fn get_config_path() -> std::path::PathBuf {
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "com".to_string(),
author: "Ohea Corp".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
strategy.config_dir().join("config.json")
}
fn load_config_map() -> HashMap<String, String> {
let config_path = get_config_path();
match std::fs::read_to_string(&config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, String>) -> color_eyre::Result<()> {
let config_path = get_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, contents)?;
Ok(())
}
+6 -22
View File
@@ -1,8 +1,7 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use std::future::Future;
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::time::Duration;
/// Mobile platform implementation using Tokio, native audio, and Android permissions.
@@ -10,38 +9,23 @@ pub struct MobilePlatform;
impl super::PlatformInterface for MobilePlatform {
type AudioSystem = super::native_audio::NativeAudioSystem;
type ConfigSystem = super::native_config::NativeConfigSystem;
async fn load_config() -> color_eyre::Result<ClientConfig> {
Ok(ClientConfig {
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
Ok(ProxyOverrides {
proxy_url: None,
cert_hash: None,
any_server: true,
})
}
fn load_username() -> Option<String> {
None
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(_username: &str) -> Option<()> {
None
}
fn set_default_server(server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
super::connect::network_connect(address, username, event_rx, gui_config).await
super::connect::network_connect(address, username, event_rx, overrides).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
+39 -20
View File
@@ -7,7 +7,8 @@
use crate::{app::Command, effects::AudioProcessor};
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::collections::HashMap;
use std::future::Future;
use std::time::Duration;
@@ -50,11 +51,24 @@ pub trait AudioPlayerInterface {
fn play_opus(&mut self, payload: &[u8]);
}
pub trait ConfigSystemInterface: Sized {
fn new() -> Result<Self, Error>;
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned;
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize;
}
/// This is the main trait that each platform must implement. It combines all
/// platform-specific functionality into a single interface, providing compile-time
/// verification that all platforms implement the required functionality.
pub trait PlatformInterface {
type AudioSystem: AudioSystemInterface;
type ConfigSystem: ConfigSystemInterface;
/// Initialize logging for the platform.
fn init_logging();
@@ -67,7 +81,7 @@ pub trait PlatformInterface {
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
proxy_overrides: &ProxyOverrides,
) -> impl Future<Output = Result<(), Error>>;
/// Get server status (user count, version, etc.).
@@ -76,19 +90,7 @@ pub trait PlatformInterface {
) -> impl Future<Output = color_eyre::Result<ServerStatus>>;
/// Load the proxy overrides (proxy URL, cert hash, etc.).
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>>;
/// Load saved username.
fn load_username() -> Option<String>;
/// Load saved server URL.
fn load_server_url() -> Option<String>;
/// Save the default username.
fn set_default_username(username: &str) -> Option<()>;
/// Save the default server URL.
fn set_default_server(server: &str) -> Option<()>;
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>>;
/// Async sleep for the given duration.
fn sleep(duration: Duration) -> impl Future<Output = ()>;
@@ -98,15 +100,21 @@ pub trait PlatformInterface {
// Platform Modules
// ============================================================================
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod connect;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_audio;
mod stub;
#[cfg(any(feature = "desktop", feature = "mobile"))]
mod native_config;
#[cfg(feature = "desktop")]
mod desktop;
#[cfg(feature = "mobile")]
mod mobile;
#[cfg(feature = "web")]
mod web;
@@ -133,6 +141,8 @@ pub type Platform = stub::StubPlatform;
pub type AudioSystem = <Platform as PlatformInterface>::AudioSystem;
pub type AudioPlayer = <AudioSystem as AudioSystemInterface>::AudioPlayer;
pub type ConfigSystem = <Platform as PlatformInterface>::ConfigSystem;
// ========================
// Platform Async Runtime
// ========================
@@ -164,3 +174,12 @@ const _: () = {
let _ = assert_platform::<mobile::MobilePlatform>;
let _ = assert_platform::<stub::StubPlatform>;
};
fn global_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+121
View File
@@ -0,0 +1,121 @@
use crate::app::Command;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::ServerStatus;
use std::collections::HashMap;
use std::time::Duration;
use tracing::{error, info, warn};
#[derive(Clone, PartialEq)]
pub struct NativeConfigSystem {
config_path: std::path::PathBuf,
}
impl super::ConfigSystemInterface for NativeConfigSystem {
fn new() -> color_eyre::Result<Self, Error> {
return Ok(NativeConfigSystem {
config_path: get_config_path()?,
});
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
let config = load_config_map(&self.config_path);
let Some(value_untyped) = config.get(key).cloned().or_else(|| config_get_default(key))
else {
return None;
};
match serde_json::from_value::<T>(value_untyped) {
Ok(v) => Some(v),
Err(_) => {
let default_value = config_get_default(key)
.expect("Default value required after config parse failure");
Some(
serde_json::from_value::<T>(default_value)
.expect("Default value could not be parsed"),
)
}
}
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
let mut config = load_config_map(&self.config_path);
let json_value = serde_json::to_value(value).expect("failed to serialize config value");
config.insert(key.to_string(), json_value);
save_config_map(&config).expect("failed to set config")
}
}
#[cfg(any(feature = "desktop"))]
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "xyz".to_string(),
author: "ohea".to_string(),
app_name: "Mumble Web2".to_string(),
})
.expect("failed to choose app strategy");
Ok(strategy.config_dir().join("config.json"))
}
#[cfg(target_os = "android")]
fn get_config_path() -> color_eyre::Result<std::path::PathBuf> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }?;
let mut env = vm.attach_current_thread()?;
let ctx = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
let cache_dir = env
.call_method(ctx, "getFilesDir", "()Ljava/io/File;", &[])?
.l()?;
let cache_dir: jni::objects::JString = env
.call_method(&cache_dir, "toString", "()Ljava/lang/String;", &[])?
.l()?
.try_into()?;
let cache_dir = env.get_string(&cache_dir)?;
let cache_dir = cache_dir.to_str()?;
Ok(std::path::PathBuf::from(cache_dir).join("config.json"))
}
fn load_config_map(config_path: &std::path::PathBuf) -> HashMap<String, serde_json::Value> {
match std::fs::read_to_string(config_path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
}
fn save_config_map(config: &HashMap<String, serde_json::Value>) -> color_eyre::Result<()> {
let config_path = get_config_path().expect("Could not get config file path.");
if let Some(parent) = config_path.parent() {
info!("Creating config directory: {}", parent.display());
std::fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(config)?;
info!("Writing config to {}", config_path.display());
std::fs::write(&config_path, contents)?;
Ok(())
}
fn config_get_default(key: &str) -> Option<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+26 -19
View File
@@ -3,13 +3,14 @@
use crate::effects::AudioProcessor;
use color_eyre::eyre::Error;
use dioxus::hooks::UnboundedReceiver;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use std::future::Future;
pub struct StubPlatform;
impl super::PlatformInterface for StubPlatform {
type AudioSystem = StubAudioSystem;
type ConfigSystem = StubConfigSystem;
fn init_logging() {
panic!("stubbed platform")
@@ -23,7 +24,7 @@ impl super::PlatformInterface for StubPlatform {
_address: String,
_username: String,
_event_rx: &mut UnboundedReceiver<crate::app::Command>,
_gui_config: &ClientConfig,
_overrides: &ProxyOverrides,
) -> impl Future<Output = Result<(), Error>> {
async { panic!("stubbed platform") }
}
@@ -34,26 +35,10 @@ impl super::PlatformInterface for StubPlatform {
async { panic!("stubbed platform") }
}
fn load_config() -> impl Future<Output = color_eyre::Result<ClientConfig>> {
fn load_proxy_overrides() -> impl Future<Output = color_eyre::Result<ProxyOverrides>> {
async { panic!("stubbed platform") }
}
fn load_username() -> Option<String> {
panic!("stubbed platform")
}
fn load_server_url() -> Option<String> {
panic!("stubbed platform")
}
fn set_default_username(_username: &str) -> Option<()> {
panic!("stubbed platform")
}
fn set_default_server(_server: &str) -> Option<()> {
panic!("stubbed platform")
}
fn sleep(_duration: std::time::Duration) -> impl Future<Output = ()> {
async { panic!("stubbed platform") }
}
@@ -92,6 +77,28 @@ impl super::AudioPlayerInterface for StubAudioPlayer {
}
}
pub struct StubConfigSystem;
impl super::ConfigSystemInterface for StubConfigSystem {
fn new() -> Result<Self, Error> {
panic!("stubbed platform")
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
panic!("stubbed platform")
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
panic!("stubbed platform")
}
}
#[allow(unused)]
pub struct SpawnHandle;
+74 -36
View File
@@ -6,8 +6,9 @@ use dioxus::prelude::*;
use gloo_timers::future::TimeoutFuture;
use js_sys::Float32Array;
use mumble_protocol::control::ClientControlCodec;
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use reqwest::Url;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
@@ -62,6 +63,7 @@ pub struct WebPlatform;
impl super::PlatformInterface for WebPlatform {
type AudioSystem = WebAudioSystem;
type ConfigSystem = WebConfigSystem;
fn init_logging() {
// copied from tracing_web example usage
@@ -89,53 +91,28 @@ impl super::PlatformInterface for WebPlatform {
// No-op on web
}
async fn load_config() -> color_eyre::Result<ClientConfig> {
let config_url = match option_env!("MUMBLE_WEB2_GUI_CONFIG_URL") {
async fn load_proxy_overrides() -> color_eyre::Result<ProxyOverrides> {
let overrides = match option_env!("MUMBLE_WEB2_PROXY_OVERRIDES_URL") {
Some(url) => Url::parse(url)?,
None => absolute_url("config")?,
None => absolute_url("overrides")?,
};
info!("loading config from {}", config_url);
info!("loading config from {}", overrides);
let config = reqwest::get(config_url)
let config = reqwest::get(overrides)
.await?
.json::<ClientConfig>()
.json::<ProxyOverrides>()
.await?;
Ok(config)
}
fn load_username() -> Option<String> {
web_sys::window()
.unwrap()
.local_storage()
.ok()??
.get_item("username")
.ok()?
}
fn load_server_url() -> Option<String> {
None
}
fn set_default_username(username: &str) -> Option<()> {
web_sys::window()?
.local_storage()
.ok()??
.set_item("username", username)
.ok()
}
fn set_default_server(_server: &str) -> Option<()> {
None
}
async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
network_connect(address, username, event_rx, gui_config).await
network_connect(address, username, event_rx, overrides).await
}
async fn get_status(client: &reqwest::Client) -> color_eyre::Result<ServerStatus> {
@@ -456,7 +433,7 @@ pub async fn network_connect(
address: String,
username: String,
event_rx: &mut UnboundedReceiver<Command>,
gui_config: &ClientConfig,
overrides: &ProxyOverrides,
) -> Result<(), Error> {
info!("connecting");
@@ -469,7 +446,7 @@ pub async fn network_connect(
)
.ey()?;
if let Some(server_hash) = &gui_config.cert_hash {
if let Some(server_hash) = &overrides.cert_hash {
let hash = web_sys::js_sys::Uint8Array::from(server_hash.as_slice());
web_sys::js_sys::Reflect::set(&object, &"value".into(), &hash).ey()?;
}
@@ -523,3 +500,64 @@ pub fn absolute_url(path: &str) -> Result<Url, Error> {
let location = window.location();
Ok(Url::parse(&location.href().ey()?)?.join(path)?)
}
#[derive(Clone, PartialEq)]
pub struct WebConfigSystem {}
impl super::ConfigSystemInterface for WebConfigSystem {
fn new() -> Result<Self, Error> {
return Ok(WebConfigSystem {});
}
fn config_get<T>(&self, key: &str) -> Option<T>
where
T: serde::de::DeserializeOwned,
{
// Get Storage
let storage = web_sys::window()?.local_storage().ok()??;
// Try localStorage first
if let Ok(Some(raw)) = storage.get_item(key) {
if let Ok(parsed) = serde_json::from_str::<T>(&raw) {
return Some(parsed);
}
}
// Fallback to default if deserialization fails or key missing
let default_value = config_get_default(key)?;
serde_json::from_value::<T>(default_value).ok()
}
fn config_set<T>(&self, key: &str, value: &T)
where
T: serde::Serialize,
{
let storage = window()
.and_then(|w| w.local_storage().ok().flatten())
.expect("localStorage not available");
let json_value =
serde_json::to_string(value).expect("failed to serialize config value to JSON string");
storage
.set_item(key, &json_value)
.expect("failed to write to localStorage");
}
}
fn config_get_default(key: &str) -> Option<serde_json::Value> {
let default_config = platform_default_config();
default_config
.get(key)
.cloned()
.or(super::global_default_config().get(key).cloned())
}
fn platform_default_config() -> HashMap<String, serde_json::Value> {
serde_json::json!({})
.as_object()
.unwrap()
.clone()
.into_iter()
.collect()
}
+3
View File
@@ -5,6 +5,9 @@ use app::STATE;
use asynchronous_codec::FramedRead;
use asynchronous_codec::FramedWrite;
use color_eyre::eyre::{bail, Error};
#[cfg(feature = "blitz")]
use dioxus_native::prelude::*;
#[cfg(not(feature = "blitz"))]
use dioxus::prelude::*;
use futures::select;
use futures::AsyncRead;
+3
View File
@@ -2,5 +2,8 @@ use mumble_web2_gui::{app, imp::Platform, imp::PlatformInterface as _};
pub fn main() {
Platform::init_logging();
#[cfg(feature = "blitz")]
dioxus_native::launch(app::app);
#[cfg(not(feature = "blitz"))]
dioxus::launch(app::app);
}
+11 -14
View File
@@ -1,5 +1,5 @@
use color_eyre::eyre::{anyhow, bail, Context, Result};
use mumble_web2_common::{ClientConfig, ServerStatus};
use mumble_web2_common::{ProxyOverrides, ServerStatus};
use rand::Rng;
use salvo::conn::rustls::{Keycert, RustlsConfig};
use salvo::cors::{AllowOrigin, Cors};
@@ -16,7 +16,7 @@ use tokio::net::TcpStream;
use tokio::pin;
use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerifier};
use tokio_rustls::rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use tokio_rustls::rustls::{ClientConfig as RlsClientConfig, DigitallySignedStruct};
use tokio_rustls::rustls::{ClientConfig, DigitallySignedStruct};
use tokio_rustls::{rustls, TlsConnector};
use tracing::info;
use tracing::info_span;
@@ -77,7 +77,7 @@ async fn main() -> Result<()> {
.install_default()
.map_err(|e| anyhow!("could not install crypto provider {e:?}"))?;
let mut client_config = ClientConfig {
let mut overrides = ProxyOverrides {
proxy_url: match &server_config.proxy_url {
Some(url) => Some(url.to_string()),
None => None,
@@ -102,7 +102,7 @@ async fn main() -> Result<()> {
let cert = cert_params.self_signed(&key_pair)?;
let hash = hmac_sha256::Hash::hash(cert.der().as_ref());
client_config.cert_hash = Some(hash.into());
overrides.cert_hash = Some(hash.into());
(cert.pem().into(), key_pair.serialize_pem().into())
}
@@ -122,14 +122,11 @@ async fn main() -> Result<()> {
};
let rustls_config = RustlsConfig::new(Keycert::new().cert(cert.as_slice()).key(key.as_slice()));
info!(
"client config:\n{}",
toml::to_string_pretty(&client_config)?
);
info!("proxy overrides:\n{}", toml::to_string_pretty(&overrides)?);
let config_craft = ConfigCraft {
server_config: server_config.clone(),
client_config,
overrides,
};
let status_craft = StatusCraft {
@@ -139,7 +136,7 @@ async fn main() -> Result<()> {
// Server routing
let mut router = Router::new()
.push(Router::with_path("/proxy").goal(config_craft.connect_proxy()))
.push(Router::with_path("/config").get(config_craft.get_config()))
.push(Router::with_path("/overrides").get(config_craft.get_overrides()))
.push(Router::with_path("/status").get(status_craft.get_status()))
.hoop(Logger::new());
if let Some(gui_path) = server_config.gui_path.clone() {
@@ -252,14 +249,14 @@ impl StatusCraft {
#[derive(Clone)]
pub struct ConfigCraft {
server_config: Arc<Config>,
client_config: ClientConfig,
overrides: ProxyOverrides,
}
#[craft]
impl ConfigCraft {
#[craft(handler)]
async fn get_config(&self) -> Json<ClientConfig> {
Json(self.client_config.clone())
async fn get_overrides(&self) -> Json<ProxyOverrides> {
Json(self.overrides.clone())
}
#[craft(handler)]
@@ -320,7 +317,7 @@ async fn connect_proxy_impl(
) -> Result<()> {
info!("connecting to Mumble server...");
let config = RlsClientConfig::builder()
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertificateVerification))
.with_no_client_auth();