diff --git a/apps/pwa/src/vite.ts b/apps/pwa/src/vite.ts index 962dce1..107e08f 100644 --- a/apps/pwa/src/vite.ts +++ b/apps/pwa/src/vite.ts @@ -92,6 +92,11 @@ function emitIcons(): Plugin { attrs: { name: "apple-mobile-web-app-capable", content: "yes" }, injectTo: "head", }, + { + tag: "meta", + attrs: { name: "mobile-web-app-capable", content: "yes" }, + injectTo: "head", + }, { tag: "meta", attrs: { diff --git a/apps/tauri/src-tauri/Cargo.toml b/apps/tauri/src-tauri/Cargo.toml index 2a68e95..d38f304 100644 --- a/apps/tauri/src-tauri/Cargo.toml +++ b/apps/tauri/src-tauri/Cargo.toml @@ -15,7 +15,7 @@ name = "mobile_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] } +tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "b6660a041db44729893dae7991a9be61cf8c2ed5", features = [] } [dependencies] tauri-plugin-opener = "2" diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 6636682..859b653 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ pub fn run() { accessibility_backend::accessibility_get_initial_scale, accessibility_backend::accessibility_set_initial_scale, mtp_backend::mtp_request, + mtp_backend::mtp_send_sealed_relay, mtp_backend::mtp_status, mtp_backend::mtp_store_credentials, mtp_backend::mtp_has_credentials, diff --git a/apps/tauri/src-tauri/src/mtp_backend.rs b/apps/tauri/src-tauri/src/mtp_backend.rs index fa28597..97fb983 100644 --- a/apps/tauri/src-tauri/src/mtp_backend.rs +++ b/apps/tauri/src-tauri/src/mtp_backend.rs @@ -9,9 +9,12 @@ use base64::{ Engine as _, }; use mtp::client::{ClientConfig, MTPClient, MTPConnection, Policy, SendMode}; -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; +use mtp::codec::{ + CommunicationType, CommunicationValue, DataType, DataValue, SealedRelayBuilder, TypeMap, +}; use mtp::crypto::{ - derive_encryption_key, AeadDecrypt, ChaCha20Poly1305, HybridKem, Keyring, PublicKeyBundle, + derive_encryption_key, AeadDecrypt, ChaCha20Poly1305, DualSigner, HybridKem, Keyring, + PublicKeyBundle, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -28,6 +31,9 @@ const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha2 const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30); const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000; const NOTIFICATION_QUEUE_CAPACITY: usize = 32; +const ROUTE_TARGET_ID_MASK: u64 = (1_u64 << 48) - 1; +const USER_ROUTE_TARGET_KIND: u64 = 0x4000_0000_0000_0000; +const IOTA_ROUTE_TARGET_KIND: u64 = 0x8000_0000_0000_0000; #[cfg(target_os = "android")] const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\ @@ -79,6 +85,44 @@ pub struct MtpSnapshot { pub error: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EncodedKeyMaterial { + value: String, + encoding: String, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum RelayTargetDto { + User { id: u64 }, + Iota { id: u64 }, +} + +impl RelayTargetDto { + fn wire_id(self) -> Result { + let (kind, id) = match self { + Self::User { id } => (USER_ROUTE_TARGET_KIND, id), + Self::Iota { id } => (IOTA_ROUTE_TARGET_KIND, id), + }; + if id == 0 || id > ROUTE_TARGET_ID_MASK { + return Err("relay target ID must be a non-zero 48-bit integer".into()); + } + Ok(kind | id) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealedRelayRequest { + type_name: String, + data: Value, + next_hop: RelayTargetDto, + final_recipient_id: u64, + metadata_recipients: Vec, + content_recipients: Vec, +} + #[derive(Clone, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] enum MtpEvent { @@ -695,6 +739,88 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String )) } +fn decode_key_material(value: EncodedKeyMaterial) -> Result { + let bytes = match value.encoding.as_str() { + "base64" => decode_browser_base64(&value.value)?, + "hex" => decode_sdk_bytes(&value.value)?, + encoding => return Err(format!("unsupported key material encoding: {encoding}")), + }; + let key = PublicKeyBundle::from_bytes(&bytes) + .map_err(|error| format!("invalid relay recipient public key: {error}"))?; + key.validate() + .map_err(|error| format!("invalid relay recipient public key: {error}"))?; + Ok(key) +} + +async fn send_sealed_relay(request: SealedRelayRequest) -> Result { + if request.metadata_recipients.is_empty() || request.content_recipients.is_empty() { + return Err("sealed relay requires metadata and content recipients".into()); + } + if request.final_recipient_id == 0 { + return Err("sealed relay final recipient ID must be non-zero".into()); + } + let manager = manager(); + let connection = manager + .connection + .read() + .map_err(|_| "MTP connection lock is unavailable")? + .clone() + .ok_or_else(|| "MTP is not connected".to_string())?; + let config = manager + .config + .read() + .map_err(|_| "MTP configuration lock is unavailable")? + .clone() + .ok_or_else(|| "MTP credentials are unavailable".to_string())?; + let keyring_bytes = decode_browser_base64(&config.keyring)?; + let keyring = Keyring::from_bytes(&keyring_bytes) + .map_err(|error| format!("invalid MTP keyring: {error}"))?; + let signer = DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .map_err(|error| format!("invalid MTP signing key: {error}"))?; + let next_hop_id = request.next_hop.wire_id()?; + let type_map = TypeMap::latest(); + let content = json_to_frame(&request.type_name, request.data, 1)?.into_payload(); + let metadata_recipients = request + .metadata_recipients + .into_iter() + .map(decode_key_material) + .collect::, _>>()?; + let content_recipients = request + .content_recipients + .into_iter() + .map(decode_key_material) + .collect::, _>>()?; + let created_at = mtp::common::unix_time_millis() + .map_err(|error| format!("failed to get relay timestamp: {error}"))?; + let request_id = connection.next_request_id().await?; + let message_id = format!("tensamin-relay-{created_at}-{request_id}"); + let frame = SealedRelayBuilder::new( + request.type_name, + content, + config.user_id, + request.final_recipient_id, + next_hop_id, + &signer, + ) + .message_id(message_id) + .created_at(created_at) + .metadata_recipients(metadata_recipients) + .content_recipients(content_recipients) + .type_map(&type_map) + .build() + .map_err(|error| format!("failed to build sealed relay: {error}"))?; + let response = connection + .mtp + .request(&frame, None) + .await + .map_err(|error| format!("sealed relay request failed: {error}"))?; + frame_to_json(&response) +} + async fn handle_push( generation: u64, notification_tx: &mpsc::Sender, @@ -1047,7 +1173,8 @@ mod tests { use super::{ container_value_by_name, decode_browser_base64, decode_sdk_bytes, frame_to_json, - jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator, + jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RelayTargetDto, + RequestIdAllocator, }; #[test] @@ -1070,6 +1197,20 @@ mod tests { assert_eq!(ids.next().unwrap(), 2); } + #[test] + fn iota_relay_target_encodes_its_namespace() { + assert_eq!( + RelayTargetDto::Iota { id: 42 }.wire_id().unwrap(), + 0x8000_0000_0000_002a + ); + } + + #[test] + fn relay_target_rejects_raw_or_out_of_range_ids() { + assert!(RelayTargetDto::Iota { id: 0 }.wire_id().is_err()); + assert!(RelayTargetDto::User { id: 1_u64 << 48 }.wire_id().is_err()); + } + #[test] fn retry_jitter_stays_within_policy_bounds() { let delay = jittered_retry_delay(std::time::Duration::from_secs(10)); @@ -1194,6 +1335,11 @@ pub async fn mtp_request(type_name: String, data: Value) -> Result Result { + send_sealed_relay(request).await +} + #[tauri::command] pub fn mtp_status() -> MtpSnapshot { manager().snapshot() diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 8952e07..264fe41 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.mtp dist dist-ssr *.local diff --git a/apps/web/package.json b/apps/web/package.json index a6b3951..e6b015e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@tensamin/chat": "workspace:*", "@tensamin/crypto": "workspace:*", "@tensamin/hotkeys": "workspace:*", + "@tensamin/markdown": "workspace:*", "@tensamin/mtp": "workspace:*", "@tensamin/notifications": "workspace:*", "@tensamin/onboarding": "workspace:*", @@ -32,7 +33,7 @@ "@tensamin/storage": "workspace:*", "@tensamin/tauri": "workspace:*", "@tensamin/tauth": "workspace:*", - "@tensamin/user": "workspace:*", + "@tensamin/identity": "workspace:*", "decimal.js-light": "^2.5.1", "eventemitter3": "^5.0.4", "lucide-react": "^1.29.0", diff --git a/apps/web/src/components/callPopout.tsx b/apps/web/src/components/callPopout.tsx new file mode 100644 index 0000000..bbe0f91 --- /dev/null +++ b/apps/web/src/components/callPopout.tsx @@ -0,0 +1,8 @@ +import { useCall } from "@tensamin/call/state"; +import Popout from "@tensamin/call/popout"; + +export default function CallPopout() { + const active = useCall((state) => state.state !== "closed"); + + return active ? : null; +} diff --git a/apps/web/src/components/callRuntimeInit.tsx b/apps/web/src/components/callRuntimeInit.tsx new file mode 100644 index 0000000..296057d --- /dev/null +++ b/apps/web/src/components/callRuntimeInit.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from "react"; + +import { useTheme } from "@methanium/ui"; +import { useIsSpeaking } from "@tensamin/call/speakingState"; +import { useCall, useInitializeCall } from "@tensamin/call/store"; +import { useStorage } from "@tensamin/storage/context"; + +function createCallTrayIcon(color: string, speaking: boolean) { + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + + const context = canvas.getContext("2d"); + if (!context) return undefined; + + context.globalAlpha = speaking ? 1 : 0.55; + context.fillStyle = color; + context.beginPath(); + context.arc(16, 16, 13, 0, Math.PI * 2); + context.fill(); + + if (speaking) { + context.globalAlpha = 0.3; + context.fillStyle = "#ffffff"; + context.fill(); + } + + return canvas.toDataURL("image/png"); +} + +export default function CallRuntimeInit() { + const callInvitePopup = useInitializeCall(); + const { load } = useStorage(); + const { + themeColor, + themePalette, + themePrimaryColor, + themePolarity, + themeTint, + themeCustomCss, + } = useTheme(); + const [localUserId, setLocalUserId] = useState(-1); + const [primaryColor, setPrimaryColor] = useState(""); + const inCall = useCall((state) => state.state === "open"); + const speaking = useIsSpeaking(localUserId); + + useEffect(() => { + let active = true; + + load("user_id").then((userId) => { + if (active) setLocalUserId(userId); + }); + + return () => { + active = false; + }; + }, [load]); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + setPrimaryColor( + getComputedStyle(document.documentElement) + .getPropertyValue("--primary") + .trim(), + ); + }); + + return () => cancelAnimationFrame(frame); + }, [ + themeColor, + themeCustomCss, + themePalette, + themePolarity, + themePrimaryColor, + themeTint, + ]); + + useEffect(() => { + const iconDataUrl = primaryColor + ? createCallTrayIcon(primaryColor, speaking) + : undefined; + + void window.tensaminDesktop?.call + ?.setStatus?.({ + inCall, + speaking: inCall && speaking, + iconDataUrl: inCall ? iconDataUrl : undefined, + }) + .catch((error: unknown) => { + console.error("Failed to update desktop call status", error); + }); + }, [inCall, primaryColor, speaking]); + + return callInvitePopup; +} diff --git a/apps/web/src/components/callSidebarBox.tsx b/apps/web/src/components/callSidebarBox.tsx new file mode 100644 index 0000000..89d7905 --- /dev/null +++ b/apps/web/src/components/callSidebarBox.tsx @@ -0,0 +1,8 @@ +import { useCall } from "@tensamin/call/state"; +import SidebarBox from "@tensamin/call/sidebarBox"; + +export default function CallSidebarBox() { + const active = useCall((state) => state.state !== "closed"); + + return active ? : null; +} diff --git a/apps/web/src/components/modals/basic.tsx b/apps/web/src/components/modals/basic.tsx index dc4e48a..9608f66 100644 --- a/apps/web/src/components/modals/basic.tsx +++ b/apps/web/src/components/modals/basic.tsx @@ -1,4 +1,4 @@ -import type { User } from "@tensamin/user/context"; +import type { User } from "@tensamin/identity/context"; import { Avatar, AvatarImage, diff --git a/apps/web/src/components/modals/profile.tsx b/apps/web/src/components/modals/profile.tsx index a4bddb2..f077783 100644 --- a/apps/web/src/components/modals/profile.tsx +++ b/apps/web/src/components/modals/profile.tsx @@ -1,6 +1,6 @@ -import type { User } from "@tensamin/user/context"; +import type { User } from "@tensamin/identity/context"; import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui"; -import { Text } from "@methanium/ui/markdown"; +import Text from "@tensamin/markdown/text"; import { ChevronDown, ChevronUp } from "lucide-react"; import { useState } from "react"; diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 122f188..e980a0d 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -14,8 +14,8 @@ import { User, } from "lucide-react"; import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; -import { joinCall, useCall } from "@tensamin/call/store"; -import Wrapper from "@tensamin/user/wrapper"; +import { useCall } from "@tensamin/call/state"; +import Wrapper from "@tensamin/identity/wrapper"; import { Skeleton } from "@methanium/ui"; import { Select, @@ -27,7 +27,8 @@ import { displayCallId } from "@tensamin/call/utils"; import { useState } from "react"; import { SidebarTrigger, useSidebar } from "@methanium/ui"; import { WindowControls as Controls } from "@methanium/ui"; -import { useSession } from "@tensamin/storage/session"; +import { useSession } from "@tensamin/identity/session"; +import { joinCall } from "@tensamin/call/store"; import Profile from "./modals/profile"; export default function Navbar({ forMobile }: { forMobile: boolean }) { @@ -139,9 +140,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) { {currentCalls.length === 0 ? (