Compare commits

..
Author SHA1 Message Date
ab2ac4813a Merge pull request 'feat(pwa): remove install prompt' (#76) from dev into main
All checks were successful
/ build-web (push) Successful in 5m0s
/ build-desktop (linux) (push) Successful in 11m28s
/ build-mobile (push) Successful in 29m19s
/ release (push) Successful in 2m5s
Reviewed-on: #76
2026-08-30 16:59:43 +03:00
0c0a341f1e
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 5m22s
/ build-desktop (linux) (push) Successful in 10m44s
/ build-mobile (push) Successful in 28m17s
/ release (push) Successful in 2m4s
2026-08-30 15:58:40 +02:00
f74ca52f4f
feat(nix-flake): rework nix package 2026-08-30 15:58:34 +02:00
Alex Emmet
94f11f60f6
Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m37s
/ build-desktop (linux) (push) Successful in 12m5s
/ build-mobile (push) Successful in 25m44s
/ release (push) Successful in 1m58s
2026-08-29 16:11:40 +02:00
Alex Emmet
f5461bbd42
chore(mtp): update type-maps 2026-08-29 16:11:24 +02:00
7c9d9529b4
feat(workflows): allow manual runs for the dev deploy
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m13s
/ build-desktop (linux) (push) Successful in 14m39s
/ build-mobile (push) Successful in 38m15s
/ release (push) Successful in 3m8s
2026-08-29 12:57:44 +02:00
8a04b327fc
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-mobile (push) Failing after 1m44s
/ build-desktop (linux) (push) Failing after 10m56s
/ build-web (push) Failing after 10m56s
/ release (push) Has been cancelled
2026-08-28 20:02:35 +02:00
0a304e44f2
feat(mtp): move useful stuff over to mtp directly
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m14s
/ build-desktop (linux) (push) Successful in 11m36s
/ build-mobile (push) Successful in 24m26s
/ release (push) Successful in 1m39s
2026-08-27 23:30:33 +02:00
094cb910aa
Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m14s
/ build-mobile (push) Failing after 12m6s
/ build-desktop (linux) (push) Successful in 12m16s
/ release (push) Has been skipped
# Conflicts:
#	apps/pwa/src/runtime.tsx
#	flake.nix
#	packages/mtp/src/context.test.tsx
#	packages/mtp/src/context.tsx
2026-08-27 19:39:52 +02:00
cf17365c8f
Merge branch 'main' into dev 2026-08-27 19:36:41 +02:00
aa34bd962b
temp(mtp): temp commit stuff that needs to get a rework 2026-08-27 19:35:17 +02:00
1d1b304bcf
feat(workflows): remove env thing 2026-08-27 19:32:48 +02:00
forgejo-actions
0fbedf6733 (qol): update release flake hash
Some checks were skipped
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-27 12:59:08 +00:00
96b81d30b1
feat(pwa): keep service workers from getting registerd outside the pwa
Some checks failed
/ build-web (push) Successful in 5m32s
/ build-desktop (linux) (push) Successful in 9m55s
/ build-mobile (push) Successful in 18m33s
/ release (push) Failing after 1m42s
2026-08-27 14:38:53 +02:00
edcf0e15dd
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m3s
/ build-desktop (linux) (push) Successful in 11m9s
/ build-mobile (push) Failing after 11m15s
/ release (push) Has been skipped
2026-08-25 21:00:32 +02:00
5056f084b4
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-desktop (linux) (push) Failing after 4s
/ build-web (push) Failing after 4s
/ build-mobile (push) Failing after 5s
/ release (push) Has been skipped
2026-08-25 20:58:32 +02:00
ede0199858
chore(mtp): udpate type maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m37s
/ build-desktop (linux) (push) Failing after 3m58s
/ build-mobile (push) Failing after 6m4s
/ release (push) Has been skipped
2026-08-25 20:48:56 +02:00
88a094c8bf
feat(pwa): remove update toast
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-25 20:46:14 +02:00
e43e03ff12
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-20 20:56:38 +02:00
97270abed8
Update workflows 2026-08-20 20:56:30 +02:00
2a55c87df1
[Updt] Mtp 0.3.0
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m32s
/ build-desktop (linux) (push) Failing after 2m47s
/ build-mobile (push) Failing after 5m29s
/ release (push) Has been skipped
2026-08-20 17:05:53 +02:00
forgejo-actions
57c7ceb27a (qol): update release flake hash 2026-08-18 20:46:45 +00:00
forgejo-actions
d0918ff384 (qol): update release flake hash 2026-08-18 20:46:44 +00:00
caa7d65b84
feat(pwa): remove install prompt
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 5m40s
/ build-desktop (linux) (push) Successful in 10m33s
/ build-mobile (push) Successful in 23m6s
/ release (push) Successful in 2m15s
2026-08-18 21:40:57 +02:00
0ac20f09e4 Merge pull request 'Add pwa & stuff' (#75) from dev into main
All checks were successful
/ build-web (push) Successful in 5m44s
/ build-desktop (linux) (push) Successful in 10m41s
/ build-mobile (push) Successful in 20m49s
/ release (push) Successful in 1m46s
Reviewed-on: #75
2026-08-18 22:32:16 +03:00
7b36218ffa
feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-18 18:44:45 +02:00
73db7e6b2b Update flake.nix 2026-08-12 15:12:05 +02:00
6e977bca7e
feat(mobile): use drawer for gif picker
Some checks failed
/ build-desktop (linux) (push) Failing after 6m36s
/ build-web (push) Failing after 7m7s
/ build-mobile (push) Failing after 9m21s
/ release (push) Has been skipped
fix(mobile): some ui bugs
fix(mobile): disable pinch zooming
2026-08-12 02:16:56 +02:00
forgejo-actions
ac1bdf5ac2 (qol): update release flake hash 2026-08-06 14:47:23 +00:00
95 changed files with 8319 additions and 2799 deletions

2
.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[env]
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -3,6 +3,7 @@
"entry": [ "entry": [
"src/index.{ts,tsx,js,jsx}", "src/index.{ts,tsx,js,jsx}",
"src/main.{ts,tsx,js,jsx}", "src/main.{ts,tsx,js,jsx}",
"apps/pwa/src/serviceWorker.ts",
"apps/tauri/render-version.ts", "apps/tauri/render-version.ts",
"packages/**/*.test.ts" "packages/**/*.test.ts"
], ],

View file

@ -4,7 +4,6 @@ on:
pull_request: pull_request:
env: env:
NIX_CONFIG: experimental-features = nix-command flakes
FORGEJO_TOKEN: "" FORGEJO_TOKEN: ""
GITHUB_TOKEN: "" GITHUB_TOKEN: ""
@ -14,7 +13,6 @@ jobs:
name: Build web name: Build web
runs-on: nixos runs-on: nixos
steps: steps:
- run: nix profile add nixpkgs#nodejs_24
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
@ -28,7 +26,6 @@ jobs:
name: Build desktop name: Build desktop
runs-on: nixos runs-on: nixos
steps: steps:
- run: nix profile add nixpkgs#nodejs_24
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false
@ -37,12 +34,23 @@ jobs:
- run: nix develop .#electron --command pnpm run build:packages - run: nix develop .#electron --command pnpm run build:packages
- run: nix develop .#electron --command pnpm run build:desktop - run: nix develop .#electron --command pnpm run build:desktop
mobile: native-mtp:
if: ${{ github.actor == 'rasensprenger' }} if: ${{ github.actor == 'rasensprenger' }}
name: Build mobile name: Test native MTP
runs-on: nixos runs-on: nixos
steps: steps:
- run: nix profile add nixpkgs#nodejs_24 - run: nix profile add nixpkgs#nodejs_24
- uses: https://data.forgejo.org/actions/checkout@v4
with:
persist-credentials: false
- run: git submodule update --init --recursive
- run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test'
mobile:
if: ${{ github.actor == 'rasensprenger' }}
name: Build mobile
runs-on: nixos
steps:
- uses: https://data.forgejo.org/actions/checkout@v4 - uses: https://data.forgejo.org/actions/checkout@v4
with: with:
persist-credentials: false persist-credentials: false

View file

@ -1,20 +1,15 @@
on: on:
workflow_dispatch:
push: push:
branches: branches:
- dev - dev
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
build-web: build-web:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -39,9 +34,6 @@ jobs:
build-mobile: build-mobile:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -62,8 +54,6 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: | run: |
nix profile add nixpkgs#gnused
set -euo pipefail set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then if [ -z "$KEYSTORE_BASE64" ]; then
@ -128,9 +118,6 @@ jobs:
matrix: matrix:
target: [linux] target: [linux]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -181,9 +168,6 @@ jobs:
runs-on: nixos runs-on: nixos
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
with: with:

View file

@ -6,16 +6,10 @@ on:
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
build-web: build-web:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -40,9 +34,6 @@ jobs:
build-mobile: build-mobile:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -63,8 +54,6 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: | run: |
nix profile add nixpkgs#gnused
set -euo pipefail set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then if [ -z "$KEYSTORE_BASE64" ]; then
@ -129,9 +118,6 @@ jobs:
matrix: matrix:
target: [linux] target: [linux]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -180,9 +166,6 @@ jobs:
runs-on: nixos runs-on: nixos
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
@ -328,58 +311,3 @@ jobs:
"$API/repos/$REPO/releases/$release_id" "$API/repos/$REPO/releases/$release_id"
done < "$DELETE_RELEASES" done < "$DELETE_RELEASES"
EOF EOF
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)"
test -n "$DEB"
HASH="$(node -e 'const fs = require("fs"); const crypto = require("crypto"); const file = process.argv[1]; console.log("sha256-" + crypto.createHash("sha256").update(fs.readFileSync(file)).digest("base64"));' "$DEB")"
export HASH
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on main."
else
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push
fi
git fetch origin dev
git worktree add ../dev-flake-update origin/dev
cd ../dev-flake-update
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on dev."
exit 0
fi
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push origin HEAD:dev
EOF

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

36
apps/pwa/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@tensamin/pwa",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./vite": "./src/vite.ts",
"./runtime": "./src/runtime.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
},
"dependencies": {
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*",
"mtp": "*",
"react": "^19.2.8",
"sonner": "^2.0.7",
"vite-plugin-pwa": "^1.1.0",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@types/node": "^26.1.2",
"@types/react": "^19.2.18",
"typescript": "~6.0.3",
"vite": "^8.2.1"
}
}

189
apps/pwa/src/runtime.tsx Normal file
View file

@ -0,0 +1,189 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { setDatabaseEntry } from "@tensamin/shared/indexedDb";
import { isTauri } from "@tauri-apps/api/core";
import "./style.css";
const launchedFiles: File[] = [];
const fileListeners = new Set<(file: File) => void>();
function emitLaunchedFile(file: File) {
if (fileListeners.size === 0) launchedFiles.push(file);
else for (const listener of fileListeners) listener(file);
}
export function subscribeTuFileLaunch(listener: (file: File) => void) {
fileListeners.add(listener);
for (const file of launchedFiles.splice(0)) listener(file);
return () => {
fileListeners.delete(listener);
};
}
function isInstalledPwa() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: window-controls-overlay)").matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function applicationServerKey(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
}
async function enablePush() {
if (!("Notification" in window))
throw new Error("Notifications are not supported by this browser.");
const permission = await Notification.requestPermission();
if (permission !== "granted")
throw new Error("Notification permission was not granted.");
const publicKey = import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY;
if (
!publicKey ||
!("serviceWorker" in navigator) ||
!("PushManager" in window)
) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription =
(await registration.pushManager.getSubscription()) ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey(publicKey),
}));
await setDatabaseEntry("keys", "push-subscription", subscription.toJSON());
}
function InstalledPwaRuntime() {
useEffect(() => {
if (
!("serviceWorker" in navigator) ||
!["http:", "https:"].includes(window.location.protocol)
) {
return;
}
let reloading = false;
const handleControllerChange = () => {
if (reloading) return;
reloading = true;
// A newly activated service worker must reload the document it controls.
// eslint-disable-next-line tensamin/no-window-location-reload
window.location.reload();
};
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange,
);
void navigator.serviceWorker
.register(
import.meta.env.DEV ? "/dev-sw.js?dev-sw" : "/serviceWorker.js",
{
type: "module",
},
)
.then((registration) => {
const watchWorker = (worker: ServiceWorker) => {
worker.addEventListener("statechange", () => {
if (worker.state !== "installed") return;
if (!navigator.serviceWorker.controller) {
toast.success("Tensamin is ready for offline startup");
return;
}
toast("A Tensamin update is ready", {
duration: Infinity,
action: {
label: "Update",
onClick: () => worker.postMessage({ type: "SKIP_WAITING" }),
},
});
});
};
if (registration.installing) watchWorker(registration.installing);
registration.addEventListener("updatefound", () => {
if (registration.installing) watchWorker(registration.installing);
});
})
.catch((error: unknown) => {
console.error("Failed to register the Tensamin service worker", error);
});
return () => {
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange,
);
};
}, []);
useEffect(() => {
const launchQueue = (
window as Window & {
launchQueue?: {
setConsumer: (
consumer: (params: {
files?: Array<{ getFile: () => Promise<File> }>;
}) => void,
) => void;
};
}
).launchQueue;
launchQueue?.setConsumer((params) => {
for (const handle of params.files ?? []) {
void handle.getFile().then(emitLaunchedFile);
}
});
}, []);
useEffect(() => {
if (
!("Notification" in window) ||
Notification.permission !== "default" ||
localStorage.getItem("pwa-push-hint")
) {
return;
}
localStorage.setItem("pwa-push-hint", "shown");
toast("Enable message notifications", {
duration: Infinity,
action: {
label: "Enable",
onClick: () => {
void enablePush()
.then(() => toast.success("Notifications enabled"))
.catch((error: unknown) =>
toast.error(
error instanceof Error
? error.message
: "Could not enable notifications",
),
);
},
},
});
}, []);
useEffect(() => {
if (
"Notification" in window &&
Notification.permission === "granted" &&
import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY
) {
void enablePush().catch((error: unknown) => {
console.error("Failed to refresh the Web Push subscription", error);
});
}
}, []);
return null;
}
export default function PwaRuntime() {
if (isTauri() || !isInstalledPwa()) return null;
return <InstalledPwaRuntime />;
}

View file

@ -0,0 +1,161 @@
/// <reference lib="webworker" />
import { base64ToBytes } from "mtp";
import { clientsClaim } from "workbox-core";
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { createHandlerBoundToURL } from "workbox-precaching";
import { CacheFirst } from "workbox-strategies";
import { decryptChatText, unwrapChatSecret } from "@tensamin/crypto/chatSecret";
import { loadSecureBrowserValue } from "@tensamin/storage/browserSecure";
declare let self: ServiceWorkerGlobalScope;
type PushPayload = {
version: 1;
senderId: number;
sender: string;
avatar?: string;
message: { content: string };
secret: {
chatId: string;
secretId: string;
version: number;
encryptedSecret: string;
kemCiphertext: string;
wrappingScheme: string;
};
};
function isPushPayload(value: unknown): value is PushPayload {
if (!value || typeof value !== "object") return false;
const payload = value as Partial<PushPayload>;
const message = payload.message as
Partial<PushPayload["message"]> | undefined;
const secret = payload.secret as Partial<PushPayload["secret"]> | undefined;
const stringValues = [
payload.sender,
message?.content,
secret?.chatId,
secret?.secretId,
secret?.encryptedSecret,
secret?.kemCiphertext,
secret?.wrappingScheme,
];
return (
payload.version === 1 &&
typeof payload.senderId === "number" &&
Number.isSafeInteger(payload.senderId) &&
payload.senderId > 0 &&
typeof secret?.version === "number" &&
stringValues.every((item) => typeof item === "string")
);
}
async function decryptPush(payload: PushPayload) {
const keyring = await loadSecureBrowserValue<string>("mtp_keyring");
if (!keyring) throw new Error("MTP credentials are unavailable.");
const chatSecret = await unwrapChatSecret({
encryptedSecret: base64ToBytes(payload.secret.encryptedSecret),
kemCiphertext: base64ToBytes(payload.secret.kemCiphertext),
keyring,
chatId: payload.secret.chatId,
secretId: payload.secret.secretId,
version: payload.secret.version,
wrappingScheme: payload.secret.wrappingScheme,
});
try {
return await decryptChatText(chatSecret, payload.message.content);
} finally {
chatSecret.fill(0);
}
}
clientsClaim();
cleanupOutdatedCaches();
const precacheManifest = self.__WB_MANIFEST;
precacheAndRoute(precacheManifest);
if (
precacheManifest.some((entry) =>
(typeof entry === "string" ? entry : entry.url).endsWith("index.html"),
)
) {
registerRoute(
new NavigationRoute(createHandlerBoundToURL("index.html"), {
denylist: [/^\/api\//],
}),
);
}
registerRoute(
({ request, url }) =>
url.origin === self.location.origin &&
["font", "image", "style"].includes(request.destination),
new CacheFirst({ cacheName: "tensamin-static-v1" }),
);
self.addEventListener("push", (event) => {
event.waitUntil(
(async () => {
let payload: PushPayload | undefined;
try {
const value = event.data?.json() as unknown;
if (isPushPayload(value)) payload = value;
} catch {
// The generic notification below is safe for malformed payloads.
}
let body = "Open Tensamin to view the encrypted message.";
if (payload) {
try {
body = await decryptPush(payload);
} catch {
// Do not leak credential or decryption failures in the notification.
}
}
await self.registration.showNotification(payload?.sender ?? "Tensamin", {
body,
icon: payload?.avatar || "./icons/icon-192.png",
badge: "./icons/notification-badge.png",
tag: payload ? `message-${payload.senderId}` : "message",
data: { url: payload ? `/chat?id=${payload.senderId}` : "/" },
});
const navigatorWithBadge = self.navigator as WorkerNavigator & {
setAppBadge?: (contents?: number) => Promise<void>;
};
await navigatorWithBadge.setAppBadge?.().catch(() => undefined);
})(),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
(async () => {
const target = new URL(
String(
(event.notification.data as { url?: string } | undefined)?.url ?? "/",
),
self.location.origin,
);
const windows = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of windows) {
if ("navigate" in client) await client.navigate(target.href);
return client.focus();
}
return self.clients.openWindow(target.href);
})(),
);
});
self.addEventListener("message", (event) => {
if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") {
void self.skipWaiting();
}
});

18
apps/pwa/src/style.css Normal file
View file

@ -0,0 +1,18 @@
@media (display-mode: standalone), (display-mode: fullscreen) {
[data-pwa-root] {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
}
}
@media (display-mode: window-controls-overlay) and (min-width: 768px) {
[data-pwa-navbar] {
min-height: env(titlebar-area-height, 3.375rem);
padding-left: max(1px, env(titlebar-area-x, 0px));
padding-right: max(
0px,
calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))
);
}
}

218
apps/pwa/src/vite.ts Normal file
View file

@ -0,0 +1,218 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const pwaDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const tauriIcons = resolve(pwaDirectory, "../tauri/src-tauri/icons");
const androidResources = resolve(
pwaDirectory,
"../tauri/src-tauri/gen/android/app/src/main/res",
);
function emitIcons(): Plugin {
const icons = [
{
fileName: "icons/icon-180.png",
source: resolve(tauriIcons, "ios/AppIcon-60x60@3x.png"),
},
{
fileName: "icons/icon-192.png",
source: resolve(androidResources, "mipmap-xxxhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-96.png",
source: resolve(androidResources, "mipmap-xhdpi/ic_launcher.png"),
},
{
fileName: "icons/icon-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-maskable-512.png",
source: resolve(tauriIcons, "icon.png"),
},
{
fileName: "icons/icon-monochrome-432.png",
source: resolve(
androidResources,
"mipmap-xxxhdpi/ic_launcher_monochrome.png",
),
},
{
fileName: "icons/notification-badge.png",
source: resolve(androidResources, "drawable/ic_notification_small.png"),
},
];
return {
name: "tensamin-pwa-icons",
configureServer(server) {
server.middlewares.use((request, response, next) => {
const pathname = request.url
? new URL(request.url, "http://localhost").pathname.slice(1)
: "";
const icon = icons.find(({ fileName }) => fileName === pathname);
if (!icon) {
next();
return;
}
response.statusCode = 200;
response.setHeader("Content-Type", "image/png");
response.setHeader("Cache-Control", "no-cache");
response.end(readFileSync(icon.source));
});
},
generateBundle() {
for (const icon of icons) {
this.emitFile({
type: "asset",
fileName: icon.fileName,
source: readFileSync(icon.source),
});
}
},
transformIndexHtml: {
order: "post",
handler() {
return [
{
tag: "link",
attrs: {
rel: "apple-touch-icon",
sizes: "180x180",
href: "./icons/icon-180.png",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "apple-mobile-web-app-capable", content: "yes" },
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-status-bar-style",
content: "black-translucent",
},
injectTo: "head",
},
{
tag: "meta",
attrs: {
name: "apple-mobile-web-app-title",
content: "Tensamin",
},
injectTo: "head",
},
{
tag: "meta",
attrs: { name: "theme-color", content: "#006a67" },
injectTo: "head",
},
];
},
},
};
}
export function tensaminPwa(): Plugin[] {
return [
emitIcons(),
...VitePWA({
strategies: "injectManifest",
srcDir: resolve(pwaDirectory, "src"),
filename: "serviceWorker.ts",
injectRegister: null,
registerType: "prompt",
buildBase: "/",
manifestFilename: "manifest.json",
includeAssets: ["favicon.ico", "icons/*.png"],
manifest: {
id: "/",
name: "Tensamin",
short_name: "Tensamin",
description: "Private messaging and calls with Tensamin.",
start_url: "/",
scope: "/",
display: "standalone",
display_override: ["window-controls-overlay", "standalone"],
background_color: "#001f1e",
theme_color: "#006a67",
categories: ["social", "communication"],
orientation: "any",
launch_handler: { client_mode: "focus-existing" },
icons: [
{
src: "icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "icons/icon-monochrome-432.png",
sizes: "432x432",
type: "image/png",
purpose: "monochrome",
},
],
shortcuts: [
{
name: "Chats",
short_name: "Chats",
url: "/",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
{
name: "Settings",
short_name: "Settings",
url: "/settings",
icons: [
{
src: "icons/icon-96.png",
sizes: "96x96",
type: "image/png",
},
],
},
],
file_handlers: [
{
action: "/login",
accept: { "application/x-tensamin-user": [".tu"] },
},
],
},
injectManifest: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2,wasm,mp3,wav}"],
globIgnores: ["assets/v2/**"],
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
},
devOptions: {
enabled: true,
type: "module",
},
}),
];
}

33
apps/pwa/todo.md Normal file
View file

@ -0,0 +1,33 @@
# Web Push Backend TODO
The client can subscribe and decrypt version 1 push payloads, but reliable delivery requires backend support.
- Generate and securely store a VAPID key pair. Expose only the public key to the web build as `VITE_WEB_PUSH_PUBLIC_KEY`.
- Add authenticated MTP requests for registering, replacing, and deleting a browser `PushSubscription` per user and installation.
- Persist the endpoint, `p256dh`, `auth`, expiration time, stable installation ID, and last-seen time.
- Remove subscriptions when a push service returns HTTP 404 or 410 and rate-limit registrations per user.
- Send pushes when an encrypted live message cannot be delivered to an active browser client. Define duplicate suppression for clients that receive both MTP and Web Push.
- Keep the JSON payload within push-provider limits and use this version 1 shape:
```json
{
"version": 1,
"senderId": 123,
"sender": "Display name",
"avatar": "https://optional.example/avatar",
"message": { "content": "base64 encrypted message content" },
"secret": {
"chatId": "123:456",
"secretId": "chat:123:456:main",
"version": 1,
"encryptedSecret": "base64 wrapped chat secret",
"kemCiphertext": "base64 KEM ciphertext",
"wrappingScheme": "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"
}
}
```
- Ensure the wrapped secret is intended for the receiving user's MTP keyring. The server must never receive plaintext message content or plaintext chat secrets.
- Decide how edits, deletions, reactions, calls, read states, and per-chat notification cancellation map to push events.
- Add subscription rotation handling and unregister subscriptions when a user logs out or clears application data.
- Configure production HTTPS, SPA route fallback, `application/manifest+json` for `manifest.json`, and `Cache-Control: no-cache` for the service worker.

23
apps/pwa/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vite-plugin-pwa/client", "node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/runtime.tsx", "src/vite.ts"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "WebWorker"],
"types": ["vite-plugin-pwa/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/serviceWorker.ts"]
}

View file

@ -1,2 +0,0 @@
[env]
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -7,10 +7,6 @@
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
},
"./qrCodeScanner": {
"types": "./src/qrCodeScanner.tsx",
"default": "./src/qrCodeScanner.tsx"
} }
}, },
"scripts": { "scripts": {
@ -28,7 +24,6 @@
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-barcode-scanner": "~2.4.5",
"@tauri-apps/plugin-deep-link": "~2.4.9", "@tauri-apps/plugin-deep-link": "~2.4.9",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"react": "^19.2.8", "react": "^19.2.8",

View file

@ -674,12 +674,6 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.10.2" version = "0.10.2"
@ -889,7 +883,6 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9", "fiat-crypto 0.2.9",
"rustc_version", "rustc_version",
"subtle", "subtle",
@ -974,25 +967,14 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468 0.7.0",
"zeroize",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d"
dependencies = [ dependencies = [
"const-oid 0.10.2", "const-oid",
"pem-rfc7468 1.0.0", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@ -1057,7 +1039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"const-oid 0.10.2", "const-oid",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"ctutils", "ctutils",
] ]
@ -1204,38 +1186,14 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]] [[package]]
name = "ed25519" name = "ed25519"
version = "3.0.0" version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [ dependencies = [
"pkcs8 0.11.0", "pkcs8",
"signature 3.0.0", "signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
] ]
[[package]] [[package]]
@ -1245,10 +1203,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [ dependencies = [
"curve25519-dalek 5.0.0", "curve25519-dalek 5.0.0",
"ed25519 3.0.0", "ed25519",
"serde", "serde",
"sha2 0.11.0", "sha2 0.11.0",
"signature 3.0.0", "signature",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -2737,14 +2695,14 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d"
dependencies = [ dependencies = [
"const-oid 0.10.2", "const-oid",
"crypto-common 0.2.2", "crypto-common 0.2.2",
"ctutils", "ctutils",
"hybrid-array", "hybrid-array",
"module-lattice", "module-lattice",
"pkcs8 0.11.0", "pkcs8",
"shake", "shake",
"signature 3.0.0", "signature",
] ]
[[package]] [[package]]
@ -2786,72 +2744,50 @@ dependencies = [
[[package]] [[package]]
name = "mtp" name = "mtp"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"mtp-client", "mtp-client",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-codec",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-common",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-crypto",
"mtp-host", "mtp-host",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-transport",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-type-map",
"mtp-webserver", "mtp-webserver",
] ]
[[package]] [[package]]
name = "mtp-client" name = "mtp-client"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-codec",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-common",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-crypto",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-transport",
"rand 0.10.2", "rand 0.10.2",
"tokio", "tokio",
] ]
[[package]] [[package]]
name = "mtp-codec" name = "mtp-codec"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"byteorder", "byteorder",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-common",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-crypto",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-type-map",
"rand 0.10.2", "rand 0.10.2",
]
[[package]]
name = "mtp-codec"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"base64 0.23.1",
"byteorder",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-type-map 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"rand 0.10.2",
]
[[package]]
name = "mtp-common"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"quinn",
"rustls",
"thiserror 2.0.19", "thiserror 2.0.19",
"wtransport",
] ]
[[package]] [[package]]
name = "mtp-common" name = "mtp-common"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"quinn", "quinn",
"rustls", "rustls",
@ -2861,18 +2797,18 @@ dependencies = [
[[package]] [[package]]
name = "mtp-crypto" name = "mtp-crypto"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.22.1",
"chacha20poly1305", "chacha20poly1305",
"ed25519-dalek 2.2.0", "ed25519-dalek",
"getrandom 0.4.3", "getrandom 0.4.3",
"hkdf", "hkdf",
"ml-dsa", "ml-dsa",
"mlkem-tls", "mlkem-tls",
"rand 0.10.2", "rand 0.10.2",
"rand_core 0.10.1", "rand_core 0.6.4",
"rustls", "rustls",
"serde", "serde",
"sha2 0.11.0", "sha2 0.11.0",
@ -2882,89 +2818,45 @@ dependencies = [
] ]
[[package]] [[package]]
name = "mtp-crypto" name = "mtp-host"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"base64 0.23.1", "mtp-codec",
"chacha20poly1305", "mtp-common",
"ed25519-dalek 3.0.0", "mtp-crypto",
"getrandom 0.4.3", "mtp-transport",
"hkdf",
"ml-dsa",
"rand 0.10.2", "rand 0.10.2",
"rand_core 0.10.1",
"rustls",
"sha2 0.11.0",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.3.0"
source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [
"async-trait",
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rand 0.10.2",
"rcgen",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tracing",
"wtransport",
"zeroize", "zeroize",
] ]
[[package]]
name = "mtp-host"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rand 0.10.2",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22"
dependencies = [
"async-trait",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)",
"rcgen",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tracing",
"wtransport",
]
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [
"async-trait",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"rcgen",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tracing",
"wtransport",
]
[[package]] [[package]]
name = "mtp-type-map" name = "mtp-type-map"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [
"serde",
"serde_yaml",
]
[[package]]
name = "mtp-type-map"
version = "0.2.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b#b067614a684eb1856bc5db7b3fd82148c036ce6b"
dependencies = [ dependencies = [
"serde", "serde",
"serde_yaml", "serde_yaml",
@ -2972,8 +2864,8 @@ dependencies = [
[[package]] [[package]]
name = "mtp-webserver" name = "mtp-webserver"
version = "0.2.0" version = "0.3.0"
source = "git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22#7182272e3edb0079d7b74b45b2f62a298d2fee22" source = "git+https://git.methanium.net/Methanium/mtp.git?rev=a5c8d4f0c898c78351e9d54124886c86e789a22a#a5c8d4f0c898c78351e9d54124886c86e789a22a"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@ -2984,13 +2876,12 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"mtp-codec 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-codec",
"mtp-common 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-common",
"mtp-crypto 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-crypto",
"mtp-host", "mtp-host",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=7182272e3edb0079d7b74b45b2f62a298d2fee22)", "mtp-transport",
"quinn", "quinn",
"rand 0.10.2",
"rustls", "rustls",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
@ -3492,15 +3383,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "1.0.0" version = "1.0.0"
@ -3586,24 +3468,14 @@ dependencies = [
"futures-io", "futures-io",
] ]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]] [[package]]
name = "pkcs8" name = "pkcs8"
version = "0.11.0" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [ dependencies = [
"der 0.8.1", "der",
"spki 0.8.0", "spki",
] ]
[[package]] [[package]]
@ -4592,15 +4464,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "signature" name = "signature"
version = "3.0.0" version = "3.0.0"
@ -4709,16 +4572,6 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der 0.7.10",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.8.0" version = "0.8.0"
@ -4726,7 +4579,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"der 0.8.1", "der",
] ]
[[package]] [[package]]
@ -5035,20 +4888,6 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-barcode-scanner"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b68f0e3782b61a6e16a67380be40226e2b7233f3e36dadf6e5d03f07d2e7b3"
dependencies = [
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.19",
]
[[package]] [[package]]
name = "tauri-plugin-deep-link" name = "tauri-plugin-deep-link"
version = "2.4.9" version = "2.4.9"
@ -5298,18 +5137,16 @@ dependencies = [
[[package]] [[package]]
name = "tensamin" name = "tensamin"
version = "0.0.11" version = "0.0.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"jni 0.22.4", "jni 0.22.4",
"mtp", "mtp",
"mtp-transport 0.2.0 (git+https://git.methanium.net/methanium/mtp.git?rev=b067614a684eb1856bc5db7b3fd82148c036ce6b)",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-barcode-scanner",
"tauri-plugin-deep-link", "tauri-plugin-deep-link",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-notification", "tauri-plugin-notification",

View file

@ -24,8 +24,7 @@ serde_json = "1"
base64 = "0.22" base64 = "0.22"
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "7182272e3edb0079d7b74b45b2f62a298d2fee22", features = ["client", "crypto"] } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "b067614a684eb1856bc5db7b3fd82148c036ce6b" }
webpki-root-certs = "1" webpki-root-certs = "1"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
@ -36,12 +35,14 @@ version = "2"
features = [] features = []
default-features = true default-features = true
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
version = "2"
features = []
default-features = true
[target.'cfg(target_os = "android")'.dependencies] [target.'cfg(target_os = "android")'.dependencies]
jni = "0.22" jni = "0.22"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
[patch.crates-io.tauri] [patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri" git = "https://github.com/tauri-apps/tauri"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41" rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"

View file

@ -5,9 +5,6 @@
"permissions": [ "permissions": [
"core:event:default", "core:event:default",
"deep-link:default", "deep-link:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"notification:default", "notification:default",
"log:default" "log:default"
] ]

View file

@ -5,6 +5,7 @@ import android.app.Activity
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.view.MotionEvent
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowManager import android.view.WindowManager
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
@ -54,6 +55,32 @@ class MainActivity : TauriActivity() {
} }
override fun onWebViewCreate(webView: WebView) { override fun onWebViewCreate(webView: WebView) {
webView.settings.apply {
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
}
var blockingMultiTouch = false
webView.setOnTouchListener { _, event ->
val shouldBlock = blockingMultiTouch || event.pointerCount > 1
when (event.actionMasked) {
MotionEvent.ACTION_POINTER_DOWN -> {
// Cancel the one-finger gesture before consuming the rest of the pinch.
MotionEvent.obtain(event).let { cancelEvent ->
cancelEvent.action = MotionEvent.ACTION_CANCEL
webView.onTouchEvent(cancelEvent)
cancelEvent.recycle()
}
blockingMultiTouch = true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> blockingMultiTouch = false
}
shouldBlock
}
NativeAccessibilityBridge.attach(webView) NativeAccessibilityBridge.attach(webView)
mediaWebView = webView mediaWebView = webView
MobileMediaEvents.attach(webView) MobileMediaEvents.attach(webView)

View file

@ -15,9 +15,6 @@ pub fn run() {
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
let app = builder let app = builder
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
accessibility_backend::accessibility_get_initial_scale, accessibility_backend::accessibility_get_initial_scale,

View file

@ -1,9 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use lib::log;
fn main() { fn main() {
mobile_lib::run(); mobile_lib::run();
log("Test test 123")
} }

View file

@ -2,7 +2,7 @@ use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex, OnceLock, RwLock, Arc, Mutex, OnceLock, RwLock,
}; };
use std::time::Duration; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::{ use base64::{
engine::general_purpose::{STANDARD, STANDARD_NO_PAD}, engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
@ -16,6 +16,7 @@ use mtp::crypto::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
const EVENT_NAME: &str = "mtp://event"; const EVENT_NAME: &str = "mtp://event";
const DISCONNECTED: u8 = 0; const DISCONNECTED: u8 = 0;
@ -24,6 +25,9 @@ const CONNECTED: u8 = 2;
const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1"; const CHAT_SECRET_SALT: &[u8] = b"tensamin-chat-secret-v1";
const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1"; const CHAT_MESSAGE_SALT: &[u8] = b"tensamin-chat-message-v1";
const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"; const CHAT_SECRET_SCHEME: &str = "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1";
const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_BUFFERED_INITIAL_FRAMES: usize = 1_000;
const NOTIFICATION_QUEUE_CAPACITY: usize = 32;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ const ROOT_YE_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\n\
MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\ MIIB2TCCAWCgAwIBAgIRAKQCa6LvbHwg1AR+XmWmk4AwCgYIKoZIzj0EAwMwLjEL\n\
@ -95,7 +99,7 @@ enum MtpEvent {
pub struct MtpManager { pub struct MtpManager {
runtime: tokio::runtime::Runtime, runtime: tokio::runtime::Runtime,
config: RwLock<Option<MtpConfig>>, config: RwLock<Option<MtpConfig>>,
connection: RwLock<Option<Arc<MTPConnection>>>, connection: RwLock<Option<Arc<ManagedConnection>>>,
snapshot: RwLock<MtpSnapshot>, snapshot: RwLock<MtpSnapshot>,
generation: AtomicU64, generation: AtomicU64,
enabled: AtomicBool, enabled: AtomicBool,
@ -104,6 +108,46 @@ pub struct MtpManager {
start_lock: Mutex<()>, start_lock: Mutex<()>,
} }
struct RequestIdAllocator {
next: AtomicU64,
}
impl RequestIdAllocator {
fn new() -> Self {
Self {
next: AtomicU64::new(1),
}
}
fn next(&self) -> Result<u32, String> {
let value = self.next.fetch_add(1, Ordering::Relaxed);
u32::try_from(value)
.map_err(|_| "MTP request ID space exhausted for this connection".to_string())
}
}
struct ManagedConnection {
mtp: Arc<MTPConnection>,
request_ids: RequestIdAllocator,
}
impl ManagedConnection {
async fn next_request_id(&self) -> Result<u32, String> {
let id = self.request_ids.next();
if id.is_err() {
self.mtp.sender.close().await;
}
id
}
}
struct PreparedConnection {
connection: MTPConnection,
request_ids: RequestIdAllocator,
initial_state: Value,
buffered_frames: Vec<CommunicationValue>,
}
static MANAGER: OnceLock<MtpManager> = OnceLock::new(); static MANAGER: OnceLock<MtpManager> = OnceLock::new();
pub fn manager() -> &'static MtpManager { pub fn manager() -> &'static MtpManager {
@ -149,7 +193,7 @@ impl MtpManager {
.take() .take()
{ {
self.runtime self.runtime
.spawn(async move { connection.sender.close().await }); .spawn(async move { connection.mtp.sender.close().await });
} }
self.set_snapshot(MtpSnapshot { self.set_snapshot(MtpSnapshot {
generation, generation,
@ -171,7 +215,7 @@ impl MtpManager {
.take() .take()
{ {
self.runtime self.runtime
.spawn(async move { connection.sender.close().await }); .spawn(async move { connection.mtp.sender.close().await });
} }
self.set_snapshot(MtpSnapshot { self.set_snapshot(MtpSnapshot {
generation: self.generation.load(Ordering::SeqCst), generation: self.generation.load(Ordering::SeqCst),
@ -231,20 +275,16 @@ impl MtpManager {
self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation self.enabled.load(Ordering::SeqCst) && self.generation.load(Ordering::SeqCst) == generation
} }
async fn request( async fn request(&self, type_name: &str, data: Value) -> Result<Value, String> {
&self,
type_name: &str,
data: Value,
id: Option<u32>,
) -> Result<Value, String> {
let connection = self let connection = self
.connection .connection
.read() .read()
.map_err(|_| "MTP connection lock is unavailable")? .map_err(|_| "MTP connection lock is unavailable")?
.clone() .clone()
.ok_or_else(|| "MTP is not connected".to_string())?; .ok_or_else(|| "MTP is not connected".to_string())?;
let request = json_to_frame(type_name, data, id)?; let request = json_to_frame(type_name, data, connection.next_request_id().await?)?;
let response = connection let response = connection
.mtp
.request(&request, None) .request(&request, None)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -261,9 +301,12 @@ async fn supervise(generation: u64) {
manager.log(2, "Starting native MTP connection", None); manager.log(2, "Starting native MTP connection", None);
android_status("Connecting"); android_status("Connecting");
match connect(&config).await { match connect(&config).await {
Ok((connection, state)) => { Ok(prepared) => {
delay = Duration::from_secs(1); delay = Duration::from_secs(1);
let connection = Arc::new(connection); let connection = Arc::new(ManagedConnection {
mtp: Arc::new(prepared.connection),
request_ids: prepared.request_ids,
});
let stale = { let stale = {
let _guard = manager.start_lock.lock().expect("start lock poisoned"); let _guard = manager.start_lock.lock().expect("start lock poisoned");
let mut current = manager let mut current = manager
@ -279,22 +322,47 @@ async fn supervise(generation: u64) {
generation, generation,
ready_state: CONNECTED, ready_state: CONNECTED,
identified: true, identified: true,
state: Some(state), state: Some(prepared.initial_state),
error: None, error: None,
}); });
false false
} }
}; };
if stale { if stale {
connection.sender.close().await; connection.mtp.sender.close().await;
break; break;
} }
android_status("Connected"); android_status("Connected");
manager.log(2, "Native MTP connection established", None); manager.log(2, "Native MTP connection established", None);
let (notification_tx, mut notification_rx) =
mpsc::channel(NOTIFICATION_QUEUE_CAPACITY);
let notification_connection = connection.clone();
let notification_config = config.clone();
let notification_worker = tokio::spawn(async move {
while let Some(frame) = notification_rx.recv().await {
if !manager.is_current(generation) {
break;
}
if let Err(error) = notify_message(
&notification_config,
notification_connection.clone(),
&frame,
)
.await
{
eprintln!("failed to create background message notification: {error}");
}
}
});
for frame in prepared.buffered_frames {
handle_push(generation, &notification_tx, frame).await;
}
while manager.is_current(generation) { while manager.is_current(generation) {
match connection.receive().await { match connection.mtp.receive().await {
Ok(frame) => handle_push(generation, connection.clone(), frame).await, Ok(frame) => handle_push(generation, &notification_tx, frame).await,
Err(error) => { Err(error) => {
let _guard = manager.start_lock.lock().expect("start lock poisoned"); let _guard = manager.start_lock.lock().expect("start lock poisoned");
if manager.is_current(generation) { if manager.is_current(generation) {
@ -310,6 +378,13 @@ async fn supervise(generation: u64) {
} }
} }
} }
drop(notification_tx);
notification_worker.abort();
if let Err(error) = notification_worker.await {
if !error.is_cancelled() {
eprintln!("background notification worker failed: {error}");
}
}
let mut current = manager let mut current = manager
.connection .connection
.write() .write()
@ -339,12 +414,21 @@ async fn supervise(generation: u64) {
break; break;
} }
android_status("Reconnecting"); android_status("Reconnecting");
tokio::time::sleep(delay).await; tokio::time::sleep(jittered_retry_delay(delay)).await;
delay = (delay * 2).min(Duration::from_secs(60)); delay = (delay * 2).min(Duration::from_secs(60));
} }
} }
async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> { fn jittered_retry_delay(delay: Duration) -> Duration {
let entropy = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos())
.unwrap_or_default();
let percent = 80 + entropy % 41;
delay.mul_f64(percent as f64 / 100.0)
}
async fn connect(config: &MtpConfig) -> Result<PreparedConnection, String> {
let (url, public_key) = resolve_endpoint(config) let (url, public_key) = resolve_endpoint(config)
.await .await
.map_err(|error| format!("endpoint discovery failed: {error}"))?; .map_err(|error| format!("endpoint discovery failed: {error}"))?;
@ -381,39 +465,10 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
.map_err(|error| format!("transport authentication failed: {error}"))?; .map_err(|error| format!("transport authentication failed: {error}"))?;
manager().log(2, "Native MTP authentication completed", None); manager().log(2, "Native MTP authentication completed", None);
let connected = CommunicationValue::new(CommunicationType::ClientConnected) let (state, buffered_frames) = await_initial_state(&connection).await?;
.add_typed_default( let request_ids = RequestIdAllocator::new();
DataType::SessionId, let (initial_state, ack) = prepare_initial_state_ack(&state, &request_ids)?;
DataValue::UnsignedNumber(current_millis() as u128), let response = tokio::time::timeout(INITIAL_SYNC_TIMEOUT, connection.request(&ack, None))
)
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheValid, DataValue::BoolFalse)
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0));
let state = tokio::time::timeout(
Duration::from_secs(30),
connection.request(&connected, None),
)
.await
.map_err(|_| "initial state synchronization timed out".to_string())?
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
if !state.is_type(CommunicationType::ClientStateSync) {
return Err(format!(
"expected ClientStateSync, received {}",
state.get_type_name().unwrap_or("unknown")
));
}
let session_id = state
.get_data(DataType::SessionId)
.as_number()
.ok_or("ClientStateSync omitted SessionId")?;
let version = state
.get_data(DataType::VersionNumber)
.as_number()
.ok_or("ClientStateSync omitted VersionNumber")?;
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
.add_typed_default(DataType::SessionId, number_to_data(session_id))
.add_typed_default(DataType::VersionNumber, number_to_data(version));
let response = tokio::time::timeout(Duration::from_secs(30), connection.request(&ack, None))
.await .await
.map_err(|_| "state acknowledgement timed out".to_string())? .map_err(|_| "state acknowledgement timed out".to_string())?
.map_err(|error| format!("state acknowledgement failed: {error}"))?; .map_err(|error| format!("state acknowledgement failed: {error}"))?;
@ -423,7 +478,182 @@ async fn connect(config: &MtpConfig) -> Result<(MTPConnection, Value), String> {
{ {
return Err(format!("ClientStateAck failed: {response}")); return Err(format!("ClientStateAck failed: {response}"));
} }
Ok((connection, frame_data_to_json(&state)?)) Ok(PreparedConnection {
connection,
request_ids,
initial_state,
buffered_frames,
})
}
fn prepare_initial_state_ack(
state: &CommunicationValue,
request_ids: &RequestIdAllocator,
) -> Result<(Value, CommunicationValue), String> {
let initial_state = frame_data_to_json(state)?;
validate_client_state_sync(&initial_state)?;
let data = initial_state
.as_object()
.ok_or("ClientStateSync payload is not an object")?;
let session_id = required_integer(data, "SessionId")?;
let version = required_integer(data, "VersionNumber")?;
let ack = CommunicationValue::new(communication_type("ClientStateAck")?)
.with_id(request_ids.next()?)
.add_typed_default(DataType::SessionId, number_to_data(session_id))
.add_typed_default(DataType::VersionNumber, number_to_data(version));
Ok((initial_state, ack))
}
fn validate_client_state_sync(state: &Value) -> Result<(), String> {
let data = state
.as_object()
.ok_or("ClientStateSync payload is not an object")?;
if required_integer(data, "SessionId")? <= 0 {
return Err("ClientStateSync SessionId must be a positive integer".into());
}
for field in ["VersionNumber", "CacheSchemaVersion"] {
if required_integer(data, field)? < 0 {
return Err(format!("ClientStateSync {field} must be nonnegative"));
}
}
match data.get("SyncMode").and_then(Value::as_str) {
Some("full" | "delta") => {}
_ => return Err("ClientStateSync SyncMode must be 'full' or 'delta'".into()),
}
for field in ["Contacts", "Communities", "Calls", "Messages"] {
if !data.get(field).is_some_and(Value::is_array) {
return Err(format!("ClientStateSync {field} must be an array"));
}
}
for field in ["DeletedMessageIds", "DeletedContactIds"] {
if let Some(value) = data.get(field) {
let values = value
.as_array()
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
if values.iter().any(|value| !value.is_number()) {
return Err(format!("ClientStateSync {field} must contain numbers"));
}
}
}
validate_object_array(data, "Communities", |_| Ok(()))?;
validate_object_array(data, "Contacts", validate_contact)?;
validate_object_array(data, "Calls", validate_call)?;
validate_object_array(data, "Messages", validate_message)?;
Ok(())
}
fn validate_object_array(
data: &Map<String, Value>,
field: &str,
validate: impl Fn(&Map<String, Value>) -> Result<(), String>,
) -> Result<(), String> {
let values = data
.get(field)
.and_then(Value::as_array)
.ok_or_else(|| format!("ClientStateSync {field} must be an array"))?;
for value in values {
let object = value
.as_object()
.ok_or_else(|| format!("ClientStateSync {field} entries must be objects"))?;
validate(object)?;
}
Ok(())
}
fn validate_contact(contact: &Map<String, Value>) -> Result<(), String> {
if !contact.get("UserId").is_some_and(Value::is_number) {
return Err("ClientStateSync contact omitted numeric UserId".into());
}
if let Some(messages) = contact.get("Messages") {
let messages = messages
.as_array()
.ok_or("ClientStateSync contact Messages must be an array")?;
for message in messages {
validate_message(
message
.as_object()
.ok_or("ClientStateSync contact message must be an object")?,
)?;
}
}
Ok(())
}
fn validate_call(call: &Map<String, Value>) -> Result<(), String> {
if !call.get("CallId").is_some_and(Value::is_string) {
return Err("ClientStateSync call omitted string CallId".into());
}
let members = call
.get("CallMembers")
.and_then(Value::as_array)
.ok_or("ClientStateSync call omitted CallMembers array")?;
if members.iter().any(|member| !member.is_number()) {
return Err("ClientStateSync CallMembers must contain numbers".into());
}
Ok(())
}
fn validate_message(message: &Map<String, Value>) -> Result<(), String> {
for field in ["SenderId", "SendTime"] {
if !message.get(field).is_some_and(Value::is_number) {
return Err(format!("ClientStateSync message omitted numeric {field}"));
}
}
let content = message
.get("Content")
.and_then(Value::as_str)
.ok_or("ClientStateSync message omitted string Content")?;
STANDARD
.decode(content)
.or_else(|_| STANDARD_NO_PAD.decode(content))
.map_err(|_| "ClientStateSync message Content must be base64".to_string())?;
if let Some(state) = message.get("MessageState") {
match state.as_str() {
Some("read" | "received" | "sent" | "sending" | "awaiting") => {}
_ => return Err("ClientStateSync message has invalid MessageState".into()),
}
}
Ok(())
}
fn required_integer(data: &Map<String, Value>, field: &str) -> Result<i128, String> {
let value = data
.get(field)
.ok_or_else(|| format!("ClientStateSync omitted {field}"))?;
if let Some(value) = value.as_i64() {
return Ok(value as i128);
}
value
.as_u64()
.map(|value| value as i128)
.ok_or_else(|| format!("ClientStateSync {field} must be an integer"))
}
async fn await_initial_state(
connection: &MTPConnection,
) -> Result<(CommunicationValue, Vec<CommunicationValue>), String> {
let mut buffered = Vec::new();
let deadline = tokio::time::Instant::now() + INITIAL_SYNC_TIMEOUT;
loop {
let frame = tokio::time::timeout_at(deadline, connection.receive())
.await
.map_err(|_| "initial state synchronization timed out".to_string())?
.map_err(|error| format!("initial state synchronization failed: {error}"))?;
if frame.is_type(CommunicationType::ErrorNoIota) {
return Err("No Iota is currently connected".into());
}
if frame.get_type_name() == Some("ClientStateSync") {
return Ok((frame, buffered));
}
if buffered.len() == MAX_BUFFERED_INITIAL_FRAMES {
return Err("initial state synchronization buffered too many frames".into());
}
buffered.push(frame);
}
} }
async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> { async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String> {
@ -465,7 +695,11 @@ async fn resolve_endpoint(config: &MtpConfig) -> Result<(String, String), String
)) ))
} }
async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: CommunicationValue) { async fn handle_push(
generation: u64,
notification_tx: &mpsc::Sender<CommunicationValue>,
frame: CommunicationValue,
) {
let manager = manager(); let manager = manager();
if let Ok(message) = frame_to_json(&frame) { if let Ok(message) = frame_to_json(&frame) {
manager.emit(MtpEvent::Message { manager.emit(MtpEvent::Message {
@ -478,7 +712,7 @@ async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: Com
{ {
if let Some(partner_id) = frame if let Some(partner_id) = frame
.get_data(DataType::ChatPartnerId) .get_data(DataType::ChatPartnerId)
.as_number() .and_then(DataValue::as_number)
.and_then(|value| u64::try_from(value).ok()) .and_then(|value| u64::try_from(value).ok())
{ {
if let Err(error) = android_cancel_notification(partner_id) { if let Err(error) = android_cancel_notification(partner_id) {
@ -487,40 +721,39 @@ async fn handle_push(generation: u64, connection: Arc<MTPConnection>, frame: Com
} }
} }
if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) { if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) {
if let Err(error) = notify_message(connection, &frame).await { if notification_tx.try_send(frame).is_err() {
eprintln!("failed to create background message notification: {error}"); eprintln!("background message notification queue is full");
} }
} }
} }
async fn notify_message( async fn notify_message(
connection: Arc<MTPConnection>, config: &MtpConfig,
connection: Arc<ManagedConnection>,
frame: &CommunicationValue, frame: &CommunicationValue,
) -> Result<(), String> { ) -> Result<(), String> {
let sender_id = frame let sender_id = frame
.get_data(DataType::SenderId) .get_data(DataType::SenderId)
.as_number() .and_then(DataValue::as_number)
.and_then(|value| u64::try_from(value).ok()) .and_then(|value| u64::try_from(value).ok())
.ok_or("MessageLive omitted SenderId")?; .ok_or("MessageLive omitted SenderId")?;
let message = frame.get_data(DataType::Message); let message = frame
let content = container_value(message, DataType::Content) .get_data(DataType::Message)
.ok_or("MessageLive omitted Message")?;
let content = container_value_by_name(message, "Content")
.and_then(DataValue::as_str) .and_then(DataValue::as_str)
.ok_or("MessageLive omitted Content")?; .ok_or("MessageLive omitted Content")?;
let config = manager()
.config
.read()
.expect("config lock poisoned")
.clone()
.ok_or("missing config")?;
let keyring_bytes = decode_browser_base64(&config.keyring)?; let keyring_bytes = decode_browser_base64(&config.keyring)?;
let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?; let keyring = Keyring::from_bytes(&keyring_bytes).map_err(|error| error.to_string())?;
let chat_id = derive_chat_id(config.user_id, sender_id); let chat_id = derive_chat_id(config.user_id, sender_id);
let secret_id = format!("chat:{chat_id}:main"); let secret_id = format!("chat:{chat_id}:main");
let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret) let secret_request = CommunicationValue::new(CommunicationType::GetChatSecret)
.with_id(connection.next_request_id().await?)
.add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string())) .add_typed_default(DataType::UserId, DataValue::Str(config.user_id.to_string()))
.add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone())) .add_typed_default(DataType::ChatId, DataValue::Str(chat_id.clone()))
.add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone())); .add_typed_default(DataType::SecretId, DataValue::Str(secret_id.clone()));
let secret = connection let secret = connection
.mtp
.request(&secret_request, None) .request(&secret_request, None)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -532,7 +765,7 @@ async fn notify_message(
} }
let version = secret let version = secret
.get_data(DataType::VersionNumber) .get_data(DataType::VersionNumber)
.as_number() .and_then(DataValue::as_number)
.ok_or("missing secret version")?; .ok_or("missing secret version")?;
let encrypted_secret = secret let encrypted_secret = secret
.get_bytes(DataType::EncryptedSecret) .get_bytes(DataType::EncryptedSecret)
@ -560,11 +793,14 @@ async fn notify_message(
.decrypt(&ciphertext, b"") .decrypt(&ciphertext, b"")
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let user_request = CommunicationValue::new(CommunicationType::GetUserData).add_typed_default( let user_request = CommunicationValue::new(CommunicationType::GetUserData)
.with_id(connection.next_request_id().await?)
.add_typed_default(
DataType::UserId, DataType::UserId,
DataValue::UnsignedNumber(sender_id as u128), DataValue::UnsignedNumber(sender_id as u128),
); );
let user = connection let user = connection
.mtp
.request(&user_request, None) .request(&user_request, None)
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@ -652,17 +888,17 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
value.get_field(id) value.get_field(id)
} }
fn json_to_frame( fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> {
type_name: &str, container_value(value, DataType::from_name(field)?)
data: Value,
id: Option<u32>,
) -> Result<CommunicationValue, String> {
let comm_type = CommunicationType::from_name(type_name)
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
let mut frame = CommunicationValue::new(comm_type);
if let Some(id) = id {
frame = frame.with_id(id);
} }
fn communication_type(name: &str) -> Result<CommunicationType, String> {
CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}"))
}
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
let comm_type = communication_type(type_name)?;
let mut frame = CommunicationValue::new(comm_type).with_id(id);
let Value::Object(fields) = data else { let Value::Object(fields) = data else {
return Err("MTP request data must be an object".into()); return Err("MTP request data must be an object".into());
}; };
@ -737,8 +973,8 @@ fn is_bytes_field(field: &str) -> bool {
fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> { fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
let mut result = Map::new(); let mut result = Map::new();
if frame.get_id() != 0 { if let Some(id) = frame.id() {
result.insert("id".into(), Value::from(frame.get_id())); result.insert("id".into(), Value::from(id));
} }
result.insert( result.insert(
"type".into(), "type".into(),
@ -751,7 +987,10 @@ fn frame_to_json(frame: &CommunicationValue) -> Result<Value, String> {
fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> { fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); let map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
let mut result = Map::new(); let mut result = Map::new();
for (id, value) in frame.data() { let entries = frame
.data()
.ok_or("MTP frame payload is not a data container")?;
for (id, value) in entries {
let name = map let name = map
.data_type_name(id.0) .data_type_name(id.0)
.ok_or_else(|| format!("unknown data type id: {}", id.0))?; .ok_or_else(|| format!("unknown data type id: {}", id.0))?;
@ -801,16 +1040,15 @@ fn number_to_json(value: i128) -> Result<Value, String> {
.map_err(|_| "number exceeds JSON range".into()) .map_err(|_| "number exceeds JSON range".into())
} }
fn current_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{decode_browser_base64, decode_sdk_bytes}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use serde_json::json;
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,
};
#[test] #[test]
fn browser_base64_accepts_file_whitespace_and_missing_padding() { fn browser_base64_accepts_file_whitespace_and_missing_padding() {
@ -823,11 +1061,137 @@ mod tests {
assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]); assert_eq!(decode_sdk_bytes("0x01:02-ff").unwrap(), [1, 2, 255]);
assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]); assert_eq!(decode_sdk_bytes("AQI=").unwrap(), [1, 2]);
} }
#[test]
fn request_ids_are_nonzero_and_monotonic() {
let ids = RequestIdAllocator::new();
assert_eq!(ids.next().unwrap(), 1);
assert_eq!(ids.next().unwrap(), 2);
}
#[test]
fn retry_jitter_stays_within_policy_bounds() {
let delay = jittered_retry_delay(std::time::Duration::from_secs(10));
assert!(delay >= std::time::Duration::from_secs(8));
assert!(delay <= std::time::Duration::from_secs(12));
}
#[test]
fn json_content_uses_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
"Content": "ciphertext",
"ChatPartnerId": 42,
"SendTime": 10,
}),
1,
)
.unwrap();
assert_eq!(
frame
.get_data(DataType::Content)
.and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn nested_json_content_uses_content_wire_type() {
let frame = json_to_frame(
"MessageEdit",
json!({
"Message": { "Content": "ciphertext" },
}),
1,
)
.unwrap();
let message = frame.get_data(DataType::Message).unwrap();
assert_eq!(
container_value_by_name(message, "Content").and_then(DataValue::as_str),
Some("ciphertext")
);
}
#[test]
fn content_is_exposed_to_frontend() {
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
.with_id(1)
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()));
let json = frame_to_json(&frame).unwrap();
assert_eq!(json["data"]["Content"], "ciphertext");
}
fn valid_initial_state() -> CommunicationValue {
CommunicationValue::new(communication_type("ClientStateSync").unwrap())
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
.add_typed_default(DataType::Contacts, DataValue::Array(vec![]))
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
.add_typed_default(DataType::Messages, DataValue::Array(vec![]))
}
#[test]
fn valid_initial_state_is_prepared_before_ack() {
let ids = RequestIdAllocator::new();
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
assert_eq!(state["SyncMode"], "full");
assert_eq!(ack.get_type_name(), Some("ClientStateAck"));
assert_eq!(ack.id(), Some(1));
}
#[test]
fn malformed_initial_state_does_not_prepare_ack() {
let ids = RequestIdAllocator::new();
let malformed = valid_initial_state()
.add_typed_default(DataType::SyncMode, DataValue::Str("invalid".into()));
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
assert_eq!(
ids.next().unwrap(),
1,
"no acknowledgement ID was allocated"
);
}
#[test]
fn malformed_nested_initial_state_does_not_prepare_ack() {
let ids = RequestIdAllocator::new();
let malformed = CommunicationValue::new(communication_type("ClientStateSync").unwrap())
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
.add_typed_default(DataType::SyncMode, DataValue::Str("full".into()))
.add_typed_default(
DataType::Contacts,
DataValue::Array(vec![DataValue::Container(vec![])]),
)
.add_typed_default(DataType::Communities, DataValue::Array(vec![]))
.add_typed_default(DataType::Calls, DataValue::Array(vec![]))
.add_typed_default(DataType::Messages, DataValue::Array(vec![]));
assert!(prepare_initial_state_ack(&malformed, &ids).is_err());
assert_eq!(
ids.next().unwrap(),
1,
"no acknowledgement ID was allocated"
);
}
} }
#[tauri::command] #[tauri::command]
pub async fn mtp_request(type_name: String, data: Value, id: Option<u32>) -> Result<Value, String> { pub async fn mtp_request(type_name: String, data: Value) -> Result<Value, String> {
manager().request(&type_name, data, id).await manager().request(&type_name, data).await
} }
#[tauri::command] #[tauri::command]

View file

@ -1,38 +0,0 @@
import {
scan,
Format,
requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@methanium/ui";
import { toast } from "@tensamin/shared/log";
export default function QrCodeScanner({
onData,
}: {
onData: (data: string) => void;
}) {
return (
<Button
onClick={() => {
requestPermissions()
.catch((err) => {
toast("error", err.message);
})
.then(() =>
scan({ windowed: false, formats: [Format.QRCode] })
.catch((err) => {
toast("error", err.message);
})
.then((data) => {
if (data) {
onData(data.content);
}
}),
);
}}
>
Open QR Code Scanner
</Button>
);
}

View file

@ -3,7 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" /> <link rel="icon" href="./favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Tensamin</title> <title>Tensamin</title>
</head> </head>
<body> <body>

View file

@ -8,7 +8,7 @@
"lint": "eslint src", "lint": "eslint src",
"dev": "vite", "dev": "vite",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -b && vite build", "build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build",
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
}, },
"dependencies": { "dependencies": {
@ -18,6 +18,7 @@
"@tanstack/react-router": "^1.170.21", "@tanstack/react-router": "^1.170.21",
"@tanstack/react-virtual": "^3.14.9", "@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tensamin/pwa": "workspace:*",
"@tensamin/cache": "workspace:*", "@tensamin/cache": "workspace:*",
"@tensamin/call": "workspace:*", "@tensamin/call": "workspace:*",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",
@ -54,6 +55,7 @@
"esbuild": "^0.28.1", "esbuild": "^0.28.1",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"globals": "^17.9.0", "globals": "^17.9.0",
"mtp": "*",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.66.0", "typescript-eslint": "^8.66.0",
"vite": "^8.2.1" "vite": "^8.2.1"

View file

@ -52,6 +52,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
data-pwa-navbar
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`} className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">

View file

@ -13,8 +13,11 @@ import {
useState, useState,
} from "react"; } from "react";
import { z } from "zod"; import { z } from "zod";
import { invoke, isTauri } from "@tauri-apps/api/core"; import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner"; import {
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
@ -35,50 +38,6 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92), mtp_keyring: z.string().min(1).max(92),
}); });
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, privateKeyValue] = rawFileContent.split("::");
const privateKey = privateKeyValue.trim();
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
return { userId, privateKey, domain };
}
export default function Form() { export default function Form() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null); const uploadRef = useRef<HTMLInputElement | null>(null);
@ -92,27 +51,12 @@ export default function Form() {
if (loginPendingRef.current) return false; if (loginPendingRef.current) return false;
loginPendingRef.current = true; loginPendingRef.current = true;
try { try {
if (domain) await save("omega_url", `https://${domain}/`); await persistMtpCredentials({
if (isTauri()) { storage: { load, save },
const [omegaUrl, forcedOmikronUrl, forcedOmikronPublicKey] =
await Promise.all([
domain ? `https://${domain}/` : load("omega_url"),
load("forced_omikron_url"),
load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId, userId,
keyring: privateKey, keyring: privateKey,
omegaUrl, domain,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
}); });
}
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
await navigate({ to: "/", replace: true }); await navigate({ to: "/", replace: true });
return true; return true;
} finally { } finally {
@ -143,6 +87,11 @@ export default function Form() {
[persistLogin], [persistLogin],
); );
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files // Handle .tu files
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
@ -281,32 +230,10 @@ export default function Form() {
return ( return (
<div className="relative flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauri() && isMobile ? ( {isMobile ? (
<>
<QrCodeScanner
onData={async (data) => {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
}
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await persistLogin(userId, privateKey, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}> <Button onClick={() => uploadRef.current?.click()}>
Select .tu file Select .tu file
</Button> </Button>
</>
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}

View file

@ -27,7 +27,6 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@methanium/ui"; } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar"; import { MobileNavbar } from "./navbar";
@ -194,11 +193,7 @@ export default function Sidebar() {
const content = ( const content = (
<> <>
<SidebarContent <SidebarContent className="pt-2">
className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
>
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!"> <div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div> <div>
<Wrapper <Wrapper
@ -309,7 +304,7 @@ export default function Sidebar() {
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground" className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar pt-[var(--ui-safe-area-top)] pr-[var(--ui-safe-area-right)] pl-[var(--ui-safe-area-left)] text-sidebar-foreground"
style={{ style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)", transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0, opacity: openMobile ? 1 : 0,

View file

@ -29,6 +29,7 @@ import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
@ -48,7 +49,6 @@ import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui"; import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { HotkeysProvider } from "@tensamin/hotkeys"; import { HotkeysProvider } from "@tensamin/hotkeys";
const wrapper = document.getElementById("root"); const wrapper = document.getElementById("root");
@ -254,19 +254,14 @@ function RootShell() {
parentThemeStorageKey={null} parentThemeStorageKey={null}
designStorageKey={null} designStorageKey={null}
> >
<div className="w-screen h-dvh overflow-hidden"> <div
<Toaster data-pwa-root
position={isMobile ? "top-center" : "bottom-right"} className="box-border w-screen h-dvh overflow-hidden"
{...(isTauri() && isMobile >
? { <Toaster position={isMobile ? "top-center" : "bottom-right"} />
mobileOffset: {
top: "env(safe-area-inset-top)",
},
}
: {})}
/>
<TooltipProvider> <TooltipProvider>
<Storage> <Storage>
<PwaRuntime />
<HotkeysProvider> <HotkeysProvider>
<ThemeStorageBridge /> <ThemeStorageBridge />
<LoginWrapper> <LoginWrapper>

View file

@ -19,6 +19,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<Sidebar /> <Sidebar />
<CallPopout /> <CallPopout />
<div <div
data-app-layout
// Background of ui that is overlapping with the system ui // Background of ui that is overlapping with the system ui
className={cn( className={cn(
"w-full h-full min-h-0 flex flex-col overflow-hidden", "w-full h-full min-h-0 flex flex-col overflow-hidden",

View file

@ -8,6 +8,7 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite"; import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
@ -125,6 +126,7 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }), methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),

145
flake.nix
View file

@ -17,8 +17,6 @@
systems = [ "x86_64-linux" ]; systems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems; forAllSystems = nixpkgs.lib.genAttrs systems;
version = "0.0.11"; version = "0.0.11";
x86_64DebHash = "sha256-E4FGMpCt2ByaN6d+YzglqeA6LbhMOQmPf3UzAj7uZXE=";
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
in in
{ {
packages = forAllSystems ( packages = forAllSystems (
@ -32,8 +30,6 @@
android_sdk.accept_license = true; android_sdk.accept_license = true;
}; };
}; };
debArtifact = "Tensamin-${version}-linux-amd64.deb";
electronRuntimeLibs = with pkgs; [ electronRuntimeLibs = with pkgs; [
alsa-lib alsa-lib
at-spi2-atk at-spi2-atk
@ -75,71 +71,130 @@
libXtst libXtst
libxcb libxcb
]; ];
packageDeb = electron = pkgs.electron;
src: pnpm = pkgs.pnpm;
pkgs.stdenv.mkDerivation { mtpTypeMaps = pkgs.fetchgit {
url = "https://git.methanium.net/tensamin/mtp-type-maps";
rev = "6e5122fe44f793c0e0d3229b3d34145ce17c2d31";
hash = "sha256-/4n8F0YLJaLncefKL907P5l+en1CTjpgdFmLBF3gbiQ=";
};
mtpSource = pkgs.fetchzip {
url = "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz";
hash = "sha256-XLTa8DxP93Q4hBHRCLUzCPOqkbdb4V3aZxE5Iuq+kW0=";
};
mtpCargoDeps = pkgs.rustPlatform.fetchCargoVendor {
src = mtpSource;
hash = "sha256-8MZ65N/EtWPAggal0JkGDx3WSn+LxWQORinkqVbsrys=";
};
wasmBindgenCliSource = pkgs.fetchCrate {
pname = "wasm-bindgen-cli";
version = "0.2.127";
hash = "sha256-di+qBAdd7pENLiIB9CoZoab+W5xeDoByMREcCGTSzWo=";
};
wasmBindgenCli = pkgs.buildWasmBindgenCli {
src = wasmBindgenCliSource;
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
src = wasmBindgenCliSource;
hash = "sha256-FTv2GZIAQs0ePdIZXIXil7JbZ6kIT05VG6vqC1qNFxQ=";
};
};
desktopItem = pkgs.makeDesktopItem {
name = "tensamin";
desktopName = "Tensamin";
exec = "tensamin";
icon = "tensamin";
startupWMClass = "Tensamin";
categories = [ "Network" ];
};
defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "tensamin"; pname = "tensamin";
inherit version src; inherit version;
src = self;
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
inherit pnpm;
fetcherVersion = 4;
hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg=";
};
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
autoPatchelfHook copyDesktopItems
dpkg
makeWrapper makeWrapper
nodejs_22
pnpm
pnpmConfigHook
cargo
lld
rustc
wasm-pack
wasmBindgenCli
binaryen
]; ];
buildInputs = electronRuntimeLibs;
dontConfigure = true; env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
dontBuild = true;
unpackPhase = '' postPatch = ''
runHook preUnpack rm -rf mtp-type-maps
dpkg-deb -x "$src" . ln -s ${mtpTypeMaps} mtp-type-maps
runHook postUnpack
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = "${version}";
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
'';
buildPhase = ''
runHook preBuild
mkdir -p "$HOME/.cargo"
substitute ${mtpCargoDeps}/.cargo/config.toml "$HOME/.cargo/config.toml" \
--replace-fail @vendor@ ${mtpCargoDeps}
pnpm run copy-licenses
pnpm run build:packages
pnpm run build:web
pnpm --dir apps/tauri run gen-icons
pnpm --dir apps/electron run build
pnpm --dir apps/electron exec electron-builder --dir --linux --publish never \
--config.electronDist=${electron.dist} \
--config.electronVersion=${electron.version}
runHook postBuild
''; '';
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
mkdir -p "$out" mkdir -p "$out/lib/tensamin" "$out/bin"
cp -r opt "$out/" cp -r apps/electron/release/linux-unpacked/. "$out/lib/tensamin/"
cp -r usr/* "$out/"
mkdir -p "$out/bin" makeWrapper "$out/lib/tensamin/tensamin" "$out/bin/tensamin" \
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}" --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
substituteInPlace "$out/share/applications/Tensamin.desktop" \ install -Dm644 apps/electron/build/icons/icon.png \
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin" "$out/share/icons/hicolor/512x512/apps/tensamin.png"
runHook postInstall runHook postInstall
''; '';
desktopItems = [ desktopItem ];
meta = {
description = "Tensamin desktop client";
homepage = "https://git.methanium.net/tensamin/client";
mainProgram = "tensamin";
platforms = pkgs.lib.platforms.linux;
}; };
defaultPackage = packageDeb ( });
pkgs.fetchurl {
url = "${forgejoBaseUrl}/${debArtifact}";
hash = x86_64DebHash;
}
);
localDebPath = builtins.getEnv "TENSAMIN_DEB";
localPathPackage =
if localDebPath == "" then
pkgs.writeShellScriptBin "tensamin" ''
echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2
exit 1
''
else
packageDeb (
builtins.path {
path = localDebPath;
name = debArtifact;
}
);
in in
{ {
default = defaultPackage; default = defaultPackage;
tensamin = defaultPackage; tensamin = defaultPackage;
electron = defaultPackage; electron = defaultPackage;
localPathForDev = localPathPackage;
} }
); );

View file

@ -1,20 +0,0 @@
SPDXVersion: SPDX-2.1
DataLicense: CC0-1.0
PackageName: tauri
DataFormat: SPDXRef-1
PackageSupplier: Organization: The Tauri Programme in the Commons Conservancy
PackageHomePage: https://tauri.app
PackageLicenseDeclared: Apache-2.0
PackageLicenseDeclared: MIT
PackageCopyrightText: 2019-2022, The Tauri Programme in the Commons Conservancy
PackageSummary: <text>Tauri is a rust project that enables developers to make secure
and small desktop applications using a web frontend.
</text>
PackageComment: <text>The package includes the following libraries; see
Relationship information.
</text>
Created: 2019-05-20T09:00:00Z
PackageDownloadLocation: git://github.com/tauri-apps/tauri
PackageDownloadLocation: git+https://github.com/tauri-apps/tauri.git
PackageDownloadLocation: git+ssh://github.com/tauri-apps/tauri.git
Creator: Person: Daniel Thompson-Yvetot

View file

@ -117,15 +117,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@tauri-apps_cli@2.11.4` - Folder: `licenses/@tauri-apps_cli@2.11.4`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/cli` - Source package dir: `apps/tauri/node_modules/@tauri-apps/cli`
## @tauri-apps/plugin-barcode-scanner@2.4.5
- License: MIT OR Apache-2.0
- Repository: https://github.com/tauri-apps/plugins-workspace
- Description: Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS
- Included files: LICENSE.spdx
- Folder: `licenses/@tauri-apps_plugin-barcode-scanner@2.4.5`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner`
## @tauri-apps/plugin-deep-link@2.4.9 ## @tauri-apps/plugin-deep-link@2.4.9
- License: MIT OR Apache-2.0 - License: MIT OR Apache-2.0

View file

@ -414,37 +414,6 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"purl": "pkg:npm/%40tauri-apps/plugin-barcode-scanner@2.4.5",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"licenses": [
{
"license": {
"name": "MIT OR Apache-2.0"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/tauri-apps/plugins-workspace"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5"
},
{
"name": "local:sourcePackageDir",
"value": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9", "bomRef": "pkg:npm/%40tauri-apps/plugin-deep-link@2.4.9",

View file

@ -160,19 +160,6 @@
"licenseFolder": "licenses/@tauri-apps_cli@2.11.4", "licenseFolder": "licenses/@tauri-apps_cli@2.11.4",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli" "sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/cli"
}, },
{
"name": "@tauri-apps/plugin-barcode-scanner",
"version": "2.4.5",
"license": "MIT OR Apache-2.0",
"homepage": null,
"repository": "https://github.com/tauri-apps/plugins-workspace",
"description": "Scan QR codes, EAN-13 and other kinds of barcodes on Android and iOS",
"files": [
"LICENSE.spdx"
],
"licenseFolder": "licenses/@tauri-apps_plugin-barcode-scanner@2.4.5",
"sourcePackageDir": "apps/tauri/node_modules/@tauri-apps/plugin-barcode-scanner"
},
{ {
"name": "@tauri-apps/plugin-deep-link", "name": "@tauri-apps/plugin-deep-link",
"version": "2.4.9", "version": "2.4.9",

@ -1 +1 @@
Subproject commit a297dcce60bc6e84696c6a16f5fd510beb2ca643 Subproject commit f4e45aa3a3ad0e3c3a257f66857b904a1af7901c

View file

@ -25,26 +25,27 @@
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile", "start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
"dev:desktop": "cd apps/electron && pnpm run dev", "dev:desktop": "cd apps/electron && pnpm run dev",
"build:desktop": "cd apps/electron && pnpm run package", "build:desktop": "cd apps/electron && pnpm run package",
"delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts" "delete:mobile": "nix develop .#tauri --command node apps/tauri/scripts/delete-mobile.ts",
"update-submodules": "git submodule update --remote --force --recursive"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/node": "^26.1.2", "@types/node": "^26.2.0",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@typescript-eslint/parser": "^8.66.0", "@typescript-eslint/parser": "^8.67.0",
"eslint": "^10.8.0", "eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"fallow": "^3.14.0", "fallow": "^3.17.0",
"globals": "^17.9.0", "globals": "^17.11.0",
"prettier": "^3.9.6", "prettier": "^3.9.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.66.0", "typescript-eslint": "^8.67.0",
"vitest": "^4.1.10" "vitest": "^4.1.11"
}, },
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz",
"mtp": "*", "mtp": "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz",
"sonner": "^2.0.7" "sonner": "^2.0.8"
} }
} }

View file

@ -12,8 +12,7 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "build": "tsc -p tsconfig.json --noEmit"
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",

View file

@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import {
replaceConversation,
selectConversationWindows,
trimMessages,
} from "./helpers";
import type { CachedMessage, ConversationWindow } from "./schemas";
const message = (SendTime: number): CachedMessage => ({
SenderId: 1,
SendTime,
Content: "Y2lwaGVydGV4dA==",
MessageState: "received",
});
const window = (UserId: number, LastMessageAt: number): ConversationWindow => ({
UserId,
LastMessageAt,
Messages: [],
});
describe("conversation cache helpers", () => {
it("selects the five most recent windows", () => {
const selected = selectConversationWindows(
[
window(1, 1),
window(2, 6),
window(3, 3),
window(4, 4),
window(5, 5),
window(6, 2),
],
5,
);
expect(selected.map(({ UserId }) => UserId)).toEqual([2, 5, 4, 3, 6]);
});
it("replaces only the matching conversation", () => {
expect(
replaceConversation([window(1, 1), window(2, 2)], window(1, 9)),
).toEqual([window(1, 9), window(2, 2)]);
});
it("retains the newest messages in chronological order", () => {
expect(
trimMessages([message(2), message(3), message(1)], 2).map(
(item) => item.SendTime,
),
).toEqual([2, 3]);
});
});

View file

@ -21,8 +21,7 @@ export function removeMissingContactSnapshots<T extends { UserId: number }>(
} }
export default function CacheSync() { export default function CacheSync() {
const { addInterceptor, contextReady, freshContacts, subscribePush } = const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP();
useMTP();
const { load } = useStorage(); const { load } = useStorage();
const [accountId, setAccountId] = useState(0); const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve()); const queueRef = useRef(Promise.resolve());
@ -321,10 +320,19 @@ export default function CacheSync() {
useEffect(() => { useEffect(() => {
if (!accountId || !contextReady) return; if (!accountId || !contextReady) return;
return subscribePush((message) => { const handleMessage = (message: ProtocolMessage) => {
void enqueue(() => synchronizePush(message)); void enqueue(() => synchronizePush(message));
}); };
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]); const unsubscribers = [
subscribe("GetStates", handleMessage),
subscribe("MessageLive", handleMessage),
subscribe("MessageEditLive", handleMessage),
subscribe("MessageDeleteLive", handleMessage),
subscribe("MessageState", handleMessage),
subscribe("MessageReactionLive", handleMessage),
];
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
}, [accountId, contextReady, enqueue, subscribe, synchronizePush]);
return null; return null;
} }

View file

@ -28,6 +28,7 @@
"deepfilternet3-noise-filter": "1.3.0", "deepfilternet3-noise-filter": "1.3.0",
"livekit-client": "^2.21.0", "livekit-client": "^2.21.0",
"lucide-react": "^1.29.0", "lucide-react": "^1.29.0",
"mtp": "*",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"recharts": "^3.10.1", "recharts": "^3.10.1",

View file

@ -19,7 +19,6 @@ export function getMediaShareAdapter(): MediaShareAdapter {
export type { export type {
MediaShareAdapter, MediaShareAdapter,
MediaShareCapabilities,
MediaShareKind, MediaShareKind,
MediaShareRequest, MediaShareRequest,
MediaShareSession, MediaShareSession,

View file

@ -1223,7 +1223,7 @@ export const useCall = create<{
export function useInitializeCall() { export function useInitializeCall() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { send, subscribePush } = useMTP(); const { send, subscribe } = useMTP();
const { load } = useStorage(); const { load } = useStorage();
const { insertCall } = useSession(); const { insertCall } = useSession();
const { get } = useUser(); const { get } = useUser();
@ -1313,14 +1313,9 @@ export function useInitializeCall() {
// listen to call invites // listen to call invites
useEffect(() => { useEffect(() => {
return subscribePush(async (message) => { return subscribe("CallInvite", async ({ data }) => {
if (message.type !== "CallInvite") return; const { CallId, CallSecret, SenderId } = data;
if (!CallId || !CallSecret || !SenderId) return;
const { CallId, CallSecret, SenderId } = message.data as {
CallId: string;
CallSecret: ProtocolCallSecret;
SenderId: number;
};
if (SenderId === Number(await load("user_id"))) { if (SenderId === Number(await load("user_id"))) {
return; return;
@ -1337,7 +1332,7 @@ export function useInitializeCall() {
SenderId, SenderId,
); );
}); });
}, [load, subscribePush, showCallingScreen]); }, [load, subscribe, showCallingScreen]);
// get callId from url // get callId from url
useEffect(() => { useEffect(() => {

View file

@ -2,6 +2,9 @@ import { Input, type InputController } from "@methanium/ui/markdown";
import { import {
Card, Card,
CardHeader, CardHeader,
Drawer,
DrawerContent,
DrawerTrigger,
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
@ -331,11 +334,33 @@ export default function InputComponent({
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{isMobile ? (
<Drawer open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
<DrawerTrigger asChild>
<div>
<Button className="w-9 h-9! p-0" variant="ghost">
<FileVideo size={20} />
</Button>
</div>
</DrawerTrigger>
<DrawerContent className="h-[80dvh]">
<div className="min-h-0 flex-1 p-3">
<GifPicker
onSelect={(url) => {
void handleSubmit(url, true);
setGifPopoverOpen(false);
}}
/>
</div>
</DrawerContent>
</Drawer>
) : (
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}> <Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
<PopoverTrigger <PopoverTrigger
render={({ onClick }) => ( render={({ onClick }) => (
<Button <Button
onClick={onClick} onClick={onClick}
aria-label="Open GIF picker"
className="w-9 h-9! p-0" className="w-9 h-9! p-0"
variant="ghost" variant="ghost"
> >
@ -367,6 +392,7 @@ export default function InputComponent({
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>

View file

@ -166,9 +166,9 @@ export default function Media({
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
onWheel={handleWheel} onWheel={handleWheel}
className="bg-black/50 border-0! w-screen! h-screen! max-w-screen! rounded-none! p-0!" className="inset-0! h-dvh! w-screen! max-h-none! max-w-none! translate-none! overflow-hidden! rounded-none! border-0! bg-black/50 p-0!"
> >
<div className="relative z-10 flex w-full h-full justify-between p-7 items-start pointer-events-none"> <div className="pointer-events-none relative z-10 flex h-full w-full items-start justify-between pt-[max(1.75rem,var(--ui-safe-area-top))] pr-[max(1.75rem,var(--ui-safe-area-right))] pb-[max(1.75rem,var(--ui-safe-area-bottom))] pl-[max(1.75rem,var(--ui-safe-area-left))]">
<div <div
className={`flex gap-2 items-center transition-opacity duration-200 ${zoomLevel === 0 ? "opacity-100" : "pointer-events-none opacity-0"}`} className={`flex gap-2 items-center transition-opacity duration-200 ${zoomLevel === 0 ? "opacity-100" : "pointer-events-none opacity-0"}`}
inert={zoomLevel !== 0} inert={zoomLevel !== 0}

View file

@ -226,7 +226,7 @@ export async function fetchReplyMessage({
export default function Provider({ children }: { children: ReactNode }) { export default function Provider({ children }: { children: ReactNode }) {
const { load } = useStorage(); const { load } = useStorage();
const { send, subscribePush } = useMTP(); const { send, subscribe } = useMTP();
const { get: getUser } = useUser(); const { get: getUser } = useUser();
const { moveUserIdToTop } = useSession(); const { moveUserIdToTop } = useSession();
@ -912,30 +912,9 @@ export default function Provider({ children }: { children: ReactNode }) {
// Get live updates for message states // Get live updates for message states
useEffect(() => { useEffect(() => {
return subscribePush((message) => { const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => {
if (message.type === "MessageEditLive") {
if (!currentChatSecret) return; if (!currentChatSecret) return;
if (data.ChatPartnerId !== userIdValue) {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Content: string;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) {
log(
3,
"chat",
"yellow",
"Cancel message edit update due to invalid data",
);
return;
}
if (chatPartnerId !== userIdValue) {
log( log(
3, 3,
"chat", "chat",
@ -943,93 +922,37 @@ export default function Provider({ children }: { children: ReactNode }) {
"Cancel message edit update due to user ID mismatch", "Cancel message edit update due to user ID mismatch",
{ {
expected: userIdValue, expected: userIdValue,
received: chatPartnerId, received: data.ChatPartnerId,
}, },
); );
return; return;
} }
void decryptChatText(currentChatSecret, rawData.Content) void decryptChatText(currentChatSecret, data.Content)
.then((content) => { .then((content) => {
editMessage(sendTime, { Content: content, Edited: true }); editMessage(data.SendTime, { Content: content, Edited: true });
}) })
.catch((err) => { .catch((err) => {
log(1, "chat", "red", "Failed to decrypt message edit", err, { log(1, "chat", "red", "Failed to decrypt message edit", err, {
SendTime: sendTime, SendTime: data.SendTime,
}); });
}); });
return; });
} const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => {
if (message.type === "MessageReactionLive") {
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
Reaction: string;
SenderId: unknown;
Accepted: boolean;
};
const chatPartnerId = Number(rawData.ChatPartnerId);
const sendTime = Number(rawData.SendTime);
const senderId = Number(rawData.SenderId);
if (
chatPartnerId !== userIdValue ||
!Number.isFinite(sendTime) ||
!Number.isFinite(senderId)
) {
return;
}
applyLiveReaction(
sendTime,
rawData.Reaction,
senderId,
rawData.Accepted,
);
return;
}
if (message.type === "MessageDeleteLive") {
const data = message.data as {
ChatPartnerId: number;
SendTime: number;
};
if (data.ChatPartnerId !== userIdValue) return; if (data.ChatPartnerId !== userIdValue) return;
applyLiveReaction(
removeMessage(data.SendTime); data.SendTime,
return; data.Reaction,
} data.SenderId,
data.Accepted,
if (message.type !== "MessageState") return;
const rawData = message.data as {
ChatPartnerId: unknown;
SendTime: unknown;
MessageState: RawMessage["MessageState"];
};
const nextState = {
ChatPartnerId: Number(rawData.ChatPartnerId),
SendTime: Number(rawData.SendTime),
MessageState: rawData.MessageState,
};
if (
!Number.isFinite(nextState.ChatPartnerId) ||
!Number.isFinite(nextState.SendTime)
) {
log(
3,
"chat",
"yellow",
"Cancel message state update due to invalid data",
); );
return; });
} const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) return;
if (nextState.ChatPartnerId !== userIdValue) { removeMessage(data.SendTime);
});
const unsubscribeState = subscribe("MessageState", ({ data }) => {
if (data.ChatPartnerId !== userIdValue) {
log( log(
3, 3,
"chat", "chat",
@ -1037,22 +960,27 @@ export default function Provider({ children }: { children: ReactNode }) {
"Cancel message state update due to user ID mismatch", "Cancel message state update due to user ID mismatch",
{ {
expected: userIdValue, expected: userIdValue,
received: nextState.ChatPartnerId, received: data.ChatPartnerId,
}, },
); );
return; return;
} }
editMessage(data.SendTime, {
editMessage(nextState.SendTime, { MessageState: data.MessageState,
MessageState: nextState.MessageState,
}); });
}); });
return () => {
unsubscribeEdit();
unsubscribeReaction();
unsubscribeDelete();
unsubscribeState();
};
}, [ }, [
currentChatSecret, currentChatSecret,
applyLiveReaction, applyLiveReaction,
editMessage, editMessage,
removeMessage, removeMessage,
subscribePush, subscribe,
userIdValue, userIdValue,
]); ]);

View file

@ -12,9 +12,10 @@
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"test": "vitest run", "test": "vitest run",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit" "build": "tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"mtp": "*",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
} }

View file

@ -13,11 +13,13 @@ export function deriveCallSecretId(callId: string): string {
} }
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
return crypto.keyringToKeys(keyring).kemPublicKey; return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
.kemPublicKey;
} }
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
.kemPublicKey;
} }
export async function wrapCallSecret(args: { export async function wrapCallSecret(args: {
@ -72,7 +74,10 @@ export async function unwrapCallSecret(args: {
); );
} }
const ownKeys = crypto.keyringToKeys(args.keyring); const ownKeys = crypto.keyringToKeys({
value: args.keyring,
encoding: "base64",
});
const sharedSecret = crypto.decapsulate( const sharedSecret = crypto.decapsulate(
ownKeys.kemSecretKey, ownKeys.kemSecretKey,
args.kemCiphertext, args.kemCiphertext,

View file

@ -23,11 +23,13 @@ export function randomChatSecret(): Uint8Array {
} }
export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array { export function ownKemPublicKeyFromKeyring(keyring: string): Uint8Array {
return crypto.keyringToKeys(keyring).kemPublicKey; return crypto.keyringToKeys({ value: keyring, encoding: "base64" })
.kemPublicKey;
} }
export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array { export function kemPublicKeyFromPublicKeyBundle(publicKey: string): Uint8Array {
return crypto.publicKeyBundleToKeys(publicKey).kemPublicKey; return crypto.publicKeyBundleToKeys({ value: publicKey, encoding: "base64" })
.kemPublicKey;
} }
export async function wrapChatSecret(args: { export async function wrapChatSecret(args: {
@ -79,7 +81,10 @@ export async function unwrapChatSecret(args: {
); );
} }
const ownKeys = crypto.keyringToKeys(args.keyring); const ownKeys = crypto.keyringToKeys({
value: args.keyring,
encoding: "base64",
});
const sharedSecret = crypto.decapsulate( const sharedSecret = crypto.decapsulate(
ownKeys.kemSecretKey, ownKeys.kemSecretKey,
args.kemCiphertext, args.kemCiphertext,

View file

@ -1,65 +0,0 @@
import { describe, expect, test, vi } from "vitest";
vi.mock("mtp", () => ({
crypto: {},
}));
import { createCryptoActions } from "./context";
/**
* Creates a rejected API getter used to verify initialization guards.
* @returns Null API reference.
*/
function getUninitializedApi(): null {
return null;
}
describe("createCryptoActions", () => {
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
test("throws when API is not initialized", async () => {
const actions = createCryptoActions(getUninitializedApi);
let failed = false;
try {
await actions.encrypt("ab", new TextEncoder().encode("plain"));
} catch (error) {
failed = (error as Error).message.includes("API not initialized");
}
expect(failed).toBe(true);
});
test("delegates encrypt/decrypt/getSharedSecret to API reference", async () => {
const api = {
encrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}:${textDecoder.decode(input)}`),
decrypt: async (
secret: string,
input: Uint8Array<ArrayBuffer>,
): Promise<Uint8Array<ArrayBuffer>> =>
textEncoder.encode(`${secret}|${textDecoder.decode(input)}`),
encryptText: async (secret: string, plaintext: string): Promise<string> =>
`${secret}:${plaintext}`,
decryptText: async (
secret: string,
ciphertext: string,
): Promise<string> => `${secret}|${ciphertext}`,
};
const actions = createCryptoActions(() => api);
expect(
textDecoder.decode(await actions.encrypt("s", textEncoder.encode("p"))),
).toBe("s:p");
expect(
textDecoder.decode(await actions.decrypt("s", textEncoder.encode("c"))),
).toBe("s|c");
expect(await actions.encryptText("s", "p")).toBe("s:p");
expect(await actions.decryptText("s", "c")).toBe("s|c");
});
});

View file

@ -0,0 +1,30 @@
{
"name": "@tensamin/markdown",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
"./text": "./src/text.tsx",
"./input": "./src/input.tsx",
"./emoji": "./src/emoji.tsx"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@methanium/ui": "*",
"@twemoji/api": "^17.0.3",
"emojibase-data": "^17.0.0",
"lucide-react": "^1.30.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}

View file

@ -0,0 +1,50 @@
import twemoji from "@twemoji/api";
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
import { resolveEmoji } from "./emojiData";
export {
emojis,
findEmojiShortcodes,
normalizeShortcode,
resolveEmoji,
searchEmojis,
} from "./emojiData";
export type { EmojiDefinition } from "./emojiData";
export function getEmojiUrl(shortcode: string): string | undefined {
const emoji = resolveEmoji(shortcode);
return emoji ? `${twemoji.base}svg/${emoji.hexcode}.svg` : undefined;
}
export default function Emoji({
className = "h-6 w-6",
shortcode,
tooltip = true,
}: {
className?: string;
shortcode: string;
tooltip?: boolean;
}) {
const emoji = resolveEmoji(shortcode);
if (!emoji) return <span>{shortcode}</span>;
const image = (
<img
alt={emoji.shortcode}
className={className}
decoding="async"
draggable={false}
loading="lazy"
src={`${twemoji.base}svg/${emoji.hexcode}.svg`}
/>
);
if (!tooltip) return image;
return (
<Tooltip>
<TooltipTrigger render={image} />
<TooltipContent sideOffset={8}>{emoji.shortcode}</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,94 @@
import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json";
export type EmojiDefinition = {
aliases: readonly string[];
hexcode: string;
name: string;
shortcode: string;
};
function normalizeName(value: string) {
return value
.trim()
.replace(/^:+|:+$/g, "")
.toLowerCase();
}
export const emojis: readonly EmojiDefinition[] = Object.entries(
shortcodeData as Record<string, string | string[]>,
).map(([hexcode, value]) => {
const aliases = Array.isArray(value) ? value : [value];
const name = aliases[0];
return {
aliases,
hexcode: hexcode.toLowerCase().replaceAll("_", "-"),
name,
shortcode: `:${name}:`,
};
});
const emojiByName = new Map<string, EmojiDefinition>();
for (const emoji of emojis) {
for (const alias of emoji.aliases) {
emojiByName.set(normalizeName(alias), emoji);
}
}
export function resolveEmoji(value: string): EmojiDefinition | undefined {
return emojiByName.get(normalizeName(value));
}
export function normalizeShortcode(value: string): string | undefined {
return resolveEmoji(value)?.shortcode;
}
export function findEmojiShortcodes(value: string) {
const matches: Array<{
emoji: EmojiDefinition;
from: number;
to: number;
}> = [];
let searchFrom = 0;
while (searchFrom < value.length) {
const from = value.indexOf(":", searchFrom);
if (from === -1) break;
const candidate = value.slice(from).match(/^:([a-z0-9_+-]+):/i);
if (!candidate) {
searchFrom = from + 1;
continue;
}
const emoji = resolveEmoji(candidate[1]);
if (!emoji) {
// The closing colon may also open the next valid shortcode.
searchFrom = from + candidate[0].length - 1;
continue;
}
const to = from + candidate[0].length;
matches.push({ emoji, from, to });
searchFrom = to;
}
return matches;
}
export function searchEmojis(query: string): EmojiDefinition[] {
const normalizedQuery = normalizeName(query);
if (!normalizedQuery) return [...emojis];
return emojis
.map((emoji) => {
const names = emoji.aliases.map(normalizeName);
const exact = names.includes(normalizedQuery);
const prefix = names.some((name) => name.startsWith(normalizedQuery));
const contains = names.some((name) => name.includes(normalizedQuery));
return { emoji, rank: exact ? 0 : prefix ? 1 : contains ? 2 : 3 };
})
.filter(({ rank }) => rank < 3)
.sort((a, b) => a.rank - b.rank || a.emoji.name.localeCompare(b.emoji.name))
.map(({ emoji }) => emoji);
}

View file

@ -0,0 +1,850 @@
import { markdown } from "@codemirror/lang-markdown";
import { syntaxTree } from "@codemirror/language";
import {
acceptCompletion,
autocompletion,
completionStatus,
pickedCompletion,
startCompletion,
type Completion,
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import {
EditorState,
EditorSelection,
Annotation,
Compartment,
Prec,
Transaction,
type Extension,
type Range,
type SelectionRange,
} from "@codemirror/state";
import {
Decoration,
EditorView,
keymap,
placeholder,
ViewPlugin,
WidgetType,
type DecorationSet,
type KeyBinding,
type ViewUpdate,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import { createRoot, type Root } from "react-dom/client";
import { collectInlineRanges, ensureMarkdownStyles } from "./markdown";
import Emoji, {
findEmojiShortcodes,
getEmojiUrl,
resolveEmoji,
searchEmojis,
} from "./emoji";
export const MAX_RENDERED_EMOJI_OPTIONS = 100;
export type InputController = {
focus: () => void;
hasFocus: () => boolean;
insertText: (text: string) => void;
};
export type InputProps = {
ref?: HTMLDivElement;
placeholder?: string;
value: string;
setValue: (value: string) => void;
onSubmit?: () => void;
invertEnterBehavior?: boolean;
styled?: boolean;
fontSize?: CSSProperties["fontSize"];
paddingX?: CSSProperties["padding"];
paddingY?: CSSProperties["padding"];
className?: string;
emojiFrequencies?: Readonly<Record<string, number>>;
onEmojiSelect?: (shortcode: string) => void;
autoFocus?: boolean;
onControllerChange?: (controller: InputController | null) => void;
};
function toCssLength(value: CSSProperties["padding"]): string | undefined {
if (value === undefined) {
return undefined;
}
return typeof value === "number" ? `${value}px` : value;
}
function toCssPadding(
vertical: CSSProperties["padding"],
horizontal: CSSProperties["padding"],
styled: boolean,
): string {
const defaultVertical = styled ? "0.25rem" : "0";
const defaultHorizontal = styled ? "0.625rem" : "0";
return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`;
}
const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" });
const strongDecoration = Decoration.mark({ class: "tm-md-strong" });
const emDecoration = Decoration.mark({ class: "tm-md-em" });
const delDecoration = Decoration.mark({ class: "tm-md-del" });
const codeDecoration = Decoration.mark({ class: "tm-md-code" });
const linkDecoration = Decoration.mark({ class: "tm-md-link" });
const codeLineDecoration = Decoration.line({ class: "tm-md-code-line" });
const externalValueSync = Annotation.define<boolean>();
const widgetRoots = new WeakMap<HTMLElement, Root>();
type EmojiRange = {
from: number;
shortcode: string;
to: number;
url: string;
};
class EmojiWidget extends WidgetType {
readonly shortcode: string;
readonly url: string;
constructor(shortcode: string, url: string) {
super();
this.shortcode = shortcode;
this.url = url;
}
eq(other: EmojiWidget) {
return other.shortcode === this.shortcode && other.url === this.url;
}
toDOM() {
const container = document.createElement("span");
const root = createRoot(container);
root.render(
<Emoji className="tm-md-editor-emoji" shortcode={this.shortcode} />,
);
widgetRoots.set(container, root);
return container;
}
destroy(dom: HTMLElement) {
widgetRoots.get(dom)?.unmount();
widgetRoots.delete(dom);
}
ignoreEvent() {
return true;
}
}
function codeRanges(state: EditorState) {
const ranges: Array<{ from: number; to: number }> = [];
syntaxTree(state).iterate({
enter(node) {
if (
node.name === "InlineCode" ||
node.name === "FencedCode" ||
node.name === "CodeBlock"
) {
ranges.push({ from: node.from, to: node.to });
return false;
}
},
});
return ranges;
}
export function findEmojiRanges(state: EditorState): EmojiRange[] {
const document = state.doc.toString();
const excluded = codeRanges(state);
const ranges: EmojiRange[] = [];
for (const match of findEmojiShortcodes(document)) {
const { from, to } = match;
const inCode = excluded.some((range) => from < range.to && to > range.from);
const emoji = inCode ? undefined : match.emoji;
const url = emoji ? getEmojiUrl(emoji.shortcode) : undefined;
if (emoji && url) {
ranges.push({ from, shortcode: emoji.shortcode, to, url });
}
}
return ranges;
}
class EmojiPluginValue {
decorations: DecorationSet;
ranges: EmojiRange[];
constructor(view: EditorView) {
this.ranges = findEmojiRanges(view.state);
this.decorations = this.buildDecorations();
}
update(update: ViewUpdate) {
if (
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.ranges = findEmojiRanges(update.state);
this.decorations = this.buildDecorations();
}
}
private buildDecorations() {
return Decoration.set(
this.ranges.map((range) =>
Decoration.replace({
inclusive: false,
widget: new EmojiWidget(range.shortcode, range.url),
}).range(range.from, range.to),
),
true,
);
}
}
const emojiDecorations = ViewPlugin.fromClass(EmojiPluginValue, {
decorations: (instance) => instance.decorations,
provide: (plugin) =>
EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
),
});
function deleteEmoji(view: EditorView, direction: "backward" | "forward") {
const ranges = view.plugin(emojiDecorations)?.ranges ?? [];
const deletions: Array<{ from: number; to: number }> = [];
for (const selection of view.state.selection.ranges) {
if (selection.empty) {
const emoji = ranges.find((range) =>
direction === "backward"
? selection.from > range.from && selection.from <= range.to
: selection.from >= range.from && selection.from < range.to,
);
if (emoji) deletions.push({ from: emoji.from, to: emoji.to });
continue;
}
let from = selection.from;
let to = selection.to;
let changed = false;
for (const emoji of ranges) {
if (from < emoji.to && to > emoji.from) {
from = Math.min(from, emoji.from);
to = Math.max(to, emoji.to);
changed = true;
}
}
if (changed) deletions.push({ from, to });
}
if (deletions.length === 0) return false;
const merged = deletions
.sort((a, b) => a.from - b.from)
.reduce<Array<{ from: number; to: number }>>((result, deletion) => {
const previous = result.at(-1);
if (previous && deletion.from <= previous.to) {
previous.to = Math.max(previous.to, deletion.to);
} else {
result.push({ ...deletion });
}
return result;
}, []);
view.dispatch({
changes: merged.map((range) => ({ from: range.from, to: range.to })),
selection: EditorSelection.cursor(merged[0].from),
scrollIntoView: true,
userEvent: direction === "backward" ? "delete.backward" : "delete.forward",
});
return true;
}
/**
* Builds markdown styling decorations every time the document or cursor selection changes.
* Token delimiters are hidden unless the cursor is currently intersecting that token range.
*/
const markdownDecorations = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{
decorations: (instance: { decorations: DecorationSet }) =>
instance.decorations,
},
);
/**
* Executes Input.
* @param props Parameter props.
* @returns unknown.
*/
export default function Input(props: InputProps) {
ensureMarkdownStyles();
const shellClassName = props.styled
? "min-h-8 w-full min-w-0 rounded-lg border border-input bg-transparent text-base transition-colors outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
: "";
const elementRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | undefined>(undefined);
const setValueRef = useRef(props.setValue);
const onSubmitRef = useRef<InputProps["onSubmit"]>(props.onSubmit);
const onEmojiSelectRef = useRef<InputProps["onEmojiSelect"]>(
props.onEmojiSelect,
);
const invertEnterBehaviorRef = useRef(Boolean(props.invertEnterBehavior));
const completionCompartmentRef = useRef<Compartment | null>(null);
completionCompartmentRef.current ??= new Compartment();
const completionCompartment = completionCompartmentRef.current;
useEffect(() => {
setValueRef.current = props.setValue;
onSubmitRef.current = props.onSubmit;
onEmojiSelectRef.current = props.onEmojiSelect;
invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior);
}, [
props.onEmojiSelect,
props.onSubmit,
props.invertEnterBehavior,
props.setValue,
]);
useEffect(() => {
if (!elementRef.current) return;
const state = EditorState.create({
doc: props.value,
extensions: createEditorExtensions(
(value) => {
setValueRef.current(value);
},
() => props.placeholder,
() => invertEnterBehaviorRef.current,
() => onSubmitRef.current?.(),
completionCompartment,
props.emojiFrequencies,
(shortcode) => onEmojiSelectRef.current?.(shortcode),
),
});
viewRef.current = new EditorView({
state,
parent: elementRef.current,
});
props.onControllerChange?.({
focus: () => viewRef.current?.contentDOM.focus({ preventScroll: true }),
hasFocus: () => viewRef.current?.hasFocus ?? false,
insertText: (text) => {
const editor = viewRef.current;
if (!editor) return;
editor.dispatch({
...editor.state.replaceSelection(text),
annotations: Transaction.userEvent.of("input.type"),
scrollIntoView: true,
});
},
});
if (props.autoFocus) viewRef.current.focus();
return () => {
props.onControllerChange?.(null);
viewRef.current?.destroy();
viewRef.current = undefined;
};
// Run once to initialize/destroy the editor instance.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const editor = viewRef.current;
if (!editor) return;
const next = props.value;
const current = editor.state.doc.toString();
if (next === current) return;
editor.dispatch({
changes: {
from: 0,
to: current.length,
insert: next,
},
annotations: [
externalValueSync.of(true),
Transaction.addToHistory.of(false),
],
filter: false,
});
}, [props.value]);
useEffect(() => {
const editor = viewRef.current;
const compartment = completionCompartmentRef.current;
if (!editor || !compartment) return;
const wasActive = completionStatus(editor.state) === "active";
editor.dispatch({
effects: compartment.reconfigure(
createEmojiAutocomplete(props.emojiFrequencies, (shortcode) =>
onEmojiSelectRef.current?.(shortcode),
),
),
});
if (wasActive) startCompletion(editor);
}, [props.emojiFrequencies]);
return (
<div
ref={elementRef}
className={`tm-md-root ${shellClassName} ${props.className ?? ""}`}
style={
{
fontSize: props.fontSize ?? "1rem",
"--tm-md-content-padding": toCssPadding(
props.paddingY,
props.paddingX,
Boolean(props.styled),
),
} as CSSProperties & {
"--tm-md-content-padding"?: string;
}
}
/>
);
}
/**
* Executes createEditorExtensions.
* @param onChange Parameter onChange.
* @param getPlaceholder Parameter getPlaceholder.
* @param getInvertEnterBehavior Parameter getInvertEnterBehavior.
* @param onSubmit Parameter onSubmit.
* @returns Extension[].
*/
function createEditorExtensions(
onChange: (value: string) => void,
getPlaceholder: () => string | undefined,
getInvertEnterBehavior: () => boolean,
onSubmit: () => void,
completionCompartment: Compartment,
emojiFrequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
): Extension[] {
const editorKeymap = [
...defaultKeymap,
...historyKeymap,
indentWithTab,
] as unknown as readonly KeyBinding[];
const customEnterKeymap = keymap.of([
{
key: "Shift-Enter",
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (!getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
{
key: "Enter",
run: (view) => {
if (completionStatus(view.state) === "active") {
return acceptCompletion(view);
}
if (getInvertEnterBehavior()) {
return false;
}
onSubmit();
return true;
},
},
]);
const completionTabKeymap = keymap.of([
{
key: "Tab",
run: (view) =>
completionStatus(view.state) === "active"
? acceptCompletion(view)
: false,
},
]);
const emojiDeletionKeymap = keymap.of([
{
key: "Backspace",
run: (view) => deleteEmoji(view, "backward"),
},
{
key: "Delete",
run: (view) => deleteEmoji(view, "forward"),
},
]);
return [
history(),
markdown(),
completionCompartment.of(
createEmojiAutocomplete(emojiFrequencies, onEmojiSelect),
),
emojiDecorations,
keymap.of(editorKeymap),
Prec.highest(completionTabKeymap),
Prec.highest(emojiDeletionKeymap),
Prec.highest(customEnterKeymap),
EditorView.lineWrapping,
placeholder(getPlaceholder() ?? ""),
EditorView.updateListener.of((update: ViewUpdate) => {
if (!update.docChanged) return;
if (
update.transactions.some(
(transaction) => transaction.annotation(externalValueSync) === true,
)
) {
return;
}
onChange(update.state.doc.toString());
}),
EditorView.theme({
"&": {
fontSize: "inherit",
},
"&.cm-editor": {
width: "100%",
},
}),
EditorView.editorAttributes.of({
class: "tm-md-editor",
spellcheck: "true",
"aria-label": "Markdown input",
}),
markdownDecorations,
];
}
function createEmojiAutocomplete(
frequencies: Readonly<Record<string, number>> | undefined,
onEmojiSelect: (shortcode: string) => void,
) {
return autocompletion({
activateOnTyping: true,
addToOptions: [
{
position: 20,
render(completion) {
const container = document.createElement("span");
createRoot(container).render(
<Emoji
className="tm-md-completion-emoji"
shortcode={completion.label}
/>,
);
return container;
},
},
],
maxRenderedOptions: MAX_RENDERED_EMOJI_OPTIONS,
override: [createEmojiCompletionSource(frequencies, onEmojiSelect)],
});
}
function normalizedFrequencies(
frequencies: Readonly<Record<string, number>> | undefined,
) {
const normalized = new Map<string, number>();
for (const [value, frequency] of Object.entries(frequencies ?? {})) {
const shortcode = resolveEmoji(value)?.shortcode;
if (!shortcode || !Number.isFinite(frequency) || frequency <= 0) continue;
normalized.set(shortcode, (normalized.get(shortcode) ?? 0) + frequency);
}
return normalized;
}
export function createEmojiCompletionSource(
frequencies?: Readonly<Record<string, number>>,
onEmojiSelect: (shortcode: string) => void = () => undefined,
) {
const normalized = normalizedFrequencies(frequencies);
const maxFrequency = Math.max(0, ...normalized.values());
return (context: CompletionContext): CompletionResult | null => {
const token = context.matchBefore(/:[a-z0-9_+-]*$/i);
if (!token) return null;
if (
codeRanges(context.state).some(
(range) => token.from < range.to && token.to > range.from,
)
) {
return null;
}
const characterBefore = context.state.sliceDoc(
Math.max(0, token.from - 1),
token.from,
);
if (characterBefore && /[a-z0-9_]/i.test(characterBefore)) return null;
const query = token.text.slice(1).toLowerCase();
const options: Completion[] = searchEmojis(query)
.map((emoji) => {
const aliases = emoji.aliases.map((alias) => alias.toLowerCase());
const matchedAlias =
aliases.find((alias) => alias === query) ??
aliases.find((alias) => alias.startsWith(query)) ??
aliases.find((alias) => alias.includes(query)) ??
emoji.name;
const relevance = !query
? 0
: matchedAlias === query
? 80
: matchedAlias.startsWith(query)
? 40
: 0;
const frequency = normalized.get(emoji.shortcode) ?? 0;
const usage =
maxFrequency > 0
? (15 * Math.log1p(frequency)) / Math.log1p(maxFrequency)
: 0;
return {
apply(view, completion, from, to) {
view.dispatch({
annotations: pickedCompletion.of(completion),
changes: { from, insert: `${emoji.shortcode} `, to },
selection: EditorSelection.cursor(
from + emoji.shortcode.length + 1,
),
});
onEmojiSelect(emoji.shortcode);
},
boost: relevance + usage,
displayLabel: emoji.shortcode,
label: `:${matchedAlias}:`,
type: "text",
frequency,
relevance,
} satisfies Completion & { frequency: number; relevance: number };
})
.sort(
(a, b) =>
b.relevance - a.relevance ||
b.frequency - a.frequency ||
(a.displayLabel ?? a.label).localeCompare(b.displayLabel ?? b.label),
);
return {
from: token.from,
options,
validFor: /^:[a-z0-9_+-]*$/i,
};
};
}
export const emojiCompletionSource = createEmojiCompletionSource();
/**
* Executes buildDecorations.
* @param view Parameter view.
* @returns DecorationSet.
*/
function buildDecorations(view: EditorView): DecorationSet {
const builder: Range<Decoration>[] = [];
const selections = view.state.selection.ranges.map(
(range: SelectionRange) => ({
from: range.from,
to: range.to,
}),
);
let codeFenceOpen = false;
for (
let lineNumber = 1;
lineNumber <= view.state.doc.lines;
lineNumber += 1
) {
const line = view.state.doc.line(lineNumber);
const text = line.text;
const lineFrom = line.from;
const trimmed = text.trim();
const fence = text.match(/^```\s*([^`]*)$/);
if (fence) {
const ticksStart = lineFrom + text.indexOf("```");
const ticksEnd = ticksStart + 3;
addHiddenToken(builder, selections, { from: ticksStart, to: ticksEnd });
if (trimmed.length > 3) {
addHiddenToken(builder, selections, {
from: ticksEnd,
to: line.to,
});
}
codeFenceOpen = !codeFenceOpen;
continue;
}
if (codeFenceOpen) {
builder.push(codeLineDecoration.range(lineFrom));
continue;
}
const heading = text.match(/^(#{1,6})\s+/);
if (heading) {
const markerLength = heading[0].length;
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + markerLength,
});
const level = heading[1].length;
const headingClass = Decoration.mark({
class: `tm-md-heading tm-md-h${String(level)}`,
});
const contentFrom = lineFrom + markerLength;
if (contentFrom < line.to) {
builder.push(headingClass.range(contentFrom, line.to));
}
}
const quote = text.match(/^>\s?/);
if (quote) {
addHiddenToken(builder, selections, {
from: lineFrom,
to: lineFrom + quote[0].length,
});
}
const unordered = text.match(/^(\s*)([-+*])\s+(?:\[( |x|X)\]\s+)?/);
if (unordered) {
const markerStart = lineFrom + unordered[1].length;
const markerEnd = markerStart + unordered[2].length + 1;
addHiddenToken(builder, selections, { from: markerStart, to: markerEnd });
const checkbox = unordered[0].match(/\[( |x|X)\]\s+$/);
if (checkbox) {
const checkboxStart = lineFrom + unordered[0].lastIndexOf("[");
addHiddenToken(builder, selections, {
from: checkboxStart,
to: checkboxStart + checkbox[0].length,
});
}
}
const ordered = text.match(/^(\s*)(\d+\.)\s+/);
if (ordered) {
const markerStart = lineFrom + ordered[1].length;
addHiddenToken(builder, selections, {
from: markerStart,
to: markerStart + ordered[2].length + 1,
});
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(trimmed)) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-hr" }).range(lineFrom, line.to),
);
}
continue;
}
const tableSeparator = /^\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?$/.test(
text,
);
if (tableSeparator) {
if (lineFrom < line.to) {
builder.push(
Decoration.mark({ class: "tm-md-del" }).range(lineFrom, line.to),
);
}
continue;
}
const { styleRanges, tokenRanges } = collectInlineRanges(text, lineFrom);
for (const range of styleRanges) {
if (range.from >= range.to) continue;
if (range.className === "tm-md-strong") {
builder.push(strongDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-em") {
builder.push(emDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-del") {
builder.push(delDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-code") {
builder.push(codeDecoration.range(range.from, range.to));
} else if (range.className === "tm-md-link") {
builder.push(linkDecoration.range(range.from, range.to));
}
}
for (const token of tokenRanges) {
addHiddenToken(builder, selections, token);
}
}
return Decoration.set(builder, true);
}
/**
* Keeps markdown syntax visible only when user selection intersects the token.
* This preserves cursor predictability and cross-token selection while still hiding syntax during reading.
*/
function addHiddenToken(
builder: Range<Decoration>[],
selections: ReadonlyArray<{ from: number; to: number }>,
token: {
from: number;
to: number;
},
): void {
if (token.from >= token.to) return;
const overlapsSelection = selections.some((selection) => {
const selectionFrom = Math.min(selection.from, selection.to);
const selectionTo = Math.max(selection.from, selection.to);
if (selectionFrom === selectionTo) {
return selectionFrom >= token.from && selectionFrom <= token.to;
}
return selectionFrom < token.to && selectionTo > token.from;
});
if (overlapsSelection) return;
builder.push(hiddenTokenDecoration.range(token.from, token.to));
}

View file

@ -0,0 +1,815 @@
import {
Fragment,
useEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import Emoji from "./emoji";
import { findEmojiShortcodes } from "./emojiData";
import { Check } from "lucide-react";
type InlineNode =
| { type: "text"; value: string }
| { type: "emoji"; shortcode: string }
| { type: "strong"; value: string }
| { type: "em"; value: string }
| { type: "del"; value: string }
| { type: "code"; value: string }
| { type: "link"; label: string; href: string }
| { type: "image"; alt: string; src: string };
type InlineDecorationRange = {
from: number;
to: number;
className: string;
};
type InlineTokenRange = {
from: number;
to: number;
};
type ListItem = {
text: string;
checked: boolean | null;
};
type TableBlock = {
type: "table";
headers: string[];
rows: string[][];
};
type MarkdownBlock =
| {
type: "paragraph";
text: string;
}
| {
type: "heading";
level: number;
text: string;
}
| {
type: "hr";
}
| {
type: "blockquote";
text: string;
}
| {
type: "code";
language: string;
code: string;
}
| {
type: "list";
ordered: boolean;
items: ListItem[];
}
| TableBlock;
const INLINE_TOKEN_REGEX =
/!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(?<![a-zA-Z0-9:])_([^_\n]+)_(?![a-zA-Z0-9:])/g;
function CopiedIndicator({
block,
visible,
}: {
block: boolean;
visible: boolean;
}) {
return (
<span
className={`pointer-events-none inline-flex align-middle text-foreground transition-opacity duration-200 ease-out ${block ? "mt-3 shrink-0" : "ml-1"} ${visible ? "opacity-100" : "opacity-0"}`}
aria-live="polite"
aria-hidden={!visible}
>
<Check className="size-3.5" />
</span>
);
}
function CopyableCode({
block = false,
language,
value,
}: {
block?: boolean;
language?: string;
value: string;
}) {
const [copied, setCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
useEffect(
() => () => {
clearTimeout(copiedTimer.current);
},
[],
);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
clearTimeout(copiedTimer.current);
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
}
const code = (
<code
className={block ? "" : "bg-card border p-0.5 rounded text-sm"}
data-language={language}
role="button"
tabIndex={0}
onClick={() => void copy()}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
void copy();
}}
>
{value}
</code>
);
if (block) {
return (
<div className="flex min-w-0 items-start gap-1">
<pre className="tm-md-pre min-w-0 flex-1">{code}</pre>
<CopiedIndicator block visible={copied} />
</div>
);
}
return (
<>
{code}
<CopiedIndicator block={false} visible={copied} />
</>
);
}
/**
* Executes parseInlineNodes.
* @param input Parameter input.
* @returns InlineNode[].
*/
function parseInlineNodes(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const index = match.index;
const raw = match[0];
if (index > cursor) {
nodes.push(...parseEmojiText(input.slice(cursor, index)));
}
if (match[1] !== undefined && match[2] !== undefined) {
nodes.push({ type: "image", alt: match[1], src: normalizeUrl(match[2]) });
} else if (match[3] !== undefined && match[4] !== undefined) {
nodes.push({
type: "link",
label: match[3],
href: normalizeUrl(match[4]),
});
} else if (match[5] !== undefined) {
nodes.push({ type: "code", value: match[5] });
} else if (match[6] !== undefined) {
nodes.push({ type: "del", value: match[6] });
} else if (match[7] !== undefined || match[8] !== undefined) {
nodes.push({ type: "strong", value: match[7] ?? match[8] ?? "" });
} else if (match[9] !== undefined || match[10] !== undefined) {
nodes.push({ type: "em", value: match[9] ?? match[10] ?? "" });
} else {
nodes.push(...parseEmojiText(raw));
}
cursor = index + raw.length;
match = INLINE_TOKEN_REGEX.exec(input);
}
if (cursor < input.length) {
nodes.push(...parseEmojiText(input.slice(cursor)));
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return nodes;
}
function parseEmojiText(input: string): InlineNode[] {
const nodes: InlineNode[] = [];
let cursor = 0;
for (const match of findEmojiShortcodes(input)) {
if (match.from > cursor) {
nodes.push({ type: "text", value: input.slice(cursor, match.from) });
}
nodes.push({ type: "emoji", shortcode: match.emoji.shortcode });
cursor = match.to;
}
if (cursor < input.length) {
nodes.push({ type: "text", value: input.slice(cursor) });
}
return nodes;
}
/**
* Executes collectInlineRanges.
* @param input Parameter input.
* @param offset Parameter offset.
* @returns {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
}.
*/
export function collectInlineRanges(
input: string,
offset = 0,
): {
styleRanges: InlineDecorationRange[];
tokenRanges: InlineTokenRange[];
} {
const styleRanges: InlineDecorationRange[] = [];
const tokenRanges: InlineTokenRange[] = [];
let match = INLINE_TOKEN_REGEX.exec(input);
while (match) {
const raw = match[0];
const start = offset + match.index;
const end = start + raw.length;
if (match[1] !== undefined && match[2] !== undefined) {
const openLength = 2;
const closeLength = raw.endsWith(")") ? 1 : 0;
const imageEnd = start + openLength + match[1].length;
tokenRanges.push({ from: start, to: start + openLength });
tokenRanges.push({ from: imageEnd, to: imageEnd + 1 });
const srcStart = imageEnd + 1;
const srcEnd = end - closeLength;
tokenRanges.push({ from: srcStart, to: srcStart + 1 });
tokenRanges.push({ from: srcEnd, to: srcEnd + closeLength });
} else if (match[3] !== undefined && match[4] !== undefined) {
const label = match[3];
const labelStart = start + 1;
const labelEnd = labelStart + label.length;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: labelEnd, to: labelEnd + 1 });
tokenRanges.push({ from: labelEnd + 1, to: labelEnd + 2 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: labelStart,
to: labelEnd,
className: "tm-md-link",
});
} else if (match[5] !== undefined) {
const codeStart = start + 1;
const codeEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: codeStart,
to: codeEnd,
className: "tm-md-code",
});
} else if (match[6] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-del",
});
} else if (match[7] !== undefined || match[8] !== undefined) {
const contentStart = start + 2;
const contentEnd = end - 2;
tokenRanges.push({ from: start, to: start + 2 });
tokenRanges.push({ from: end - 2, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-strong",
});
} else if (match[9] !== undefined || match[10] !== undefined) {
const contentStart = start + 1;
const contentEnd = end - 1;
tokenRanges.push({ from: start, to: start + 1 });
tokenRanges.push({ from: end - 1, to: end });
styleRanges.push({
from: contentStart,
to: contentEnd,
className: "tm-md-em",
});
}
match = INLINE_TOKEN_REGEX.exec(input);
}
INLINE_TOKEN_REGEX.lastIndex = 0;
return { styleRanges, tokenRanges };
}
/**
* Executes parseMarkdownBlocks.
* @param markdown Parameter markdown.
* @returns MarkdownBlock[].
*/
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: MarkdownBlock[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const codeFence = line.match(/^```\s*([^`]*)$/);
if (codeFence) {
const language = (codeFence[1] ?? "").trim();
const codeLines: string[] = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) {
index += 1;
}
blocks.push({ type: "code", language, code: codeLines.join("\n") });
continue;
}
if (/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(line.trim())) {
blocks.push({ type: "hr" });
index += 1;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
blocks.push({
type: "heading",
level: heading[1].length,
text: heading[2],
});
index += 1;
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
const quoteLines: string[] = [quote[1]];
index += 1;
while (index < lines.length) {
const next = lines[index].match(/^>\s?(.*)$/);
if (!next) break;
quoteLines.push(next[1]);
index += 1;
}
blocks.push({ type: "blockquote", text: quoteLines.join("\n") });
continue;
}
const tableCandidate = readTable(lines, index);
if (tableCandidate) {
blocks.push(tableCandidate.block);
index = tableCandidate.nextIndex;
continue;
}
const unordered = line.match(/^\s*[-*+]\s+(.*)$/);
const ordered = line.match(/^\s*\d+\.\s+(.*)$/);
if (unordered || ordered) {
const orderedList = Boolean(ordered);
const items: ListItem[] = [];
while (index < lines.length) {
const current = lines[index];
const match = orderedList
? current.match(/^\s*\d+\.\s+(.*)$/)
: current.match(/^\s*[-*+]\s+(.*)$/);
if (!match) break;
const task = match[1].match(/^\[( |x|X)\]\s+(.*)$/);
if (task) {
items.push({
text: task[2],
checked: task[1].toLowerCase() === "x",
});
} else {
items.push({ text: match[1], checked: null });
}
index += 1;
}
blocks.push({ type: "list", ordered: orderedList, items });
continue;
}
const paragraphLines = [line];
index += 1;
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```\s*/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) &&
!/^\s*[-*+]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^(?:\*\s*){3,}$|^(?:-\s*){3,}$|^(?:_\s*){3,}$/.test(lines[index].trim())
) {
paragraphLines.push(lines[index]);
index += 1;
}
blocks.push({ type: "paragraph", text: paragraphLines.join("\n") });
}
return blocks;
}
/**
* Executes renderInline.
* @param nodes Parameter nodes.
* @returns React.ReactNode[].
*/
function renderInline(nodes: InlineNode[]): ReactNode[] {
return nodes.map((node, index) => {
if (node.type === "text") {
return node.value;
}
if (node.type === "emoji") {
return (
<Emoji key={index} className="tm-md-emoji" shortcode={node.shortcode} />
);
}
if (node.type === "strong") {
return (
<strong key={index} className="tm-md-strong">
{renderInline(parseEmojiText(node.value))}
</strong>
);
}
if (node.type === "em") {
return (
<em key={index} className="tm-md-em">
{renderInline(parseEmojiText(node.value))}
</em>
);
}
if (node.type === "del") {
return (
<del key={index} className="tm-md-del">
{renderInline(parseEmojiText(node.value))}
</del>
);
}
if (node.type === "code") {
return <CopyableCode key={index} value={node.value} />;
}
if (node.type === "link") {
return (
<a
key={index}
className="tm-md-link"
href={node.href}
target="_blank"
rel="noreferrer"
>
{renderInline(parseEmojiText(node.label))}
</a>
);
}
return (
<img
key={index}
className="tm-md-image"
src={node.src}
alt={node.alt}
loading="lazy"
decoding="async"
/>
);
});
}
/**
* Executes renderBlocks.
* @param blocks Parameter blocks.
* @returns React.ReactElement.
*/
export function renderBlocks(blocks: MarkdownBlock[]): ReactElement {
return (
<>
{blocks.map((block, blockIndex) => {
if (block.type === "heading") {
const className = `tm-md-heading tm-md-h${String(block.level)}`;
if (block.level === 1)
return (
<h1 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h1>
);
if (block.level === 2)
return (
<h2 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h2>
);
if (block.level === 3)
return (
<h3 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h3>
);
if (block.level === 4)
return (
<h4 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h4>
);
if (block.level === 5)
return (
<h5 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h5>
);
return (
<h6 key={blockIndex} className={className}>
{renderInline(parseInlineNodes(block.text))}
</h6>
);
}
if (block.type === "blockquote") {
return (
<blockquote key={blockIndex} className="tm-md-blockquote">
{block.text.split("\n").map((line, lineIndex) => (
<p key={lineIndex}>{renderInline(parseInlineNodes(line))}</p>
))}
</blockquote>
);
}
if (block.type === "code") {
return (
<CopyableCode
key={blockIndex}
block
language={block.language}
value={block.code}
/>
);
}
if (block.type === "list") {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag
key={blockIndex}
className={block.ordered ? "tm-md-ol" : "tm-md-ul"}
>
{block.items.map((item, itemIndex) => (
<li key={itemIndex} className="tm-md-li">
{item.checked !== null ? (
<input
type="checkbox"
checked={item.checked}
disabled
className="tm-md-checkbox"
/>
) : null}
<span>{renderInline(parseInlineNodes(item.text))}</span>
</li>
))}
</Tag>
);
}
if (block.type === "table") {
return (
<div key={blockIndex} className="tm-md-table-wrap">
<table className="tm-md-table">
<thead>
<tr>
{block.headers.map((header, headerIndex) => (
<th key={headerIndex}>
{renderInline(parseInlineNodes(header))}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>
{renderInline(parseInlineNodes(cell))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (block.type === "hr") {
return <hr key={blockIndex} className="tm-md-hr" />;
}
return (
<p key={blockIndex}>
{block.text.split("\n").map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? <br /> : null}
{renderInline(parseInlineNodes(line))}
</Fragment>
))}
</p>
);
})}
</>
);
}
/**
* Executes normalizeUrl.
* @param input Parameter input.
* @returns string.
*/
function normalizeUrl(input: string): string {
const value = input.trim();
if (/^(https?:|mailto:|tel:|\/)/i.test(value)) {
return value;
}
return "#";
}
/**
* Executes splitTableRow.
* @param row Parameter row.
* @returns string[].
*/
function splitTableRow(row: string): string[] {
const cleaned = row.trim().replace(/^\|/, "").replace(/\|$/, "");
return cleaned.split("|").map((cell) => cell.trim());
}
/**
* Executes readTable.
* @param lines Parameter lines.
* @param index Parameter index.
* @returns { block: TableBlock; nextIndex: number } | null.
*/
function readTable(
lines: string[],
index: number,
): { block: TableBlock; nextIndex: number } | null {
const header = lines[index] ?? "";
const separator = lines[index + 1] ?? "";
if (!header.includes("|") || !separator.includes("|")) {
return null;
}
const separatorCells = splitTableRow(separator);
const isSeparator = separatorCells.every((cell) => /^:?-{3,}:?$/.test(cell));
if (!isSeparator) {
return null;
}
const headers = splitTableRow(header);
const rows: string[][] = [];
let cursor = index + 2;
while (cursor < lines.length && lines[cursor].includes("|")) {
rows.push(splitTableRow(lines[cursor]));
cursor += 1;
}
return {
block: { type: "table", headers, rows },
nextIndex: cursor,
};
}
const markdownStyles = `
.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; }
.tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; }
.tm-md-h1 { font-size: 1.65rem; }
.tm-md-h2 { font-size: 1.45rem; }
.tm-md-h3 { font-size: 1.25rem; }
.tm-md-h4 { font-size: 1.1rem; }
.tm-md-h5 { font-size: 1rem; }
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
.tm-md-blockquote p { margin: 0.2rem 0; }
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; }
.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; }
.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); }
.tm-md-codeblock { display: block; }
.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.tm-md-strong { font-weight: 700; }
.tm-md-em { font-style: italic; }
.tm-md-del { text-decoration: line-through; }
.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; }
.tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; }
.tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; }
.tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; }
.tm-md-li { margin: 0.2rem 0; }
.tm-md-checkbox { margin-right: 0.5rem; vertical-align: middle; }
.tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; }
.tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; }
.tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; }
.tm-md-table th { background: var(--muted); font-weight: 600; }
.tm-md-hr { margin: 0.55rem 0; }
.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); }
.cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; }
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; }
.cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; }
.cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-track { background: transparent; }
.cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar-thumb { border-radius: 9999px; background: var(--border); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li { display: flex; min-height: 2.25rem; align-items: center; border-radius: calc(var(--radius) * 0.8); padding: 0.3rem 0.5rem; color: var(--popover-foreground); }
.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover,
.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected] { background: var(--accent); color: var(--accent-foreground); }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionIcon { display: none; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionLabel { overflow: hidden; text-overflow: ellipsis; }
.cm-tooltip.cm-tooltip-autocomplete .cm-completionMatchedText { color: inherit; text-decoration: none; font-weight: 600; }
.cm-tooltip-autocomplete .tm-md-completion-emoji { display: inline-block; width: 1.35rem; height: 1.35rem; flex: 0 0 auto; margin-right: 0.5rem; vertical-align: middle; }
`;
/**
* Executes ensureMarkdownStyles.
* @param none This function has no parameters.
* @returns void.
*/
export function ensureMarkdownStyles(): void {
if (typeof document === "undefined") return;
const styleId = "tensamin-markdown-styles";
let style = document.getElementById(styleId) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = styleId;
document.head.appendChild(style);
}
if (style.textContent !== markdownStyles) {
style.textContent = markdownStyles;
}
}

View file

@ -0,0 +1,30 @@
import { useMemo, type CSSProperties } from "react";
import {
ensureMarkdownStyles,
parseMarkdownBlocks,
renderBlocks,
} from "./markdown";
export type TextProps = {
value: string;
fontSize?: CSSProperties["fontSize"];
};
/**
* Executes Text.
* @param props Parameter props.
* @returns unknown.
*/
export default function Text(props: TextProps) {
ensureMarkdownStyles();
const blocks = useMemo(() => parseMarkdownBlocks(props.value), [props.value]);
const renderedBlocks = useMemo(() => renderBlocks(blocks), [blocks]);
return (
<div className="tm-md-root" style={{ fontSize: props.fontSize }}>
{renderedBlocks}
</div>
);
}

View file

@ -0,0 +1 @@
- Improve the Input box

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

View file

@ -9,18 +9,16 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src --ext .ts,.tsx", "lint": "eslint src --ext .ts,.tsx",
"build": "tsc -p tsconfig.json --noEmit" "test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"mtp": "*", "mtp": "*",
"react": "^19.2.8", "react": "^19.2.8"
"react-dom": "^19.2.8",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^10.8.0" "eslint": "^10.8.0"

View file

@ -0,0 +1,529 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { toast as sonnerToast } from "@methanium/ui";
import { base64ToBytes, ConnectionState, MTPClient } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import { useStorage } from "@tensamin/storage/context";
import {
type BoundSendFn,
MTPContext,
type MTPContextType,
type ProtocolMessage,
removeMissingContacts,
useMessageHandlers,
} from "./mtpContext";
import {
DISCOVERY_TIMEOUT,
INITIAL_SYNC_TIMEOUT,
RECONNECT_JITTER,
RECONNECT_LONG_INTERVAL,
RECONNECT_RESET,
RECONNECT_TRIES,
RETRY_INTERVAL,
STATE_ACK_TIMEOUT,
} from "./values";
type BrowserMtpClient = Awaited<ReturnType<typeof createBrowserClient>>;
function createBrowserClient(
options: Omit<Parameters<typeof MTPClient.create>[0], "schemas">,
) {
return MTPClient.create({
...options,
schemas: mtpSchemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate push message", error);
},
});
}
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Initial state synchronization was cancelled");
}
function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutMessage: string,
signal: AbortSignal,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
if (signal.aborted) {
reject(abortError(signal));
return;
}
let settled = false;
const finish = (complete: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
signal.removeEventListener("abort", onAbort);
complete();
};
const timeout = setTimeout(
() => finish(() => reject(new Error(timeoutMessage))),
timeoutMs,
);
const onAbort = () => finish(() => reject(abortError(signal)));
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => finish(() => resolve(value)),
(error: unknown) => finish(() => reject(error)),
);
});
}
async function completeInitialSynchronization(
client: BrowserMtpClient,
subscribe: MTPContextType["subscribe"],
signal: AbortSignal,
syncTimeoutMs = INITIAL_SYNC_TIMEOUT,
ackTimeoutMs = STATE_ACK_TIMEOUT,
): Promise<ProtocolMessage<"ClientStateSync">> {
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(resolve, reject) => {
let unsubscribeStateSync = () => {};
let unsubscribeNoIota = () => {};
const cleanup = () => {
clearTimeout(timeout);
unsubscribeStateSync();
unsubscribeNoIota();
signal.removeEventListener("abort", onAbort);
};
const onAbort = () => {
cleanup();
reject(abortError(signal));
};
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Initial state synchronization timed out"));
}, syncTimeoutMs);
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return;
}
unsubscribeStateSync = subscribe("ClientStateSync", (message) => {
cleanup();
resolve(message);
});
unsubscribeNoIota = subscribe("ErrorNoIota", () => {
cleanup();
reject(new Error("No Iota is currently connected"));
});
},
);
const [, state] = await Promise.all([
withDeadline(
client.auth(),
syncTimeoutMs,
"MTP authentication timed out",
signal,
),
stateSync,
]);
await withDeadline(
client.request("ClientStateAck", {
SessionId: state.data.SessionId,
VersionNumber: state.data.VersionNumber,
}),
ackTimeoutMs,
"State acknowledgement timed out",
signal,
);
if (signal.aborted) throw abortError(signal);
return state;
}
function protocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("type" in error)) {
return null;
}
const protocolError = error as {
id?: unknown;
type?: unknown;
frame?: unknown;
};
return {
id: protocolError.id,
type: protocolError.type,
frame: protocolError.frame,
};
}
export function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const { load } = useStorage();
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState(false);
const [identifying, setIdentifying] = useState(false);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<BrowserMtpClient | null>(null);
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("omega_url").then(setMtpUrl);
}, [load]);
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) throw new Error("mtp is not connected");
const response = await client.request(type, data, options);
if (response.type === "GetStates") {
setFreshContacts((contacts) =>
removeMissingContacts(
contacts,
response as ProtocolMessage<"GetStates">,
),
);
}
return response;
},
[],
);
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
if (!mtpUrl) return;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let connectionGeneration = 0;
let cleanupConnection = () => {};
const clearReconnectTimer = () => {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
reconnectScheduled = false;
};
const clearReconnectResetTimer = () => {
if (!reconnectResetTimer) return;
clearTimeout(reconnectResetTimer);
reconnectResetTimer = null;
};
const scheduleReconnect = (error: unknown) => {
if (disposed || reconnectScheduled) return;
attempts += 1;
const shortRetry = attempts <= RECONNECT_TRIES;
if (!shortRetry) {
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
sonnerToast.error("Connection failed", {
id: "mtp-connection-toast",
description:
error instanceof Error
? `${error.message.split(":")[0]}. Retrying in the background.`
: "Connection lost. Retrying in the background.",
icon: null,
duration: Infinity,
closeButton: true,
promise: null,
} as unknown as Parameters<typeof sonnerToast.error>[1]);
} else {
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
}
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
reconnectScheduled = true;
reconnectTimer = setTimeout(
() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
},
Math.round(baseDelay * jitter),
);
};
async function connect() {
if (disposed || props.blockConnection) return;
const generation = ++connectionGeneration;
let client: BrowserMtpClient | null = null;
let failed = false;
let connectionReady = false;
let detachSubscriptions = () => {};
let unsubscribeNoIota = () => {};
const attemptAbort = new AbortController();
const cleanup = () => {
attemptAbort.abort(
new Error("Initial state synchronization was cancelled"),
);
unsubscribeNoIota();
detachSubscriptions();
client?.disconnect();
if (clientRef.current === client) clientRef.current = null;
clearReconnectResetTimer();
if (generation === connectionGeneration) {
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
}
};
cleanupConnection = cleanup;
try {
setIdentified(false);
setIdentifying(false);
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
]);
if (!userId || !keyring) throw new Error("Missing login credentials");
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
let url = null;
let omikronPublicKey = null;
if (forcedOmikronUrl && forcedOmikronPublicKey) {
url = forcedOmikronUrl;
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
signal: AbortSignal.any([
attemptAbort.signal,
AbortSignal.timeout(DISCOVERY_TIMEOUT),
]),
});
if (data.status === 404) {
throw new Error("No Omikron assignment is currently available");
}
if (!data.ok)
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
const omikronData = (await data.json()) as {
ip_address: string;
port: number;
public_key: string;
};
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
) {
throw new Error("Invalid Omikron data");
}
url = `https://${omikronData.ip_address}:${omikronData.port}`;
omikronPublicKey = omikronData.public_key;
}
if (!url || !omikronPublicKey) {
throw new Error("Missing Omikron URL or Public Key");
}
log(2, "mtp", "green", "Connecting to: " + url);
client = await createBrowserClient({
url,
credentials: { clientId: userId, keyring: base64ToBytes(keyring) },
hostPublicKey: { value: omikronPublicKey, encoding: "base64" },
descriptor: "client",
pings: true,
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
const state = client?.state ?? ConnectionState.Disconnected;
setReadyState(state);
if (
state === ConnectionState.Disconnected &&
clientRef.current === client &&
!failed
) {
failed = true;
const error = new Error("MTP connection lost");
attemptAbort.abort(error);
if (connectionReady) {
cleanup();
scheduleReconnect(error);
}
}
}
if (event.type !== "Pong" && event.type !== "Ping") {
log(
2,
"mtp",
event.type === "state"
? "purple"
: event.direction === "recv"
? "cyan"
: event.direction === "send"
? "gray"
: "blue",
event.type === "state"
? event.data
: event.direction === "recv"
? "< " + event.type
: event.direction === "send"
? "> " + event.type
: event.type,
event,
);
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
const activeClient = client;
clientRef.current = activeClient;
detachSubscriptions = attachSubscriptions(activeClient);
unsubscribeNoIota = subscribe("ErrorNoIota", () => {
if (clientRef.current !== activeClient || failed) return;
failed = true;
const error = new Error("No Iota is currently connected");
attemptAbort.abort(error);
cleanup();
scheduleReconnect(error);
});
setReadyState(activeClient.state);
setIdentifying(true);
const finalResponse = await completeInitialSynchronization(
activeClient,
subscribe,
attemptAbort.signal,
);
if (disposed || clientRef.current !== activeClient) return;
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
connectionReady = true;
setIdentifying(false);
setIdentified(true);
clearReconnectTimer();
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
failed = true;
cleanup();
const message =
connectError instanceof Error
? connectError.message
: String(connectError ?? "Unknown error");
log(
0,
"mtp",
"red",
`Connection/authentication attempt failed: ${message}`,
protocolErrorDetails(connectError) ?? connectError,
);
scheduleReconnect(connectError);
}
}
void connect();
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
cleanupConnection();
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [attachSubscriptions, load, mtpUrl, props.blockConnection, subscribe]);
useEffect(() => {
return subscribe("ErrorNoIota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
});
}, [subscribe]);
useEffect(
() =>
subscribe("GetStates", (message) => {
setFreshContacts((contacts) =>
removeMissingContacts(contacts, message),
);
}),
[subscribe],
);
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) return "Waiting for authenticated session";
return "Loading...";
}, [connected, identified, identifying, readyState, mtpUrl]);
const contextReady = connected && identified && mtpUrl !== null;
const mtpRef = useMemo(() => createAsyncQueue<{ send: typeof send }>(), []);
useEffect(() => {
if (connected && identified && mtpUrl) {
mtpRef.set({ send });
}
}, [connected, identified, mtpUrl, send, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(interceptor({ type, data, response })).catch(
(error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
},
);
}
return response;
},
[interceptorsRef, mtpRef],
);
return (
<MTPContext.Provider
value={{
send: sendQueued,
subscribe,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}

View file

@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { isPushType, validateResponse } from "./context";
describe("MTP protocol dispatch", () => {
it("preserves protocol errors for the request layer", () => {
const error = validateResponse("GetStates", {
id: 12,
type: "ErrorInternal",
data: { ErrorType: "temporary" },
});
expect(error.type).toBe("ErrorInternal");
expect(error.id).toBe(12);
});
it("recognizes initial and live presence pushes", () => {
expect(isPushType("GetStates")).toBe(true);
expect(isPushType("ClientChanged")).toBe(true);
expect(isPushType("UnknownMessage")).toBe(false);
});
});

View file

@ -1,957 +1,41 @@
import { import { type ReactNode, useContext, useEffect, useState } from "react";
createContext, import { isTauri } from "@tauri-apps/api/core";
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { MTPClient } from "mtp"; import { MTPClient } from "mtp";
import { type z } from "zod";
import { ConnectionState } from "mtp";
import createAsyncQueue from "@tensamin/shared/asyncQueue";
import { toast as sonnerToast } from "@methanium/ui";
import { import { BrowserProvider } from "./browser";
type Calls, import { MTPContext, type MTPContextType } from "./mtpContext";
type Communities, import { TauriProvider } from "./tauri";
type Contacts,
mtp as schemas,
type MTP as Schemas,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import { ProtocolError } from "@tensamin/shared/errors";
import { useStorage } from "@tensamin/storage/context";
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
function base64ToUint8Array(b64: string) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) {
out[i] = bin.charCodeAt(i);
}
return out;
}
export type ProtocolMessage<
T extends keyof Schemas & string = keyof Schemas & string,
> = {
id?: number;
type: T | string;
data: z.infer<Schemas[T]["response"]>;
};
export type BoundSendFn = <T extends keyof Schemas & string>(
type: T,
data?: z.infer<Schemas[T]["request"]>,
options?: { id?: number },
) => Promise<ProtocolMessage<T>>;
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
const PUSH_TYPES = [
"MessageLive",
"MessageEditLive",
"MessageReactionLive",
"MessageDeleteLive",
"MessageState",
"CallInvite",
"GetStates",
"ClientChanged",
"ErrorNoIota",
] as const;
export function isPushType(type: string): boolean {
return (PUSH_TYPES as readonly string[]).includes(type);
}
function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage,
): Contacts {
if (message.type !== "GetStates") return contacts;
const data = message.data as { MissingUserIds?: unknown };
if (!Array.isArray(data.MissingUserIds)) return contacts;
const missing = new Set(
data.MissingUserIds.filter(
(userId): userId is number => typeof userId === "number",
),
);
return contacts.filter((contact) => !missing.has(contact.UserId));
}
export type MTPExchange = {
type: keyof Schemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
type ContextType = {
send: BoundSendFn;
subscribe: <T extends keyof Schemas & string>(
type: T,
handler: (message: ProtocolMessage<T>) => void,
) => () => void;
subscribePush: (handler: PushHandler) => () => void;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
identified: boolean;
freshContacts: Contacts;
freshCommunities: Communities;
freshCalls: Calls;
contextReady: boolean;
loadingDescription: string;
};
const MTPContext = createContext<ContextType | undefined>(undefined);
function getProtocolErrorDetails(error: unknown) {
if (typeof error !== "object" || error === null || !("type" in error)) {
return null;
}
const protocolError = error as {
id?: unknown;
type?: unknown;
data?: unknown;
};
return {
id: protocolError.id,
type: protocolError.type,
data: protocolError.data,
};
}
// Zod schema validation
export function validateResponse<T extends keyof Schemas & string>(
type: T,
message: { id?: number; type: string; data: unknown },
): ProtocolMessage<T> {
if (message.type.startsWith("Error")) {
return message as ProtocolMessage<T>;
}
const schema =
schemas[message.type as keyof Schemas & string]?.response ??
schemas[type]?.response;
if (!schema) {
return message as ProtocolMessage<T>;
}
const parsed = schema.safeParse(message.data);
if (!parsed.success) {
throw new Error(
`Response validation failed for ${type}: ${parsed.error.message}`,
);
}
return {
id: message.id,
type: message.type,
data: parsed.data,
} as ProtocolMessage<T>;
}
function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const pushHandlersRef = useRef(new Set<PushHandler>());
const lastInitialStateRef = useRef<ProtocolMessage | null>(null);
const subscribePush = useCallback((handler: PushHandler) => {
pushHandlersRef.current.add(handler);
const initialState = lastInitialStateRef.current;
if (initialState?.type === "GetStates") {
void Promise.resolve(handler(initialState)).catch(() => undefined);
}
return () => pushHandlersRef.current.delete(handler);
}, []);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
};
}
function BrowserProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const { load } = useStorage();
const [readyState, setReadyState] = useState<number>(
ConnectionState.Disconnected,
);
const [identified, setIdentified] = useState<boolean>(false);
const [identifying, setIdentifying] = useState<boolean>(false);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
null,
);
const {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
} = useMessageHandlers();
const connected = readyState === ConnectionState.Connected;
// MTP url
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
useEffect(() => {
load("omega_url").then(setMtpUrl);
}, [load]);
// Validation override functions
const send: BoundSendFn = useMemo(
() => async (type, data, options) => {
const client = clientRef.current;
if (!client) {
throw new Error("mtp is not connected");
}
const message = await client.request(
type,
(data ?? {}) as Record<string, unknown>,
options,
);
const response = validateResponse(type, message);
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
if (response.type.startsWith("Error")) {
const errorData = response.data as Record<string, unknown>;
throw new ProtocolError({
type: response.type,
requestId: response.id,
errorType:
typeof errorData.ErrorType === "string"
? errorData.ErrorType
: undefined,
});
}
return response;
},
[],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const client = clientRef.current;
if (!client) {
return () => {};
}
return client.subscribe(type, (message) => {
handler(validateResponse(type, message));
});
}, []);
// Reconnect stuff
const resolveConnectionRef = useRef(() => {});
useEffect(() => {
if (!mtpUrl) return;
let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false;
let disposed = false;
let connectionGeneration = 0;
const clearReconnectTimer = () => {
if (!reconnectTimer) return;
clearTimeout(reconnectTimer);
reconnectTimer = null;
reconnectScheduled = false;
};
const clearReconnectResetTimer = () => {
if (!reconnectResetTimer) return;
clearTimeout(reconnectResetTimer);
reconnectResetTimer = null;
};
const scheduleReconnect = (error: unknown) => {
if (disposed || reconnectScheduled) return;
if (attempts >= RECONNECT_TRIES) {
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
sonnerToast.error("Connection failed", {
id: "mtp-connection-toast",
description:
error instanceof Error
? error.message.split(":")[0]
: "Connection lost",
icon: null,
duration: Infinity,
closeButton: true,
promise: null,
} as unknown as Parameters<typeof sonnerToast.error>[1]);
return;
}
attempts += 1;
sonnerToast.loading(
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
{ id: "mtp-connection-toast" },
);
reconnectScheduled = true;
reconnectTimer = setTimeout(() => {
reconnectScheduled = false;
reconnectTimer = null;
void connect();
}, RETRY_INTERVAL);
};
async function connect() {
if (disposed || props.blockConnection) return;
const generation = ++connectionGeneration;
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null;
let failed = false;
const cleanup = () => {
client?.disconnect();
if (clientRef.current === client) {
clientRef.current = null;
}
clearReconnectResetTimer();
if (generation === connectionGeneration) {
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
}
};
try {
setIdentified(false);
setIdentifying(false);
const [userId, keyring] = await Promise.all([
load("user_id"),
load("mtp_keyring"),
]);
if (!userId || !keyring) {
throw new Error("Missing login credentials");
}
const forcedOmikronUrl = await load("forced_omikron_url");
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
let url = null;
let omikronPublicKey = null;
if (forcedOmikronUrl && forcedOmikronPublicKey) {
url = forcedOmikronUrl;
omikronPublicKey = forcedOmikronPublicKey;
} else {
log(2, "mtp", "purple", "Fetching Omikron data.");
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
if (data.status === 404) {
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
cleanup();
return;
}
const omikronData = (await data.json()) as {
id: number;
ip_address: string;
port: number;
public_key: string;
status: string;
};
if (
!omikronData.ip_address ||
!omikronData.port ||
!omikronData.public_key
)
throw new Error("Invalid Omikron data");
url = `https://${omikronData.ip_address}:${omikronData.port}`;
omikronPublicKey = omikronData.public_key;
}
//codec.decode(new Uint8Array(await res.arrayBuffer())),
if (!url || !omikronPublicKey)
throw new Error("Missing Omikron URL or Public Key");
log(2, "mtp", "green", "Connecting to: " + url);
client = await MTPClient.create({
url,
credentials: {
clientId: userId,
keyring: base64ToUint8Array(keyring),
},
hostPublicKey: omikronPublicKey,
descriptor: "client",
pings: true,
logger: (event) => {
if (event.type === "state") {
if (generation !== connectionGeneration) return;
const state = client?.state ?? ConnectionState.Disconnected;
setReadyState(state);
if (
state === ConnectionState.Disconnected &&
clientRef.current === client &&
!failed
) {
failed = true;
clientRef.current = null;
setIdentified(false);
setIdentifying(false);
scheduleReconnect(new Error("MTP connection lost"));
}
}
if (event.type !== "Pong" && event.type !== "Ping") {
log(
2,
"mtp",
event.type === "state"
? "purple"
: event.direction === "recv"
? "cyan"
: event.direction === "send"
? "gray"
: "blue",
event.type === "state"
? event.data
: event.direction === "recv"
? "< " + event.type
: event.direction === "send"
? "> " + event.type
: event.type,
event,
);
}
},
});
if (disposed || generation !== connectionGeneration) {
client.disconnect();
return;
}
const activeClient = client;
clientRef.current = activeClient;
for (const type of PUSH_TYPES) {
activeClient.subscribe(type, (message) => {
let validated: ProtocolMessage;
try {
validated = validateResponse(type, message);
} catch (error) {
log(1, "mtp", "red", "Failed to validate push message", error, {
type,
data: message.data,
});
return;
}
setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated),
);
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve()
.then(() => handler(validated))
.catch((error) => {
log(1, "mtp", "red", "Push handler failed", error, { type });
});
}
if (validated.type === "GetStates") {
lastInitialStateRef.current = validated;
}
});
}
setReadyState(activeClient.state);
clearReconnectTimer();
// Schedule reconnect reset
clearReconnectResetTimer();
reconnectResetTimer = setTimeout(() => {
attempts = 0;
reconnectResetTimer = null;
}, RECONNECT_RESET * 1_000);
setReadyState(activeClient.state);
setIdentifying(true);
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
(resolve, reject) => {
let unsubscribeStateSync = () => {};
let unsubscribeNoIota = () => {};
const cleanupStateSync = () => {
clearTimeout(timeout);
unsubscribeStateSync();
unsubscribeNoIota();
};
const timeout = setTimeout(() => {
cleanupStateSync();
reject(new Error("Initial state synchronization timed out"));
}, 120_000);
unsubscribeStateSync = activeClient.subscribe(
"ClientStateSync",
(message) => {
cleanupStateSync();
try {
resolve(validateResponse("ClientStateSync", message));
} catch (error) {
reject(error);
}
},
);
unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => {
cleanupStateSync();
reject(new Error("No Iota is currently connected"));
});
},
);
const [, finalResponse] = await Promise.all([
activeClient.auth(),
stateSync,
]);
if (finalResponse.type.startsWith("Error")) {
throw new Error(
`State synchronization failed: ${finalResponse.type}`,
);
}
const acknowledgement = await activeClient.request("ClientStateAck", {
SessionId: finalResponse.data.SessionId,
VersionNumber: finalResponse.data.VersionNumber,
});
if (acknowledgement.type.startsWith("Error")) {
throw new Error(
`State acknowledgement failed: ${acknowledgement.type}`,
);
}
if (disposed || clientRef.current !== activeClient) return;
setFreshContacts(finalResponse.data.Contacts);
setFreshCommunities(finalResponse.data.Communities);
setFreshCalls(finalResponse.data.Calls);
setIdentifying(false);
setIdentified(true);
resolveConnectionRef.current?.();
} catch (connectError) {
if (disposed || generation !== connectionGeneration) {
client?.disconnect();
return;
}
failed = true;
cleanup();
const connectErrorMessage =
connectError instanceof Error
? connectError.message
: String(connectError ?? "Unknown error");
log(
0,
"mtp",
"red",
`Connection/authentication attempt failed: ${connectErrorMessage}`,
getProtocolErrorDetails(connectError) ?? connectError,
);
scheduleReconnect(connectError);
}
}
void connect();
return () => {
disposed = true;
clearReconnectTimer();
clearReconnectResetTimer();
clientRef.current?.disconnect();
clientRef.current = null;
setReadyState(ConnectionState.Disconnected);
setIdentified(false);
setIdentifying(false);
sonnerToast.dismiss("mtp-connection-toast");
};
}, [
lastInitialStateRef,
mtpUrl,
props.blockConnection,
load,
pushHandlersRef,
]);
// No Iota check
useEffect(() => {
if (!connected) return;
return subscribe("ErrorNoIota", () => {
setIdentified(false);
setIdentifying(false);
sonnerToast.error("We couldn't reach your Iota", {
description:
"Check your network connection and try restarting your Iota",
icon: null,
duration: Infinity,
closeButton: true,
});
resolveConnectionRef.current?.();
});
}, [connected, subscribe]);
// Async queue
const loadingDescription = useMemo(() => {
if (!mtpUrl) return "Loading connection details";
if (readyState === ConnectionState.Connecting || !connected) {
return "Establishing transport channel";
}
if (identifying || !identified) return "Waiting for authenticated session";
return "Loading...";
}, [connected, identified, identifying, readyState, mtpUrl]);
const contextReady = connected && identified && mtpUrl !== null;
const mtpRef = useMemo(
() =>
createAsyncQueue<{
send: typeof send;
subscribe: typeof subscribe;
subscribePush: typeof subscribePush;
}>(),
[],
);
useEffect(() => {
if (connected && identified && mtpUrl) {
mtpRef.set({
send,
subscribe,
subscribePush,
});
}
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
const sendQueued: BoundSendFn = useMemo(
() => async (type, data, options) => {
const mtp = await mtpRef.get();
const response = await mtp.send(type, data, options);
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: response as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return response;
},
[interceptorsRef, mtpRef],
);
return (
<MTPContext.Provider
value={{
send: sendQueued,
subscribe,
subscribePush,
addInterceptor,
readyState,
identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription,
}}
>
{props.children}
</MTPContext.Provider>
);
}
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const {
addInterceptor,
interceptorsRef,
lastInitialStateRef,
pushHandlersRef,
subscribePush,
} = useMessageHandlers();
const subscriptionsRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void>>(),
);
const applySnapshot = useCallback((next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error) {
log(0, "android", "orange", "MTP connection failed", next.error);
}
setSnapshot(next);
if (!next.identified || next.state === undefined) return;
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
if (!parsed.success) {
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
return;
}
setFreshContacts(parsed.data.Contacts);
setFreshCommunities(parsed.data.Communities);
setFreshCalls(parsed.data.Calls);
}, []);
const dispatchMessage = useCallback(
(raw: unknown) => {
if (!raw || typeof raw !== "object" || !("type" in raw)) return;
const message = raw as { id?: number; type: string; data: unknown };
let validated: ProtocolMessage;
try {
validated = validateResponse(
message.type as keyof Schemas & string,
message,
);
} catch (error) {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
return;
}
for (const handler of subscriptionsRef.current.get(validated.type) ??
[]) {
handler(validated);
}
if (!isPushType(validated.type)) return;
setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated),
);
for (const handler of [...pushHandlersRef.current]) {
void Promise.resolve(handler(validated)).catch((error) => {
log(1, "mtp", "red", "Native MTP push handler failed", error, {
type: validated.type,
});
});
}
if (validated.type === "GetStates") {
lastInitialStateRef.current = validated;
}
},
[lastInitialStateRef, pushHandlersRef],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
try {
const nextUnlisten = await listen<
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: unknown }
| {
kind: "log";
level: number;
message: string;
details?: unknown;
}
>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
applySnapshot(payload.snapshot);
return;
}
if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
return;
}
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
});
if (disposed) nextUnlisten();
else unlisten = nextUnlisten;
} catch (error) {
log(0, "mtp", "red", "Failed to subscribe to native MTP events", error);
}
try {
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) applySnapshot(current);
} catch (error) {
log(0, "mtp", "red", "Failed to load native MTP status", error);
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await invoke<ProtocolMessage>("mtp_request", {
typeName: type,
data: data ?? {},
id: options?.id,
});
const validated = validateResponse(type, response);
setFreshContacts((contacts) =>
removeMissingContacts(contacts, validated),
);
if (validated.type.startsWith("Error")) {
const errorData = validated.data as Record<string, unknown>;
throw new ProtocolError({
type: validated.type,
requestId: validated.id,
errorType:
typeof errorData.ErrorType === "string"
? errorData.ErrorType
: undefined,
});
}
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(
interceptor({ type, data, response: validated as ProtocolMessage }),
).catch((error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
});
}
return validated;
},
[interceptorsRef],
);
const subscribe = useCallback<ContextType["subscribe"]>((type, handler) => {
const handlers =
subscriptionsRef.current.get(type) ??
new Set<(message: ProtocolMessage) => void>();
handlers.add(handler as (message: ProtocolMessage) => void);
subscriptionsRef.current.set(type, handlers);
return () => {
handlers.delete(handler as (message: ProtocolMessage) => void);
if (handlers.size === 0) subscriptionsRef.current.delete(type);
};
}, []);
const connected = snapshot.readyState === ConnectionState.Connected;
const contextReady = connected && snapshot.identified;
return (
<MTPContext.Provider
value={{
send,
subscribe,
subscribePush,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}
export function Provider(props: { export function Provider(props: {
children: ReactNode; children: ReactNode;
blockConnection?: boolean; blockConnection?: boolean;
}) {
if (isTauri()) return <TauriProvider {...props} />;
return <BrowserWasmProvider {...props} />;
}
function BrowserWasmProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) { }) {
const [wasmReady, setWasmReady] = useState(false); const [wasmReady, setWasmReady] = useState(false);
const [wasmError, setWasmError] = useState<unknown>(); const [wasmError, setWasmError] = useState<unknown>();
useEffect(() => { useEffect(() => {
let active = true; let active = true;
void MTPClient.init().then( void MTPClient.init().then(
() => { () => active && setWasmReady(true),
if (active) setWasmReady(true); (error: unknown) => active && setWasmError(() => error),
},
(error: unknown) => {
if (active) setWasmError(() => error);
},
); );
return () => { return () => {
active = false; active = false;
}; };
}, []); }, []);
if (wasmError) throw wasmError; if (wasmError) throw wasmError;
if (!wasmReady) return null; return wasmReady ? <BrowserProvider {...props} /> : null;
return isTauri() ? (
<TauriProvider {...props} />
) : (
<BrowserProvider {...props} />
);
} }
export function useMTP(): ContextType { export function useMTP(): MTPContextType {
const context = useContext(MTPContext); const context = useContext(MTPContext);
if (!context) { if (!context) throw new Error("useMTP must be used within an MTPProvider");
throw new Error("useMTP must be used within an MTPProvider");
}
return context; return context;
} }

View file

@ -3,6 +3,5 @@ export type {
BoundSendFn, BoundSendFn,
MTPExchange, MTPExchange,
MTPInterceptor, MTPInterceptor,
PushHandler,
ProtocolMessage, ProtocolMessage,
} from "./context"; } from "./mtpContext";

View file

@ -0,0 +1,159 @@
import { createContext, useCallback, useRef } from "react";
import type {
MTPRequestFunction,
MTPResponseFrame,
MTPSubscriptionFunction,
} from "mtp";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
export type ProtocolMessage<
Type extends keyof typeof mtpSchemas & string = keyof typeof mtpSchemas &
string,
> = MTPResponseFrame<typeof mtpSchemas, Type>;
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
export type MTPExchange = {
type: keyof typeof mtpSchemas & string;
data: unknown;
response: ProtocolMessage;
};
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
export type MTPContextType = {
send: BoundSendFn;
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
addInterceptor: (interceptor: MTPInterceptor) => () => void;
readyState: number;
identified: boolean;
freshContacts: Contacts;
freshCommunities: Communities;
freshCalls: Calls;
contextReady: boolean;
loadingDescription: string;
};
export const MTPContext = createContext<MTPContextType | undefined>(undefined);
export function removeMissingContacts(
contacts: Contacts,
message: ProtocolMessage<"GetStates">,
): Contacts {
const missing = new Set(message.data.MissingUserIds ?? []);
return contacts.filter((contact) => !missing.has(contact.UserId));
}
export function useMessageHandlers() {
const interceptorsRef = useRef(new Set<MTPInterceptor>());
const subscriptionHandlersRef = useRef(
new Map<string, Set<(message: ProtocolMessage) => void | Promise<void>>>(),
);
const transportRef = useRef<{
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
} | null>(null);
const transportGenerationRef = useRef(0);
const transportUnsubscribersRef = useRef(new Map<string, () => void>());
const lastInitialStateRef = useRef<ProtocolMessage<"GetStates"> | null>(null);
const attachType = useCallback(
<Type extends keyof typeof mtpSchemas & string>(type: Type) => {
const transport = transportRef.current;
if (!transport || transportUnsubscribersRef.current.has(type)) return;
const generation = transportGenerationRef.current;
const unsubscribe = transport.subscribe(type, (message) => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
if (type === "GetStates") {
lastInitialStateRef.current = message as ProtocolMessage<"GetStates">;
}
for (const handler of [
...(subscriptionHandlersRef.current.get(type) ?? []),
]) {
void Promise.resolve(handler(message as ProtocolMessage)).catch(
(error) => {
log(1, "mtp", "red", "Subscription handler failed", error, {
type,
});
},
);
}
});
transportUnsubscribersRef.current.set(type, unsubscribe);
},
[],
);
const attachSubscriptions = useCallback(
(transport: { subscribe: MTPSubscriptionFunction<typeof mtpSchemas> }) => {
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
transportRef.current = transport;
const generation = ++transportGenerationRef.current;
for (const type of subscriptionHandlersRef.current.keys()) {
attachType(type as keyof typeof mtpSchemas & string);
}
return () => {
if (
transportRef.current !== transport ||
transportGenerationRef.current !== generation
)
return;
transportRef.current = null;
transportGenerationRef.current += 1;
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
unsubscribe();
}
transportUnsubscribersRef.current.clear();
};
},
[attachType],
);
const subscribe = useCallback<MTPSubscriptionFunction<typeof mtpSchemas>>(
(type, handler) => {
const handlers = subscriptionHandlersRef.current.get(type) ?? new Set();
const untypedHandler = handler as (
message: ProtocolMessage,
) => void | Promise<void>;
handlers.add(untypedHandler);
subscriptionHandlersRef.current.set(type, handlers);
attachType(type);
const initialState = lastInitialStateRef.current;
if (type === "GetStates" && initialState) {
void Promise.resolve(untypedHandler(initialState)).catch(
() => undefined,
);
}
return () => {
handlers.delete(untypedHandler);
if (handlers.size !== 0) return;
subscriptionHandlersRef.current.delete(type);
transportUnsubscribersRef.current.get(type)?.();
transportUnsubscribersRef.current.delete(type);
};
},
[attachType],
);
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
interceptorsRef.current.add(interceptor);
return () => interceptorsRef.current.delete(interceptor);
}, []);
return {
addInterceptor,
attachSubscriptions,
interceptorsRef,
subscribe,
};
}

258
packages/mtp/src/tauri.tsx Normal file
View file

@ -0,0 +1,258 @@
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
ConnectionState,
MTPProxyConnection,
type MTPFrame,
type MTPProxyAdapter,
} from "mtp";
import {
mtp as mtpSchemas,
type Calls,
type Communities,
type Contacts,
} from "@tensamin/shared/data";
import { log } from "@tensamin/shared/log";
import {
type BoundSendFn,
MTPContext,
type ProtocolMessage,
removeMissingContacts,
useMessageHandlers,
} from "./mtpContext";
type NativeSnapshot = {
generation: number;
readyState: number;
identified: boolean;
state?: unknown;
error?: string;
};
function createTauriAdapter() {
const subscriptions = new Map<string, Set<(message: MTPFrame) => void>>();
const adapter: MTPProxyAdapter = {
request: (type, data) =>
invoke<MTPFrame>("mtp_request", { typeName: type, data }),
subscribe(type, handler) {
const handlers = subscriptions.get(type) ?? new Set();
handlers.add(handler);
subscriptions.set(type, handlers);
return () => {
handlers.delete(handler);
if (handlers.size === 0) subscriptions.delete(type);
};
},
};
return {
adapter,
dispatch(message: MTPFrame) {
for (const handler of subscriptions.get(message.type) ?? [])
handler(message);
},
};
}
export function TauriProvider(props: {
children: ReactNode;
blockConnection?: boolean;
}) {
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
generation: 0,
readyState: ConnectionState.Disconnected,
identified: false,
});
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
const [freshCalls, setFreshCalls] = useState<Calls>([]);
const generationRef = useRef(0);
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
useMessageHandlers();
const [{ bridge, connection }] = useState(() => {
const bridge = createTauriAdapter();
return {
bridge,
connection: new MTPProxyConnection(bridge.adapter, {
schemas: mtpSchemas,
throwProtocolErrors: true,
onValidationError: (error) => {
log(1, "mtp", "red", "Failed to validate native MTP message", error);
},
}),
};
});
const applySnapshot = useCallback(async (next: NativeSnapshot) => {
if (next.generation < generationRef.current) return;
generationRef.current = next.generation;
if (next.error)
log(0, "android", "orange", "MTP connection failed", next.error);
if (!next.identified) {
setSnapshot(next);
return;
}
if (next.state === undefined) {
setSnapshot({
...next,
identified: false,
error: "Native MTP connection omitted initial state",
});
return;
}
try {
const state = await mtpSchemas.ClientStateSync.response.parseAsync(
next.state,
);
setFreshContacts(state.Contacts);
setFreshCommunities(state.Communities);
setFreshCalls(state.Calls);
setSnapshot(next);
} catch (error) {
log(0, "mtp", "red", "Invalid native MTP state", error);
setSnapshot({
...next,
identified: false,
error: "Invalid ClientStateSync payload",
});
}
}, []);
const dispatchMessage = useCallback(
(message: MTPFrame) => {
bridge.dispatch(message);
},
[bridge],
);
useEffect(() => {
return attachSubscriptions(connection);
}, [attachSubscriptions, connection]);
useEffect(
() =>
subscribe("GetStates", (message) => {
setFreshContacts((contacts) =>
removeMissingContacts(contacts, message),
);
}),
[subscribe],
);
useEffect(() => {
if (props.blockConnection) return;
let disposed = false;
let unlisten: UnlistenFn | undefined;
void (async () => {
try {
const nextUnlisten = await listen<
| { kind: "state"; snapshot: NativeSnapshot }
| { kind: "message"; generation: number; message: MTPFrame }
| { kind: "log"; level: number; message: string; details?: unknown }
>("mtp://event", ({ payload }) => {
if (disposed) return;
if (payload.kind === "state") {
void applySnapshot(payload.snapshot);
} else if (payload.kind === "message") {
if (payload.generation === generationRef.current) {
dispatchMessage(payload.message);
}
} else {
log(
payload.level,
"android",
"orange",
payload.message,
payload.details,
);
}
});
if (disposed) nextUnlisten();
else unlisten = nextUnlisten;
} catch (error) {
log(0, "mtp", "red", "Failed to subscribe to native MTP events", error);
}
try {
const current = await invoke<NativeSnapshot>("mtp_status");
if (!disposed) await applySnapshot(current);
} catch (error) {
log(0, "mtp", "red", "Failed to load native MTP status", error);
}
})();
return () => {
disposed = true;
unlisten?.();
};
}, [applySnapshot, dispatchMessage, props.blockConnection]);
useEffect(() => {
if (props.blockConnection) return;
const updateVisibility = () => {
void invoke("mtp_set_ui_visible", {
visible: document.visibilityState === "visible" && document.hasFocus(),
});
};
updateVisibility();
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
return () => {
document.removeEventListener("visibilitychange", updateVisibility);
window.removeEventListener("focus", updateVisibility);
window.removeEventListener("blur", updateVisibility);
void invoke("mtp_set_ui_visible", { visible: false });
};
}, [props.blockConnection]);
const send = useCallback<BoundSendFn>(
async (type, data, options) => {
const response = await connection.request(type, data, options);
if (response.type === "GetStates") {
setFreshContacts((contacts) =>
removeMissingContacts(
contacts,
response as ProtocolMessage<"GetStates">,
),
);
}
for (const interceptor of interceptorsRef.current) {
void Promise.resolve(interceptor({ type, data, response })).catch(
(error) => {
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
},
);
}
return response;
},
[connection, interceptorsRef],
);
const connected = snapshot.readyState === ConnectionState.Connected;
return (
<MTPContext.Provider
value={{
send,
subscribe,
addInterceptor,
readyState: snapshot.readyState,
identified: snapshot.identified,
freshContacts,
freshCommunities,
freshCalls,
contextReady: connected && snapshot.identified,
loadingDescription: connected
? "Waiting for authenticated session"
: "Establishing native transport channel",
}}
>
{props.children}
</MTPContext.Provider>
);
}

View file

@ -1,3 +1,8 @@
export const RETRY_INTERVAL = 3_000; export const RETRY_INTERVAL = 3_000;
export const RECONNECT_TRIES = 3; export const RECONNECT_TRIES = 3;
export const RECONNECT_RESET = 6; export const RECONNECT_RESET = 6;
export const RECONNECT_LONG_INTERVAL = 60_000;
export const RECONNECT_JITTER = 0.2;
export const DISCOVERY_TIMEOUT = 20_000;
export const INITIAL_SYNC_TIMEOUT = 120_000;
export const STATE_ACK_TIMEOUT = 30_000;

View file

@ -16,7 +16,6 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import { decryptChatText } from "@tensamin/crypto/chatSecret"; import { decryptChatText } from "@tensamin/crypto/chatSecret";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import { playSound } from "@tensamin/shared/sounds"; import { playSound } from "@tensamin/shared/sounds";
import { type RawMessage } from "@tensamin/chat/values";
export const context = createContext<contextType | undefined>(undefined); export const context = createContext<contextType | undefined>(undefined);
@ -30,7 +29,7 @@ async function requestNotificationPermission() {
} }
export default function Provider(props: { children: React.ReactNode }) { export default function Provider(props: { children: React.ReactNode }) {
const { subscribePush, send } = useMTP(); const { subscribe, send } = useMTP();
const { load } = useStorage(); const { load } = useStorage();
const { get } = useUser(); const { get } = useUser();
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat(); const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
@ -39,13 +38,7 @@ export default function Provider(props: { children: React.ReactNode }) {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
return subscribePush(async (message) => { return subscribe("MessageLive", async ({ data }) => {
if (message.type === "MessageLive") {
const data = message.data as {
Message?: RawMessage;
SenderId?: number;
};
if (!data.SenderId) return; if (!data.SenderId) return;
const isCurrentChat = const isCurrentChat =
@ -87,15 +80,9 @@ export default function Provider(props: { children: React.ReactNode }) {
moveUserIdToTop(data.SenderId); moveUserIdToTop(data.SenderId);
if (await load("settings.receive_confirmations")) { if (await load("settings.receive_confirmations")) {
void send( void send("MessageState", {
"MessageState",
{
MessageState: "received", MessageState: "received",
}, });
{
id: data.Message.SendTime,
},
);
} }
} }
@ -141,20 +128,30 @@ export default function Provider(props: { children: React.ReactNode }) {
const hasPermissions = await requestNotificationPermission(); const hasPermissions = await requestNotificationPermission();
if (hasPermissions) { if (hasPermissions) {
const notification = new Notification(user.Display, { const options: NotificationOptions = {
body: content, body: content,
icon: user.Avatar || user.Display.slice(0, 2).toUpperCase(), icon: user.Avatar || "/icons/icon-192.png",
badge: user.Avatar || user.Display.slice(0, 2).toUpperCase(), badge: "/icons/notification-badge.png",
tag: `message-${user.UserId}`, tag: `message-${user.UserId}`,
silent: true, silent: true,
};
if ("serviceWorker" in navigator) {
const registration =
await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.showNotification(user.Display, {
...options,
data: { url: `/chat?id=${user.UserId}` },
}); });
return;
}
}
const notification = new Notification(user.Display, options);
notification.onclick = () => { notification.onclick = () => {
window.focus(); window.focus();
navigate({ navigate({
to: `/chat?id=${user.UserId}`, to: `/chat?id=${user.UserId}`,
}); });
notification.close(); notification.close();
}; };
} else { } else {
@ -175,8 +172,6 @@ export default function Provider(props: { children: React.ReactNode }) {
} }
} }
}); });
return;
}
}); });
}, [ }, [
addLiveMessage, addLiveMessage,
@ -187,7 +182,7 @@ export default function Provider(props: { children: React.ReactNode }) {
navigate, navigate,
send, send,
moveUserIdToTop, moveUserIdToTop,
subscribePush, subscribe,
getChatSecret, getChatSecret,
userId, userId,
]); ]);

View file

@ -21,17 +21,37 @@ export {
type OnboardingStepControls, type OnboardingStepControls,
} from "@methanium/ui"; } from "@methanium/ui";
interface GateState { interface GateState {
docs: z.infer<typeof legalDocsSchema>; docs: z.infer<typeof legalDocsSchema>;
acceptedPP: boolean; acceptedPP: boolean;
acceptedTOS: boolean; acceptedTOS: boolean;
changedPP: boolean;
changedTOS: boolean;
includeLegal: boolean; includeLegal: boolean;
includeOnboarding: boolean; includeOnboarding: boolean;
includeTauriPermissions: boolean; includeTauriPermissions: boolean;
} }
async function fetchLegalDocumentHash(document: string) {
const response = await fetch(
`https://legal.methanium.net/tensamin/${document}/raw`,
);
if (!response.ok) {
throw new Error(`Legal document request failed: ${response.status}`);
}
if (!response.headers.get("content-type")?.startsWith("text/plain")) {
throw new Error("Legal document request returned an invalid content type");
}
const hash = await crypto.subtle.digest(
"SHA-256",
await response.arrayBuffer(),
);
return Array.from(new Uint8Array(hash), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
export default function OnboardingGate({ children }: { children: ReactNode }) { export default function OnboardingGate({ children }: { children: ReactNode }) {
const { load, save } = useStorage(); const { load, save } = useStorage();
const [state, setState] = useState<GateState>(); const [state, setState] = useState<GateState>();
@ -51,25 +71,9 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
void (async () => { void (async () => {
try { try {
const response = await fetch("https://legal.tensamin.net/api/current");
if (!response.ok) {
throw new Error(`Legal documents request failed: ${response.status}`);
}
const current: unknown = await response.json();
if (!active) return;
const parsed = legalDocsSchema.safeParse(current);
if (!parsed.success) {
setError("Failed to load legal documents");
setErrorDescription(
"The legal documents data received from the server is invalid. Please try again later.",
);
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
return;
}
const [ const [
ppHash,
tosHash,
localDocs, localDocs,
acceptedPP, acceptedPP,
acceptedTOS, acceptedTOS,
@ -77,6 +81,8 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
onboardingStarted, onboardingStarted,
tauriPermissionsDone, tauriPermissionsDone,
] = await Promise.all([ ] = await Promise.all([
fetchLegalDocumentHash("privacy-policy"),
fetchLegalDocumentHash("terms-of-service"),
load("legal_docs"), load("legal_docs"),
load("accepted_privacy_policy"), load("accepted_privacy_policy"),
load("accepted_terms_of_service"), load("accepted_terms_of_service"),
@ -87,10 +93,16 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
if (!active) return; if (!active) return;
const docs = legalDocsSchema.parse({
pp: { hash: ppHash },
tos: { hash: tosHash },
});
const changedPP = acceptedPP && localDocs.pp.hash !== docs.pp.hash;
const changedTOS = acceptedTOS && localDocs.tos.hash !== docs.tos.hash;
const currentAcceptedPP = const currentAcceptedPP =
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash; acceptedPP && localDocs.pp.hash === docs.pp.hash;
const currentAcceptedTOS = const currentAcceptedTOS =
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash; acceptedTOS && localDocs.tos.hash === docs.tos.hash;
const existingUser = acceptedPP && acceptedTOS; const existingUser = acceptedPP && acceptedTOS;
const includeOnboarding = const includeOnboarding =
!onboardingDone && (!existingUser || onboardingStarted); !onboardingDone && (!existingUser || onboardingStarted);
@ -104,9 +116,11 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
} }
setState({ setState({
docs: parsed.data, docs,
acceptedPP: currentAcceptedPP, acceptedPP: currentAcceptedPP,
acceptedTOS: currentAcceptedTOS, acceptedTOS: currentAcceptedTOS,
changedPP,
changedTOS,
includeLegal: !currentAcceptedPP || !currentAcceptedTOS, includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
includeOnboarding, includeOnboarding,
includeTauriPermissions: includeTauriPermissions:
@ -168,16 +182,28 @@ export default function OnboardingGate({ children }: { children: ReactNode }) {
const steps: OnboardingStep[] = []; const steps: OnboardingStep[] = [];
if (state.includeLegal) { if (state.includeLegal) {
const changedDocuments = [
state.changedPP && "Privacy Policy",
state.changedTOS && "Terms of Service",
].filter(Boolean);
steps.push({ steps.push({
id: "legal", id: "legal",
title: "Privacy Policy & ToS", title:
description: `${state.docs.pp.version} / ${state.docs.tos.version}`, changedDocuments.length > 0
? "Legal documents changed"
: "Privacy Policy & ToS",
description:
changedDocuments.length > 0
? changedDocuments.join(" & ")
: "Review and accept our legal documents",
defaultCanContinue: false, defaultCanContinue: false,
content: ( content: (
<LegalPage <LegalPage
docs={state.docs}
initiallyAcceptedPP={state.acceptedPP} initiallyAcceptedPP={state.acceptedPP}
initiallyAcceptedTOS={state.acceptedTOS} initiallyAcceptedTOS={state.acceptedTOS}
changedPP={state.changedPP}
changedTOS={state.changedTOS}
onAccept={acceptLegal} onAccept={acceptLegal}
/> />
), ),

View file

@ -1,21 +1,17 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Checkbox, Label, Link } from "@methanium/ui"; import { Checkbox, Label, Link, useOnboardingStep } from "@methanium/ui";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import type { z } from "zod";
import { useOnboardingStep } from "@methanium/ui";
export default function LegalPage({ export default function LegalPage({
docs,
initiallyAcceptedPP, initiallyAcceptedPP,
initiallyAcceptedTOS, initiallyAcceptedTOS,
changedPP,
changedTOS,
onAccept, onAccept,
}: { }: {
docs: z.infer<typeof legalDocsSchema>;
initiallyAcceptedPP: boolean; initiallyAcceptedPP: boolean;
initiallyAcceptedTOS: boolean; initiallyAcceptedTOS: boolean;
changedPP: boolean;
changedTOS: boolean;
onAccept: () => Promise<void>; onAccept: () => Promise<void>;
}) { }) {
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP); const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
@ -34,34 +30,61 @@ export default function LegalPage({
return ( return (
<div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24"> <div className="mx-auto flex min-h-[calc(100dvh-17rem)] w-full max-w-5xl flex-col gap-10 p-2 py-20 md:min-h-[calc(100dvh-20.5rem)] md:p-24">
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-start gap-2"> <div className="flex w-full max-w-xl flex-col items-start gap-8">
<BigCheckbox {!initiallyAcceptedPP && (
<LegalDocumentAcceptance
id="acceptPP" id="acceptPP"
name="Privacy Policy"
link="https://legal.methanium.net/tensamin/privacy-policy/"
changed={changedPP}
checked={acceptedPP} checked={acceptedPP}
onChange={setAcceptedPP} onChange={setAcceptedPP}
label="I agree to the Privacy Policy"
/> />
<BigCheckbox )}
{!initiallyAcceptedTOS && (
<LegalDocumentAcceptance
id="acceptTOS" id="acceptTOS"
name="Terms of Service"
link="https://legal.methanium.net/tensamin/terms-of-service/"
changed={changedTOS}
checked={acceptedTOS} checked={acceptedTOS}
onChange={setAcceptedTOS} onChange={setAcceptedTOS}
label="I agree to the Terms of Service"
/>
<div className="w-full border-t-2" />
<Link
label="Privacy Policy"
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
/>
<Link
label="Terms of Service"
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
/> />
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
function LegalDocumentAcceptance({
id,
name,
link,
changed,
checked,
onChange,
}: {
id: string;
name: string;
link: string;
changed: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex w-full flex-col items-start gap-3">
<Link label={`Read the ${name}`} link={link} />
<BigCheckbox
id={id}
checked={checked}
onChange={onChange}
label={`I agree to the ${changed ? `updated ${name}` : name}`}
/>
</div>
);
}
function BigCheckbox({ function BigCheckbox({
id, id,
label, label,

View file

@ -1,8 +1,8 @@
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; import { Outlet, useLocation } from "@tanstack/react-router";
import { Button, ClearStorageButton, cn, useIsMobile } from "@methanium/ui"; import { Button, useIsMobile } from "@methanium/ui";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { settingsNavigation } from "./manifest"; import { SettingsSidebar } from "./sidebar";
export default function SettingsLayout() { export default function SettingsLayout() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -36,47 +36,3 @@ export default function SettingsLayout() {
</div> </div>
); );
} }
export function SettingsSidebar({
mobile = false,
className,
}: {
mobile?: boolean;
className?: string;
}) {
const navigate = useNavigate();
const categories = [
...new Set(settingsNavigation.map((page) => page.category)),
];
return (
<div
className={cn(
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
className,
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -8,46 +8,24 @@ import Profile from "./pages/profile";
import Security from "./pages/security"; import Security from "./pages/security";
import Theme from "./pages/theme"; import Theme from "./pages/theme";
import Hotkeys from "./pages/hotkeys"; import Hotkeys from "./pages/hotkeys";
import { settingsNavigation } from "./navigation";
const pageComponents = {
profile: Profile,
security: Security,
chat: Chat,
call: Call,
cache: Cache,
theme: Theme,
accessibility: Accessibility,
hotkeys: Hotkeys,
licenses: Licenses,
} as const;
export const settingsPages = [ export const settingsPages = [
{ path: "/", component: Index }, { path: "/", component: Index },
{ ...settingsNavigation.map((page) => ({
category: "account", ...page,
path: "profile", component: pageComponents[page.path],
label: "Profile", })),
component: Profile,
},
{
category: "account",
path: "security",
label: "Security",
component: Security,
},
{ category: "general", path: "chat", label: "Chat", component: Chat },
{ category: "general", path: "call", label: "Call", component: Call },
{ category: "application", path: "cache", label: "Cache", component: Cache },
{ category: "application", path: "theme", label: "Theme", component: Theme },
{
category: "application",
path: "accessibility",
label: "Accessibility",
component: Accessibility,
},
{
category: "application",
path: "hotkeys",
label: "Hotkeys",
component: Hotkeys,
},
{
category: "application",
path: "licenses",
label: "Licenses",
component: Licenses,
},
] as const; ] as const;
export const settingsNavigation = settingsPages.filter(
(page): page is Exclude<(typeof settingsPages)[number], { path: "/" }> =>
page.path !== "/",
);

View file

@ -0,0 +1,31 @@
export const settingsNavigation = [
{
category: "account",
path: "profile",
label: "Profile",
},
{
category: "account",
path: "security",
label: "Security",
},
{ category: "general", path: "chat", label: "Chat" },
{ category: "general", path: "call", label: "Call" },
{ category: "application", path: "cache", label: "Cache" },
{ category: "application", path: "theme", label: "Theme" },
{
category: "application",
path: "accessibility",
label: "Accessibility",
},
{
category: "application",
path: "hotkeys",
label: "Hotkeys",
},
{
category: "application",
path: "licenses",
label: "Licenses",
},
] as const;

View file

@ -1,4 +1,4 @@
import { SettingsSidebar } from "../layout"; import { SettingsSidebar } from "../sidebar";
export default function Page() { export default function Page() {
return ( return (

View file

@ -0,0 +1,48 @@
import { Button, ClearStorageButton, cn } from "@methanium/ui";
import { useNavigate } from "@tanstack/react-router";
import { settingsNavigation } from "./navigation";
export function SettingsSidebar({
mobile = false,
className,
}: {
mobile?: boolean;
className?: string;
}) {
const navigate = useNavigate();
const categories = [
...new Set(settingsNavigation.map((page) => page.category)),
];
return (
<div
className={cn(
mobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6",
className,
)}
>
{categories.map((category) => (
<div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation
.filter((page) => page.category === category)
.map((page) => (
<Button
key={page.path}
className="w-full"
variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })}
>
{page.label}
</Button>
))}
</div>
))}
<div className="mt-auto">
<ClearStorageButton className="w-full" />
</div>
</div>
);
}

View file

@ -580,20 +580,11 @@ export const storageDefaults: Storage = {
analytics_done: false, analytics_done: false,
...settingsStorageDefaults, ...settingsStorageDefaults,
legal_docs: { legal_docs: {
eula: {
version: "0.0",
hash: "000000000000",
unix: 0,
},
tos: { tos: {
version: "0.0", hash: "0000000000000000000000000000000000000000000000000000000000000000",
hash: "000000000000",
unix: 0,
}, },
pp: { pp: {
version: "0.0", hash: "0000000000000000000000000000000000000000000000000000000000000000",
hash: "000000000000",
unix: 0,
}, },
}, },
cached_contacts: [], cached_contacts: [],

View file

@ -1,13 +1,10 @@
import { z } from "zod"; import { z } from "zod";
const legalDocSchema = z.object({ const legalDocSchema = z.object({
version: z.string().regex(/^\d+\.\d+$/), hash: z.string().regex(/^[a-f0-9]{64}$/),
hash: z.string().regex(/^[a-f0-9]{12}$/),
unix: z.number().int().positive(),
}); });
export const legalDocsSchema = z.object({ export const legalDocsSchema = z.object({
eula: legalDocSchema,
tos: legalDocSchema, tos: legalDocSchema,
pp: legalDocSchema, pp: legalDocSchema,
}); });

View file

@ -6,7 +6,9 @@
"exports": { "exports": {
"./session": "./src/session.tsx", "./session": "./src/session.tsx",
"./context": "./src/context.tsx", "./context": "./src/context.tsx",
"./secure": "./src/secure.ts" "./secure": "./src/secure.ts",
"./browserSecure": "./src/browserSecure.ts",
"./credentials": "./src/credentials.ts"
}, },
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",

View file

@ -0,0 +1,40 @@
import { getDatabaseEntry } from "@tensamin/shared/indexedDb";
type SecureEnvelope = {
__tensaminSecure: 1;
version: 1;
iv: string;
data: string;
};
function base64ToBytes(value: string) {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function isSecureEnvelope(value: unknown): value is SecureEnvelope {
if (!value || typeof value !== "object") return false;
const envelope = value as Partial<SecureEnvelope>;
return (
envelope.__tensaminSecure === 1 &&
envelope.version === 1 &&
typeof envelope.iv === "string" &&
typeof envelope.data === "string"
);
}
export async function loadSecureBrowserValue<T>(key: string) {
const stored = await getDatabaseEntry<unknown>("storage", key);
if (stored === undefined || !isSecureEnvelope(stored)) {
return stored as T | undefined;
}
const masterKey = await getDatabaseEntry<CryptoKey>("keys", "master-v1");
if (!masterKey) throw new Error("Secure storage key is unavailable.");
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64ToBytes(stored.iv) },
masterKey,
base64ToBytes(stored.data),
);
return JSON.parse(new TextDecoder().decode(plaintext)) as T;
}

View file

@ -30,7 +30,7 @@ import {
export type SaveOptions = { secure?: boolean }; export type SaveOptions = { secure?: boolean };
interface StorageContextValue { export interface StorageContextValue {
load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>; load<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
save<K extends keyof StorageSchema>( save<K extends keyof StorageSchema>(
key: K, key: K,

View file

@ -0,0 +1,68 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
import type { StorageContextValue } from "./context";
export function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
const content = rawFileContent.trim();
const separator = content.indexOf("::");
if (separator <= 0 || separator !== content.lastIndexOf("::")) {
throw new Error("Invalid file");
}
const identity = content.slice(0, separator);
const privateKey = content.slice(separator + 2).trim();
const [userIdValue, domain, ...extraDomainParts] = identity.split("@");
const userId = Number(userIdValue);
if (
!Number.isSafeInteger(userId) ||
userId <= 0 ||
!privateKey ||
extraDomainParts.length > 0 ||
(identity.includes("@") && !domain)
) {
throw new Error("Invalid file");
}
return { userId, privateKey, domain: domain ?? null };
}
export async function persistMtpCredentials({
storage,
userId,
keyring,
domain,
}: {
storage: Pick<StorageContextValue, "load" | "save">;
userId: number;
keyring: string;
domain?: string | null;
}) {
const omegaUrl = domain
? `https://${domain}/`
: await storage.load("omega_url");
if (domain) await storage.save("omega_url", omegaUrl);
if (isTauri()) {
const [forcedOmikronUrl, forcedOmikronPublicKey] = await Promise.all([
storage.load("forced_omikron_url"),
storage.load("forced_omikron_public_key"),
]);
await invoke("mtp_store_credentials", {
config: {
userId,
keyring,
omegaUrl,
forcedOmikronUrl,
forcedOmikronPublicKey,
},
});
}
await storage.save("mtp_keyring", keyring, { secure: true });
await storage.save("session_id", Date.now());
await storage.save("user_id", userId);
}

View file

@ -4,10 +4,7 @@ import { getDatabaseEntry, setDatabaseEntry } from "@tensamin/shared/indexedDb";
export type SecureStorageStatus = { export type SecureStorageStatus = {
backend: backend:
| "electron-keyring" "electron-keyring" | "application-storage" | "webcrypto" | "indexeddb";
| "application-storage"
| "webcrypto"
| "indexeddb";
secure: boolean; secure: boolean;
reason?: string; reason?: string;
}; };
@ -71,7 +68,12 @@ async function getKey() {
if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") { if (!globalThis.crypto?.subtle || typeof indexedDB === "undefined") {
return null; return null;
} }
if (window.tensaminDesktop?.secureStorage) return loadElectronKey(); if (
typeof window !== "undefined" &&
window.tensaminDesktop?.secureStorage
) {
return loadElectronKey();
}
return loadBrowserKey(); return loadBrowserKey();
})().catch(() => null); })().catch(() => null);
return keyPromise; return keyPromise;
@ -120,7 +122,10 @@ export async function decodeSecureValue(value: unknown): Promise<unknown> {
} }
export async function getSecureStorageStatus(): Promise<SecureStorageStatus> { export async function getSecureStorageStatus(): Promise<SecureStorageStatus> {
const desktop = window.tensaminDesktop?.secureStorage; const desktop =
typeof window === "undefined"
? undefined
: window.tensaminDesktop?.secureStorage;
if (desktop?.getStatus) { if (desktop?.getStatus) {
const status = await desktop.getStatus(); const status = await desktop.getStatus();
if (status.available) return { backend: "electron-keyring", secure: true }; if (status.available) return { backend: "electron-keyring", secure: true };

View file

@ -9,13 +9,12 @@ import {
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { useMTP, type ProtocolMessage } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { import {
clientUserStateSchema, clientUserStateSchema,
mtp as schemas, mtp as schemas,
publicUserStateSchema, publicUserStateSchema,
userStateEntrySchema,
} from "@tensamin/shared/data"; } from "@tensamin/shared/data";
import type z from "zod"; import type z from "zod";
import { createCache } from "@tensamin/cache"; import { createCache } from "@tensamin/cache";
@ -89,7 +88,7 @@ export default function UserProvider(props: { children: ReactNode }) {
); );
const revisionsRef = useRef(new Map<number, Map<UserField, number>>()); const revisionsRef = useRef(new Map<number, Map<UserField, number>>());
const { send, subscribePush } = useMTP(); const { send, subscribe: subscribeMTP } = useMTP();
const { load } = useStorage(); const { load } = useStorage();
const { contacts } = useSession(); const { contacts } = useSession();
const [accountId, setAccountId] = useState<number | null>(null); const [accountId, setAccountId] = useState<number | null>(null);
@ -174,39 +173,6 @@ export default function UserProvider(props: { children: ReactNode }) {
[publishUser], [publishUser],
); );
const handleStatePush = useCallback(
async (message: ProtocolMessage) => {
if (!accountId) return;
const data = message.data as Record<string, unknown>;
if (message.type === "GetStates") {
if (Array.isArray(data.MissingUserIds)) {
for (const userId of data.MissingUserIds) {
if (typeof userId === "number") removePresence(userId);
}
}
if (!Array.isArray(data.UserStates)) return;
for (const entry of data.UserStates) {
const parsed = userStateEntrySchema.safeParse(entry);
if (!parsed.success) continue;
if (parsed.data.UserId === accountId) continue;
initialStatesRef.current.set(
parsed.data.UserId,
parsed.data.UserState,
);
if (applyUserState(parsed.data.UserId, parsed.data.UserState)) {
initialStatesRef.current.delete(parsed.data.UserId);
}
}
return;
}
if (message.type !== "ClientChanged") return;
const parsed = schemas.ClientChanged.response.safeParse(data);
if (!parsed.success) return;
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
},
[accountId, applyUserState, removePresence],
);
useEffect(() => { useEffect(() => {
void load("user_id").then((accountId) => { void load("user_id").then((accountId) => {
accountIdRef.current = accountId; accountIdRef.current = accountId;
@ -223,8 +189,24 @@ export default function UserProvider(props: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!accountId) return; if (!accountId) return;
return subscribePush(handleStatePush); const unsubscribeStates = subscribeMTP("GetStates", ({ data }) => {
}, [accountId, handleStatePush, subscribePush]); for (const userId of data.MissingUserIds ?? []) removePresence(userId);
for (const entry of data.UserStates) {
if (!entry || entry.UserId === accountId) continue;
initialStatesRef.current.set(entry.UserId, entry.UserState);
if (applyUserState(entry.UserId, entry.UserState)) {
initialStatesRef.current.delete(entry.UserId);
}
}
});
const unsubscribeChanged = subscribeMTP("ClientChanged", ({ data }) => {
applyUserState(data.UserId, data.UserState, true);
});
return () => {
unsubscribeStates();
unsubscribeChanged();
};
}, [accountId, applyUserState, removePresence, subscribeMTP]);
const loadUser = useCallback( const loadUser = useCallback(
async (userId: number): Promise<User> => { async (userId: number): Promise<User> => {

3530
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -6,5 +6,5 @@ allowBuilds:
electron-winstaller: true electron-winstaller: true
esbuild: true esbuild: true
overrides: overrides:
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.28/methanium-ui.tgz" "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-dev-a692bed/mtp-0.2.0.tgz" mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz"

View file

@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry); const fullPath = join(dir, entry);
if (!statSync(fullPath).isDirectory()) continue; if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) { if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath); dirs.push(fullPath);

View file

@ -16,7 +16,14 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry); const fullPath = join(dir, entry);
if (!statSync(fullPath).isDirectory()) continue; if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) { if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath); dirs.push(fullPath);

View file

@ -15,7 +15,14 @@ function getPackageDirs(dir: string): string[] {
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry); const fullPath = join(dir, entry);
if (!statSync(fullPath).isDirectory()) continue; if (entry === "node_modules" || entry.startsWith(".")) continue;
let stats;
try {
stats = statSync(fullPath);
} catch {
continue;
}
if (!stats.isDirectory()) continue;
if (existsSync(join(fullPath, "package.json"))) { if (existsSync(join(fullPath, "package.json"))) {
dirs.push(fullPath); dirs.push(fullPath);