Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2ac4813a | |||
|
0c0a341f1e |
|||
|
f74ca52f4f |
|||
|
|
94f11f60f6 |
||
|
|
f5461bbd42 |
||
|
7c9d9529b4 |
|||
|
8a04b327fc |
|||
|
0a304e44f2 |
|||
|
094cb910aa |
|||
|
cf17365c8f |
|||
|
aa34bd962b |
|||
|
1d1b304bcf |
|||
|
|
0fbedf6733 | ||
|
96b81d30b1 |
|||
|
edcf0e15dd |
|||
|
5056f084b4 |
|||
|
ede0199858 |
|||
|
88a094c8bf |
|||
|
e43e03ff12 |
|||
|
97270abed8 |
|||
|
2a55c87df1 |
|||
|
|
57c7ceb27a | ||
|
|
d0918ff384 | ||
|
caa7d65b84 |
|||
| 0ac20f09e4 | |||
|
7b36218ffa |
|||
| 73db7e6b2b | |||
|
6e977bca7e |
|||
|
eaf3f61ebd |
|||
|
6f4cc62530 |
|||
|
eaeff1b8d5 |
|||
|
4016181db9 |
|||
| 2299555fe6 | |||
| f50b4eaebe | |||
|
825be4eb33 |
|||
|
1c90a0c859 |
|||
| 6dee7e8dcc | |||
| 41258a8483 | |||
| 746f0cc9a4 | |||
| c1baf3576d | |||
| 7ffbab6b08 | |||
| f1af0f210d | |||
| a40bf3a2c2 | |||
|
dbb407606b |
|||
| f763af13be | |||
|
f83e71be6b |
|||
|
b7c94123fa |
|||
|
32671f32fd |
|||
|
2b4207f8fa |
|||
|
5f44b89043 |
|||
|
31ff5a542f |
|||
|
99c38162a6 |
|||
|
ef24ea4ca0 |
|||
|
45d39cb47c |
|||
|
c182f2e9ac |
|||
|
ec019a4dff |
|||
|
b76143e2e2 |
|||
| 01b68300ab | |||
| 3954d776ce | |||
| 860fd2ec08 | |||
| dfb82373c6 | |||
| 927a719281 | |||
| 7f64659e23 | |||
| 35769b1aae | |||
| a731f8ee42 | |||
|
|
beafb623ba | ||
|
|
ac1bdf5ac2 | ||
|
22a4cd2f0e |
|||
|
3725587232 |
|||
| f0dcfbc63e | |||
| 00ff0b00d4 | |||
| 4d72469806 | |||
|
|
9ae7dbe35e | ||
|
|
2c405af72d | ||
|
652dab0351 |
|||
|
1ea13acc3b |
|||
| 29e80d6d02 | |||
| f3ca5d637c | |||
| 565f39c255 | |||
| 5090b67deb | |||
|
2061c3c393 |
|||
|
83427a344e |
|||
|
6f9ae31f7d |
|||
|
c442803412 |
|||
|
d59f9455bf |
|||
|
6dcec11426 |
|||
|
bda3bdcd0a |
|||
|
8946da05cc |
|||
| 60fbe3c9ec | |||
| 03735f9230 | |||
| 27b60005cc | |||
| 80d74b3ac7 | |||
| 3e80a37566 | |||
| 2f8bf376b7 | |||
| cc8cd9c017 | |||
|
1b17158ad5 |
|||
| bb25ed83ef | |||
| 959f6638ad | |||
| 48f04adfd9 | |||
| 0e2995454e | |||
| acc48c1207 | |||
| d66b404368 | |||
| 5914419aee | |||
| 625314cb5a | |||
| 6c145d293d | |||
| 4e97368f2f | |||
| d07202386f | |||
| a71a9effae | |||
| 7b3367cff0 | |||
| f14bfe6233 | |||
| 2faf100fa2 | |||
| faebd27286 | |||
| 5b2ac5315f | |||
| e61be9e5e0 | |||
| d30f7100b1 | |||
| e35fd3d96d | |||
|
f2343297e0 |
|||
|
5a7d5008a3 |
|||
| 51e83b8d9e | |||
| 9d5517fe30 | |||
| a88bda43eb | |||
| 16a288725d | |||
|
d30de8b404 |
|||
|
be8ea09cd6 |
|||
| 8da83a4900 | |||
|
2dc5be85e3 |
|||
| e11f46bd7c | |||
| 2ead5e17be | |||
|
611a4944b6 |
|||
| cf38260a87 | |||
| a6775b1d14 | |||
|
b5c5dd722b |
|||
| 5fb884edb0 | |||
| 52fcf7ca02 | |||
| 014bb0480a | |||
| 3bc4f49678 | |||
|
089fd152de |
|||
|
5044947525 |
|||
|
4a841de073 |
|||
| 43fd0beb8e | |||
| 07e31d1ad6 | |||
|
|
4c2b0e4c99 | ||
|
|
329a35bcfb | ||
| 70932ca135 | |||
| c1b10ef737 | |||
|
336b9464c9 |
|||
|
13fa3d8db7 |
|||
|
4cb38264d0 |
|||
|
560e829cab |
|||
|
ab577283f9 |
|||
|
85633a1c81 |
|||
|
915568f739 |
|||
|
b97f554c32 |
|||
|
|
dcb5d43056 | ||
|
|
cdc8d31505 | ||
| aee2a0963d | |||
|
1fc55ea562 |
|||
|
3ef0380122 |
|||
|
62aaa7c01d |
|||
|
b5ce3c554d |
|||
|
37b2f8747d |
|||
|
d355c83c02 |
|||
|
6f03604c90 |
|||
|
f1874042ba |
|||
|
5a26c2d401 |
|||
|
45e7591e40 |
|||
|
184b85190b |
|||
|
6a5236bafe |
|||
|
53728b7436 |
|||
|
5ece90ef4b |
|||
|
3666902ac9 |
|||
|
676dea2aa4 |
|||
|
a43c05f035 |
|||
|
781a90d2d1 |
|||
|
0dff15cc28 |
|||
|
5602b0a916 |
|||
|
6d865a93e8 |
|||
|
ace7973dff |
|||
|
5e79893137 |
|||
|
cfabda5d28 |
|||
|
25c7ee7c26 |
|||
|
f9f8d5c6c1 |
|||
|
9e037d8500 |
|||
|
17db895203 |
|||
|
bf8f449f03 |
|||
|
|
bf17f59a95 | ||
|
f41cf7cfa4 |
|||
|
|
41bea6b107 | ||
| e44863e9cb | |||
|
|
4a23f8455f | ||
| 37a9f97361 | |||
|
|
d545b91844 | ||
| b57bc0683c | |||
| 73ff854f0f | |||
|
|
abc0cceb56 | ||
| 67756255db | |||
| 257ef320d8 | |||
| 4a53d5c6dc | |||
|
|
089e6c5b28 | ||
| 00598348ab | |||
|
|
0c06516984 | ||
| 8f4f356746 |
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"entry": [
|
||||
"src/index.{ts,tsx,js,jsx}",
|
||||
"src/main.{ts,tsx,js,jsx}",
|
||||
"apps/pwa/src/serviceWorker.ts",
|
||||
"apps/tauri/render-version.ts",
|
||||
"packages/**/*.test.ts"
|
||||
],
|
||||
|
|
|
|||
60
.forgejo/workflows/dependency-builds.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Dependency builds
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
FORGEJO_TOKEN: ""
|
||||
GITHUB_TOKEN: ""
|
||||
|
||||
jobs:
|
||||
web:
|
||||
if: ${{ github.actor == 'rasensprenger' }}
|
||||
name: Build web
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: git submodule update --init --recursive
|
||||
- run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
- run: nix develop .#electron --command pnpm run build:packages
|
||||
- run: nix develop .#electron --command pnpm run build:web
|
||||
|
||||
desktop:
|
||||
if: ${{ github.actor == 'rasensprenger' }}
|
||||
name: Build desktop
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: git submodule update --init --recursive
|
||||
- run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
- run: nix develop .#electron --command pnpm run build:packages
|
||||
- run: nix develop .#electron --command pnpm run build:desktop
|
||||
|
||||
native-mtp:
|
||||
if: ${{ github.actor == 'rasensprenger' }}
|
||||
name: Test native MTP
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: git submodule update --init --recursive
|
||||
- run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
- run: nix develop .#tauri --command pnpm run build:packages
|
||||
- run: nix develop .#tauri --command pnpm --dir apps/tauri run build:mobile:ci
|
||||
|
|
@ -1,23 +1,21 @@
|
|||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths-ignore:
|
||||
- flake.nix
|
||||
|
||||
env:
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -36,12 +34,12 @@ jobs:
|
|||
build-mobile:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -56,8 +54,6 @@ jobs:
|
|||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||
run: |
|
||||
nix profile add nixpkgs#gnused
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
|
|
@ -122,12 +118,12 @@ jobs:
|
|||
matrix:
|
||||
target: [linux]
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -172,14 +168,14 @@ jobs:
|
|||
runs-on: nixos
|
||||
needs: [build-web, build-mobile, build-desktop]
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,16 @@ on:
|
|||
paths-ignore:
|
||||
- flake.nix
|
||||
|
||||
env:
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
|
||||
jobs:
|
||||
build-web:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -37,12 +34,12 @@ jobs:
|
|||
build-mobile:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -57,8 +54,6 @@ jobs:
|
|||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||
run: |
|
||||
nix profile add nixpkgs#gnused
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||
|
|
@ -123,12 +118,12 @@ jobs:
|
|||
matrix:
|
||||
target: [linux]
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -171,12 +166,12 @@ jobs:
|
|||
runs-on: nixos
|
||||
needs: [build-web, build-mobile, build-desktop]
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -316,58 +311,3 @@ jobs:
|
|||
"$API/repos/$REPO/releases/$release_id"
|
||||
done < "$DELETE_RELEASES"
|
||||
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
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ node_modules
|
|||
releases
|
||||
.fallow
|
||||
.direnv
|
||||
keystore.jks
|
||||
keystore.properties
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "mtp-type-maps"]
|
||||
path = mtp-type-maps
|
||||
url = https://git.methanium.net/tensamin/mtp-type-maps
|
||||
25
LICENSE
|
|
@ -1,16 +1,15 @@
|
|||
Copyright (c) [2025] [Methanium]
|
||||
Copyright (c) 2025 Methanium
|
||||
|
||||
All rights reserved.
|
||||
|
||||
This software is protected by copyright. Copying, editing,
|
||||
distributing, publicly performing, or any other use of this software
|
||||
or its components, in source or binary form, is strictly prohibited without the express
|
||||
written permission of the copyright holder.
|
||||
No part of this software, source code, documentation, or
|
||||
associated materials may be copied, reproduced, modified,
|
||||
distributed, published, sublicensed, sold, or used to create
|
||||
derivative works without prior written permission from the
|
||||
copyright holder.
|
||||
|
||||
FUTURE LICENSE ACCEPTANCE:
|
||||
It is the copyright holder's intention to release this software in the future
|
||||
under a license yet to be defined, which will, among other things,
|
||||
allow private, non-commercial use. This statement does not constitute
|
||||
a current license grant and does not alter the above
|
||||
prohibition on use, copying, or modification. Until the formal
|
||||
publication of such a future license, all rights remain
|
||||
reserved.
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||
|
|
|
|||
6
TODO
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
- Add a bunch of tests
|
||||
- Full accessability
|
||||
- Add settings saving & update onboarding to use it
|
||||
- Add onboarding page to load one profile during onboarding
|
||||
|
||||
-> Most folders in packages/ have specific todos
|
||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
|
@ -28,12 +28,11 @@
|
|||
"validate:raw": "pnpm run build && pnpm run package:linux:raw",
|
||||
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#electron --command pnpm run validate:raw; else pnpm run validate:raw; fi"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.25.11",
|
||||
"@types/node": "^26.1.2",
|
||||
"electron": "^43.3.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"build": {
|
||||
|
|
@ -95,7 +94,11 @@
|
|||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "build/icons/icon.icns"
|
||||
"icon": "build/icons/icon.icns",
|
||||
"extendInfo": {
|
||||
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
|
||||
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls."
|
||||
}
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
session,
|
||||
shell,
|
||||
|
|
@ -13,6 +14,7 @@ import { checkForUpdates } from "./updates.js";
|
|||
import {
|
||||
ipcChannels,
|
||||
type DesktopCallStatus,
|
||||
type DesktopGlobalHotkeyBinding,
|
||||
type DesktopScreenShareAudioOutput,
|
||||
type DesktopScreenShareCapabilities,
|
||||
} from "../shared/ipc.js";
|
||||
|
|
@ -29,6 +31,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||
const verbose = process.argv.includes("--verbose");
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let selectedScreenShareSourceId: string | null = null;
|
||||
let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = [];
|
||||
let globalHotkeysSuspended = false;
|
||||
|
||||
app.setName("tensamin");
|
||||
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
||||
|
|
@ -46,6 +50,13 @@ if (
|
|||
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
||||
}
|
||||
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
process.env.XDG_SESSION_TYPE === "wayland"
|
||||
) {
|
||||
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
|
||||
}
|
||||
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
process.env.XDG_SESSION_TYPE === "wayland" &&
|
||||
|
|
@ -180,6 +191,29 @@ function registerDisplayMediaHandler() {
|
|||
);
|
||||
}
|
||||
|
||||
function registerMediaPermissionHandler() {
|
||||
const isTrustedRenderer = (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "file:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler(
|
||||
(_webContents, permission, requestingOrigin) =>
|
||||
permission === "media" && isTrustedRenderer(requestingOrigin),
|
||||
);
|
||||
session.defaultSession.setPermissionRequestHandler(
|
||||
(_webContents, permission, callback, details) => {
|
||||
callback(
|
||||
permission === "media" && isTrustedRenderer(details.requestingUrl),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
verboseLog("registering ipc handlers");
|
||||
|
||||
|
|
@ -219,6 +253,24 @@ function registerIpc() {
|
|||
deleteSecureStorage(key),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setGlobalHotkeyBindings,
|
||||
(event, bindings: unknown) => {
|
||||
assertTrustedRenderer(event);
|
||||
return setGlobalHotkeyBindings(bindings);
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setGlobalHotkeysSuspended,
|
||||
(event, suspended: unknown) => {
|
||||
assertTrustedRenderer(event);
|
||||
if (typeof suspended !== "boolean") {
|
||||
throw new Error("Invalid hotkey suspension state.");
|
||||
}
|
||||
globalHotkeysSuspended = suspended;
|
||||
return applyGlobalHotkeyBindings();
|
||||
},
|
||||
);
|
||||
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
||||
if (
|
||||
typeof status !== "object" ||
|
||||
|
|
@ -259,6 +311,90 @@ function registerIpc() {
|
|||
});
|
||||
}
|
||||
|
||||
function assertTrustedRenderer(event: Electron.IpcMainInvokeEvent) {
|
||||
const target = mainWindow;
|
||||
if (
|
||||
!target ||
|
||||
target.isDestroyed() ||
|
||||
event.sender !== target.webContents ||
|
||||
event.senderFrame !== target.webContents.mainFrame
|
||||
) {
|
||||
throw new Error("Untrusted hotkey IPC sender.");
|
||||
}
|
||||
|
||||
try {
|
||||
if (fileURLToPath(event.senderFrame.url) === getRendererIndex()) return;
|
||||
} catch {
|
||||
// Fall through to the rejection below.
|
||||
}
|
||||
throw new Error("Untrusted hotkey IPC sender.");
|
||||
}
|
||||
|
||||
function validGlobalHotkeyBindings(
|
||||
value: unknown,
|
||||
): value is DesktopGlobalHotkeyBinding[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= 64 &&
|
||||
value.every(
|
||||
(binding) =>
|
||||
binding &&
|
||||
typeof binding === "object" &&
|
||||
typeof (binding as DesktopGlobalHotkeyBinding).id === "string" &&
|
||||
/^[a-z0-9.-]+$/i.test((binding as DesktopGlobalHotkeyBinding).id) &&
|
||||
(binding as DesktopGlobalHotkeyBinding).id.length > 0 &&
|
||||
(binding as DesktopGlobalHotkeyBinding).id.length <= 128 &&
|
||||
typeof (binding as DesktopGlobalHotkeyBinding).accelerator ===
|
||||
"string" &&
|
||||
(binding as DesktopGlobalHotkeyBinding).accelerator.length > 0 &&
|
||||
(binding as DesktopGlobalHotkeyBinding).accelerator.length <= 128,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function applyGlobalHotkeyBindings() {
|
||||
globalShortcut.unregisterAll();
|
||||
const statuses = Object.fromEntries(
|
||||
globalHotkeyBindings.map(({ id }) => [id, false]),
|
||||
);
|
||||
if (globalHotkeysSuspended) return statuses;
|
||||
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const { id, accelerator } of globalHotkeyBindings) {
|
||||
const ids = grouped.get(accelerator) ?? [];
|
||||
ids.push(id);
|
||||
grouped.set(accelerator, ids);
|
||||
}
|
||||
|
||||
for (const [accelerator, ids] of grouped) {
|
||||
let registered = false;
|
||||
try {
|
||||
registered = globalShortcut.register(accelerator, () => {
|
||||
const target = mainWindow;
|
||||
if (!target || target.isDestroyed()) return;
|
||||
ids.forEach((id) =>
|
||||
target.webContents.send(ipcChannels.globalHotkeyTriggered, id),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to register global hotkey", accelerator, error);
|
||||
}
|
||||
ids.forEach((id) => {
|
||||
statuses[id] = registered;
|
||||
});
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function setGlobalHotkeyBindings(bindings: unknown) {
|
||||
if (!validGlobalHotkeyBindings(bindings)) {
|
||||
throw new Error("Invalid global hotkey bindings.");
|
||||
}
|
||||
globalHotkeyBindings = bindings;
|
||||
return applyGlobalHotkeyBindings();
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const rendererIndex = getRendererIndex();
|
||||
verboseLog("creating main window", {
|
||||
|
|
@ -344,6 +480,10 @@ app.on("activate", () => {
|
|||
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
||||
});
|
||||
|
||||
app.on("will-quit", () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[tensamin:electron] uncaught exception", error);
|
||||
|
|
@ -360,6 +500,7 @@ async function start() {
|
|||
verboseLog("app ready");
|
||||
registerIpc();
|
||||
registerDisplayMediaHandler();
|
||||
registerMediaPermissionHandler();
|
||||
initTray(() => mainWindow);
|
||||
await createWindow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function getTrayIconPath(filename: string) {
|
||||
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
|
||||
if (app.isPackaged)
|
||||
return path.join(process.resourcesPath, "icons", filename);
|
||||
return path.resolve(__dirname, "../../build/icons", filename);
|
||||
}
|
||||
|
||||
export function setTrayCallStatus(
|
||||
inCall: boolean,
|
||||
iconDataUrl?: string,
|
||||
) {
|
||||
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
|
||||
if (!tray) return;
|
||||
|
||||
if (inCall && iconDataUrl) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
|
|||
import {
|
||||
ipcChannels,
|
||||
type DesktopCallStatus,
|
||||
type DesktopGlobalHotkeyBinding,
|
||||
type DesktopScreenShareSource,
|
||||
secureStorageLimits,
|
||||
} from "../shared/ipc.js";
|
||||
|
|
@ -55,6 +56,23 @@ const desktopApi = {
|
|||
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
||||
},
|
||||
},
|
||||
hotkeys: {
|
||||
setBindings: (bindings: DesktopGlobalHotkeyBinding[]) =>
|
||||
ipcRenderer.invoke(ipcChannels.setGlobalHotkeyBindings, bindings),
|
||||
setSuspended: (suspended: boolean) =>
|
||||
typeof suspended === "boolean"
|
||||
? ipcRenderer.invoke(ipcChannels.setGlobalHotkeysSuspended, suspended)
|
||||
: Promise.reject(new Error("Invalid hotkey suspension state.")),
|
||||
onTriggered: (callback: (id: string) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, id: unknown) => {
|
||||
if (typeof id === "string") callback(id);
|
||||
};
|
||||
ipcRenderer.on(ipcChannels.globalHotkeyTriggered, listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(ipcChannels.globalHotkeyTriggered, listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
secureStorage: {
|
||||
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
||||
load: (key: string) =>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = {
|
|||
backend: string | null;
|
||||
};
|
||||
|
||||
export type DesktopGlobalHotkeyBinding = {
|
||||
id: string;
|
||||
accelerator: string;
|
||||
};
|
||||
|
||||
export const secureStorageLimits = {
|
||||
maxKeyBytes: 256,
|
||||
maxValueBytes: 1024 * 1024,
|
||||
|
|
@ -77,4 +82,7 @@ export const ipcChannels = {
|
|||
saveSecureStorage: "secureStorage:save",
|
||||
deleteSecureStorage: "secureStorage:delete",
|
||||
clearSecureStorage: "secureStorage:clear",
|
||||
setGlobalHotkeyBindings: "hotkeys:setBindings",
|
||||
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
|
||||
globalHotkeyTriggered: "hotkeys:triggered",
|
||||
} as const;
|
||||
|
|
|
|||
36
apps/pwa/package.json
Normal 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
|
|
@ -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 />;
|
||||
}
|
||||
161
apps/pwa/src/serviceWorker.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"]
|
||||
}
|
||||
21
apps/pwa/tsconfig.worker.json
Normal 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"]
|
||||
}
|
||||
3
apps/tauri/.gitignore
vendored
|
|
@ -24,6 +24,3 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
.android
|
||||
|
||||
/src-tauri/gen/android/keystore.properties
|
||||
/src-tauri/gen/android/keystore.jks
|
||||
|
|
|
|||
BIN
apps/tauri/monochrome_cropped.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -4,23 +4,16 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./controls": {
|
||||
"types": "./src/windowControls.tsx",
|
||||
"default": "./src/windowControls.tsx"
|
||||
},
|
||||
"./deeplinkHandler": {
|
||||
"types": "./src/deeplinkHandler.tsx",
|
||||
"default": "./src/deeplinkHandler.tsx"
|
||||
},
|
||||
"./qrCodeScanner": {
|
||||
"types": "./src/qrCodeScanner.tsx",
|
||||
"default": "./src/qrCodeScanner.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
||||
"dev:mobile:raw": "adb reverse tcp:3000 tcp:3000 && tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
||||
"start-adb:mobile:raw": "adb devices",
|
||||
"build:mobile:raw": "tauri android build",
|
||||
"build:mobile:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug",
|
||||
"dev:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run dev:mobile:raw; else pnpm run dev:mobile:raw; fi",
|
||||
"start-adb:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run start-adb:mobile:raw; else pnpm run start-adb:mobile:raw; fi",
|
||||
"build:mobile": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run build:mobile:raw; else pnpm run build:mobile:raw; fi",
|
||||
|
|
@ -29,18 +22,15 @@
|
|||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "~2",
|
||||
"@tauri-apps/plugin-log": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@methanium/ui": "*",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-deep-link": "~2.4.9",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^25.9.1"
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^26.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
apps/tauri/scripts/delete-mobile.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
const devicesResult = spawnSync("adb", ["devices", "-l"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (devicesResult.status !== 0) {
|
||||
process.stderr.write(devicesResult.stderr);
|
||||
process.exit(devicesResult.status ?? 1);
|
||||
}
|
||||
|
||||
const devices = devicesResult.stdout
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /\sdevice(?:\s|$)/.test(line));
|
||||
|
||||
if (devices.length === 0) {
|
||||
console.error("No connected ADB devices found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let selectedDevice = devices[0];
|
||||
|
||||
if (devices.length > 1) {
|
||||
console.log("Select a device:");
|
||||
devices.forEach((device, index) => console.log(`${index + 1}) ${device}`));
|
||||
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const answer = await readline.question("Device: ");
|
||||
readline.close();
|
||||
|
||||
const selectedIndex = Number(answer) - 1;
|
||||
if (!Number.isInteger(selectedIndex) || !devices[selectedIndex]) {
|
||||
console.error("Invalid device selection.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
selectedDevice = devices[selectedIndex];
|
||||
}
|
||||
|
||||
const serial = selectedDevice.split(/\s+/, 1)[0];
|
||||
const uninstallResult = spawnSync(
|
||||
"adb",
|
||||
["-s", serial, "uninstall", "net.tensamin.client.dev"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
process.exit(uninstallResult.status ?? 1);
|
||||
2423
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -15,12 +15,17 @@ name = "mobile_lib"
|
|||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] }
|
||||
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
|
||||
webpki-root-certs = "1"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-log = "2"
|
||||
|
|
@ -30,10 +35,14 @@ version = "2"
|
|||
features = []
|
||||
default-features = true
|
||||
|
||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||
tauri-plugin-barcode-scanner = "2"
|
||||
tauri-plugin-app-events = "0.2"
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
|
||||
version = "2"
|
||||
features = []
|
||||
default-features = true
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.22"
|
||||
|
||||
[patch.crates-io.tauri]
|
||||
git = "https://github.com/tauri-apps/tauri"
|
||||
branch = "feat/cef"
|
||||
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@
|
|||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:default",
|
||||
"core:event:default",
|
||||
"deep-link:default",
|
||||
"notification:default",
|
||||
"log:default"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
{
|
||||
"identifier": "mobile-capability",
|
||||
"platforms": [
|
||||
"android",
|
||||
"iOS"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"platforms": ["android", "iOS"],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:event:default",
|
||||
"deep-link:default",
|
||||
"app-events:default",
|
||||
"barcode-scanner:default",
|
||||
"barcode-scanner:allow-scan",
|
||||
"barcode-scanner:allow-cancel",
|
||||
"notification:default",
|
||||
"log:default"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
|
|
@ -61,26 +62,29 @@ android {
|
|||
)
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_1_8
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.webkit:webkit:1.16.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.google.android.material:material:1.14.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@
|
|||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
@ -39,6 +49,30 @@
|
|||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MediaProjectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name=".MtpForegroundService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the user-enabled encrypted messaging connection and receives incoming messages" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".MtpBootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
|
|||
|
|
@ -1,28 +1,114 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.view.MotionEvent
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private var contentRoot: FrameLayout? = null
|
||||
private var contentChild: android.view.View? = null
|
||||
private var previousUsableHeight = 0
|
||||
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
private var mediaWebView: WebView? = null
|
||||
private var pendingScreenAudio: Boolean? = null
|
||||
|
||||
private val screenCaptureLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val includeAudio = pendingScreenAudio
|
||||
pendingScreenAudio = null
|
||||
|
||||
if (includeAudio == null) return@registerForActivityResult
|
||||
val data = result.data
|
||||
if (result.resultCode != Activity.RESULT_OK || data == null) {
|
||||
MobileMediaEvents.emitError("Screen capture permission was denied")
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
|
||||
}
|
||||
|
||||
private val cameraPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) {
|
||||
emitCameraPermission()
|
||||
}
|
||||
|
||||
private val screenAudioPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) {
|
||||
launchScreenCaptureIntent()
|
||||
}
|
||||
|
||||
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)
|
||||
mediaWebView = webView
|
||||
MobileMediaEvents.attach(webView)
|
||||
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
super.onCreate(savedInstanceState)
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||
NativeMtpBridge.startService(this)
|
||||
}
|
||||
NativeAccessibilityBridge.nativeAttach(applicationContext)
|
||||
installKeyboardResizeWorkaround()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
NativeMtpBridge.nativeSetUiState(true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NativeMtpBridge.nativeSetUiState(false)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
attachLayoutListener?.let { listener ->
|
||||
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
||||
|
|
@ -30,9 +116,87 @@ class MainActivity : TauriActivity() {
|
|||
attachLayoutListener = null
|
||||
contentRoot = null
|
||||
contentChild = null
|
||||
mediaWebView?.let {
|
||||
NativeAccessibilityBridge.detach(it)
|
||||
it.removeJavascriptInterface("tensaminMobileMedia")
|
||||
}
|
||||
mediaWebView = null
|
||||
MobileMediaEvents.detach()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startScreenShare(includeAudio: Boolean) {
|
||||
runOnUiThread {
|
||||
if (pendingScreenAudio != null) {
|
||||
MobileMediaEvents.emitError("Screen capture permission is already pending")
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
pendingScreenAudio = includeAudio
|
||||
if (
|
||||
includeAudio &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
} else {
|
||||
launchScreenCaptureIntent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchScreenCaptureIntent() {
|
||||
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
|
||||
}
|
||||
|
||||
private fun stopScreenShare() {
|
||||
runOnUiThread {
|
||||
pendingScreenAudio = null
|
||||
MediaProjectionService.stop(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestCameraPermission() {
|
||||
runOnUiThread {
|
||||
cameraPermissionLauncher.launch(
|
||||
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitCameraPermission() {
|
||||
val detail = JSONObject()
|
||||
.put(
|
||||
"camera",
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
.put(
|
||||
"microphone",
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
|
||||
}
|
||||
|
||||
private inner class MobileMediaJavascriptInterface {
|
||||
@JavascriptInterface
|
||||
fun startScreenShare(includeAudio: Boolean) {
|
||||
this@MainActivity.startScreenShare(includeAudio)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopScreenShare() {
|
||||
this@MainActivity.stopScreenShare()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestCameraPermission() {
|
||||
this@MainActivity.requestCameraPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installKeyboardResizeWorkaround() {
|
||||
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
||||
contentRoot = content
|
||||
|
|
@ -68,16 +232,20 @@ class MainActivity : TauriActivity() {
|
|||
child: android.view.View,
|
||||
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
|
||||
) {
|
||||
val visibleFrame = Rect()
|
||||
child.getWindowVisibleDisplayFrame(visibleFrame)
|
||||
|
||||
val rootHeight = child.rootView.height
|
||||
if (rootHeight <= 0) return
|
||||
|
||||
val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
|
||||
val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom)
|
||||
val keyboardVisible = keyboardHeight > rootHeight * 0.15
|
||||
val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT
|
||||
val imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true
|
||||
val imeHeight = if (imeVisible) {
|
||||
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val usableHeight = if (imeHeight in 1 until rootHeight) {
|
||||
rootHeight - imeHeight
|
||||
} else {
|
||||
WindowManager.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
|
||||
if (previousUsableHeight == usableHeight) return
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioPlaybackCaptureConfiguration
|
||||
import android.media.AudioRecord
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.media.ImageReader
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.util.Base64
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.json.JSONObject
|
||||
|
||||
class MediaProjectionService : Service() {
|
||||
private var projection: MediaProjection? = null
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var audioThread: Thread? = null
|
||||
private val captureActive = AtomicBoolean(false)
|
||||
private var lastFrameAt = 0L
|
||||
|
||||
private val projectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
stopCapture(stopProjection = false, emitStopped = true)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopCapture(stopProjection = true, emitStopped = true)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val permissionData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA)
|
||||
}
|
||||
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE) ?: Int.MIN_VALUE
|
||||
if (permissionData == null || resultCode == Int.MIN_VALUE) {
|
||||
MobileMediaEvents.emitError("Screen capture permission data is missing")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val includeAudio = intent?.getBooleanExtra(EXTRA_INCLUDE_AUDIO, false) ?: false
|
||||
try {
|
||||
startForegroundNotification()
|
||||
startCapture(resultCode, permissionData, includeAudio)
|
||||
} catch (error: Throwable) {
|
||||
stopCapture(stopProjection = true, emitStopped = false)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to start screen capture")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopCapture(stopProjection = true, emitStopped = true)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Screen sharing",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val stopIntent = Intent(this, MediaProjectionService::class.java).setAction(ACTION_STOP)
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_share)
|
||||
.setContentTitle("Tensamin is sharing your screen")
|
||||
.setContentText("Tap Stop to end screen sharing")
|
||||
.setOngoing(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startCapture(resultCode: Int, permissionData: Intent, includeAudio: Boolean) {
|
||||
stopCapture(stopProjection = true, emitStopped = false)
|
||||
|
||||
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||
val newProjection = manager.getMediaProjection(resultCode, permissionData)
|
||||
?: error("Android did not provide a screen capture session")
|
||||
projection = newProjection
|
||||
|
||||
val thread = HandlerThread("tensamin-screen-capture").also { it.start() }
|
||||
captureThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
newProjection.registerCallback(projectionCallback, handler)
|
||||
|
||||
val metrics = DisplayMetrics()
|
||||
getSystemService(android.view.WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
|
||||
val displayWidth = metrics.widthPixels
|
||||
val displayHeight = metrics.heightPixels
|
||||
val width = minOf(displayWidth, MAX_FRAME_WIDTH)
|
||||
val height = (displayHeight.toLong() * width / displayWidth).toInt()
|
||||
val density = metrics.densityDpi
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
imageReader = reader
|
||||
reader.setOnImageAvailableListener({ source -> captureFrame(source, width, height) }, handler)
|
||||
|
||||
virtualDisplay = newProjection.createVirtualDisplay(
|
||||
"Tensamin screen sharing",
|
||||
width,
|
||||
height,
|
||||
density,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
reader.surface,
|
||||
null,
|
||||
handler,
|
||||
)
|
||||
captureActive.set(true)
|
||||
|
||||
val audioStarted = includeAudio && startAudioCapture(newProjection)
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-started",
|
||||
JSONObject().put("includeAudio", audioStarted),
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureFrame(source: ImageReader, width: Int, height: Int) {
|
||||
val image = source.acquireLatestImage() ?: return
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!captureActive.get() || now - lastFrameAt < FRAME_INTERVAL_MS) return
|
||||
lastFrameAt = now
|
||||
|
||||
val plane = image.planes[0]
|
||||
val paddedWidth = plane.rowStride / plane.pixelStride
|
||||
val paddedBitmap = Bitmap.createBitmap(paddedWidth, height, Bitmap.Config.ARGB_8888)
|
||||
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
|
||||
val croppedBitmap = if (paddedWidth == width) {
|
||||
paddedBitmap
|
||||
} else {
|
||||
Bitmap.createBitmap(paddedBitmap, 0, 0, width, height).also { paddedBitmap.recycle() }
|
||||
}
|
||||
val outputBitmap = croppedBitmap
|
||||
|
||||
val bytes = ByteArrayOutputStream()
|
||||
outputBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, bytes)
|
||||
val outputWidth = outputBitmap.width
|
||||
val outputHeight = outputBitmap.height
|
||||
outputBitmap.recycle()
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-frame",
|
||||
JSONObject()
|
||||
.put("data", Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP))
|
||||
.put("mimeType", "image/jpeg")
|
||||
.put("width", outputWidth)
|
||||
.put("height", outputHeight),
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (captureActive.get()) {
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to read a screen frame")
|
||||
}
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAudioCapture(activeProjection: MediaProjection): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
MobileMediaEvents.emitError("System audio capture requires Android 10 or newer")
|
||||
return false
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
MobileMediaEvents.emitError("Microphone permission is required for system audio capture")
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(AUDIO_SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
val configuration = AudioPlaybackCaptureConfiguration.Builder(activeProjection)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.build()
|
||||
val minimumBuffer = AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
check(minimumBuffer > 0) { "Android could not allocate a system audio buffer" }
|
||||
val bufferSize = maxOf(minimumBuffer * 2, AUDIO_BATCH_BYTES)
|
||||
val record = AudioRecord.Builder()
|
||||
.setAudioFormat(format)
|
||||
.setAudioPlaybackCaptureConfig(configuration)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
check(record.state == AudioRecord.STATE_INITIALIZED) {
|
||||
"Android could not initialize system audio capture"
|
||||
}
|
||||
audioRecord = record
|
||||
record.startRecording()
|
||||
audioThread = Thread({ readAudio(record) }, "tensamin-audio-capture").also { it.start() }
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to capture system audio")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAudio(record: AudioRecord) {
|
||||
val buffer = ByteArray(AUDIO_BATCH_BYTES)
|
||||
while (captureActive.get() && !Thread.currentThread().isInterrupted) {
|
||||
val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||
if (read > 0 && captureActive.get()) {
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-audio",
|
||||
JSONObject()
|
||||
.put("data", Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP))
|
||||
.put("sampleRate", AUDIO_SAMPLE_RATE)
|
||||
.put("channelCount", 1)
|
||||
.put("encoding", "pcm16le"),
|
||||
)
|
||||
} else if (read < 0 && captureActive.get()) {
|
||||
MobileMediaEvents.emitError("System audio capture stopped with code $read")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopCapture(stopProjection: Boolean, emitStopped: Boolean) {
|
||||
val wasActive = captureActive.getAndSet(false)
|
||||
|
||||
val record = audioRecord
|
||||
audioRecord = null
|
||||
try {
|
||||
record?.stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
}
|
||||
record?.release()
|
||||
audioThread?.interrupt()
|
||||
audioThread = null
|
||||
|
||||
imageReader?.setOnImageAvailableListener(null, null)
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
|
||||
val oldProjection = projection
|
||||
projection = null
|
||||
oldProjection?.unregisterCallback(projectionCallback)
|
||||
if (stopProjection) oldProjection?.stop()
|
||||
|
||||
captureThread?.quitSafely()
|
||||
captureThread = null
|
||||
lastFrameAt = 0L
|
||||
|
||||
if (wasActive && emitStopped) {
|
||||
MobileMediaEvents.emit("tensamin-mobile-screen-stopped")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_STOP = "net.tensamin.client.STOP_SCREEN_SHARE"
|
||||
private const val EXTRA_RESULT_CODE = "resultCode"
|
||||
private const val EXTRA_PERMISSION_DATA = "permissionData"
|
||||
private const val EXTRA_INCLUDE_AUDIO = "includeAudio"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "screen-sharing"
|
||||
private const val NOTIFICATION_ID = 7314
|
||||
private const val FRAME_INTERVAL_MS = 75L
|
||||
private const val MAX_FRAME_WIDTH = 1280
|
||||
private const val JPEG_QUALITY = 72
|
||||
private const val AUDIO_SAMPLE_RATE = 48_000
|
||||
private const val AUDIO_BATCH_BYTES = 9_600
|
||||
|
||||
fun start(context: Context, resultCode: Int, data: Intent, includeAudio: Boolean) {
|
||||
val intent = Intent(context, MediaProjectionService::class.java)
|
||||
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
.putExtra(EXTRA_PERMISSION_DATA, data)
|
||||
.putExtra(EXTRA_INCLUDE_AUDIO, includeAudio)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, MediaProjectionService::class.java).setAction(ACTION_STOP),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.webkit.WebView
|
||||
import java.lang.ref.WeakReference
|
||||
import org.json.JSONObject
|
||||
|
||||
object MobileMediaEvents {
|
||||
private var webView = WeakReference<WebView>(null)
|
||||
|
||||
fun attach(value: WebView) {
|
||||
webView = WeakReference(value)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
webView.clear()
|
||||
}
|
||||
|
||||
fun emitError(message: String) {
|
||||
emit("tensamin-mobile-screen-error", JSONObject().put("message", message))
|
||||
}
|
||||
|
||||
fun emit(name: String, detail: JSONObject = JSONObject()) {
|
||||
val view = webView.get() ?: return
|
||||
val script = "window.dispatchEvent(new CustomEvent(" +
|
||||
JSONObject.quote(name) +
|
||||
", { detail: " + detail.toString() + " }));"
|
||||
view.post { view.evaluateJavascript(script, null) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class MtpBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (
|
||||
intent.action == Intent.ACTION_BOOT_COMPLETED &&
|
||||
MtpSecureStore.isEnabled(context) &&
|
||||
MtpSecureStore.hasConfig(context)
|
||||
) {
|
||||
NativeMtpBridge.startService(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class MtpForegroundService : Service() {
|
||||
private var started = false
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = refreshNotification()
|
||||
override fun onLost(network: Network) = refreshNotification()
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
|
||||
refreshNotification()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
getSystemService(ConnectivityManager::class.java)
|
||||
.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
MtpSecureStore.setEnabled(this, false)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (started) return START_STICKY
|
||||
|
||||
createChannel(this)
|
||||
connectionStatus = "Connecting"
|
||||
val notification = buildNotification(this, displayedStatus(this))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
val config = MtpSecureStore.loadConfig(this)
|
||||
if (config == null || !MtpSecureStore.isEnabled(this)) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
try {
|
||||
NativeMtpBridge.nativeAttach(applicationContext)
|
||||
NativeMtpBridge.nativeStart(config)
|
||||
started = true
|
||||
NativeMtpBridge.log(2, "Started MTP foreground service")
|
||||
} catch (error: Throwable) {
|
||||
NativeMtpBridge.log(0, "Failed to start MTP foreground service", error)
|
||||
updateNotification(this, "Connection failed")
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val preferences = getSharedPreferences(SERVICE_PREFERENCES, Context.MODE_PRIVATE)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - preferences.getLong(LAST_TASK_RESTART, 0) < TASK_RESTART_COOLDOWN_MS) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
return
|
||||
}
|
||||
preferences.edit().putLong(LAST_TASK_RESTART, now).commit()
|
||||
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||
startService(Intent(this, MtpForegroundService::class.java))
|
||||
}
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// Tauri cannot recreate its WebView after the UI task is removed while this process survives.
|
||||
Process.killProcess(Process.myPid())
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
||||
if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun refreshNotification() {
|
||||
updateNotification(this, connectionStatus)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "tensamin-connection"
|
||||
private const val NOTIFICATION_ID = 2201
|
||||
private const val ACTION_STOP = "net.tensamin.client.STOP_MTP"
|
||||
private const val SERVICE_PREFERENCES = "tensamin-service"
|
||||
private const val LAST_TASK_RESTART = "last-task-restart"
|
||||
private const val TASK_RESTART_COOLDOWN_MS = 15_000L
|
||||
@Volatile private var connectionStatus = "Connecting"
|
||||
|
||||
fun updateNotification(context: Context, status: String) {
|
||||
if (!MtpSecureStore.isEnabled(context)) return
|
||||
connectionStatus = status
|
||||
createChannel(context)
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification(context, displayedStatus(context)))
|
||||
}
|
||||
|
||||
private fun displayedStatus(context: Context): String {
|
||||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
val network = connectivity.activeNetwork ?: return "No network"
|
||||
val capabilities = connectivity.getNetworkCapabilities(network) ?: return "No network"
|
||||
return if (
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
) connectionStatus else "No network"
|
||||
}
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Background connection",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Keeps Tensamin connected for incoming messages" },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(context: Context, status: String): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
context,
|
||||
1,
|
||||
Intent(context, MtpForegroundService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("Tensamin")
|
||||
.setContentText(status)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
object MtpSecureStore {
|
||||
private const val KEY_ALIAS = "tensamin-mtp-config"
|
||||
private const val PREFS = "tensamin-mtp"
|
||||
private const val CONFIG = "config"
|
||||
private const val ENABLED = "enabled"
|
||||
|
||||
fun saveConfig(context: Context, config: String) {
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
val encrypted = cipher.doFinal(config.toByteArray(Charsets.UTF_8))
|
||||
val payload = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
|
||||
preferences(context).edit().putString(CONFIG, payload).apply()
|
||||
}
|
||||
|
||||
fun loadConfig(context: Context): String? {
|
||||
val payload = preferences(context).getString(CONFIG, null) ?: return null
|
||||
return runCatching {
|
||||
val bytes = Base64.decode(payload, Base64.NO_WRAP)
|
||||
require(bytes.size > 12)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
getOrCreateKey(),
|
||||
GCMParameterSpec(128, bytes.copyOfRange(0, 12)),
|
||||
)
|
||||
String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun hasConfig(context: Context): Boolean = loadConfig(context) != null
|
||||
|
||||
fun setEnabled(context: Context, enabled: Boolean) {
|
||||
preferences(context).edit().putBoolean(ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
fun isEnabled(context: Context): Boolean =
|
||||
preferences(context).getBoolean(ENABLED, false)
|
||||
|
||||
private fun preferences(context: Context) =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
|
||||
val generator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore",
|
||||
)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.Keep
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
@Keep
|
||||
object NativeAccessibilityBridge {
|
||||
const val DEFAULT_INITIAL_SCALE = 290
|
||||
private const val MIN_INITIAL_SCALE = 210
|
||||
private const val MAX_INITIAL_SCALE = 500
|
||||
private const val PREFERENCES = "tensamin-accessibility"
|
||||
private const val INITIAL_SCALE = "initial-scale"
|
||||
|
||||
private var webView = WeakReference<WebView>(null)
|
||||
|
||||
init {
|
||||
System.loadLibrary("mobile_lib")
|
||||
}
|
||||
|
||||
@JvmStatic external fun nativeAttach(context: Context)
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = WeakReference(webView)
|
||||
webView.setInitialScale(getInitialScale(webView.context))
|
||||
}
|
||||
|
||||
fun detach(webView: WebView) {
|
||||
if (this.webView.get() === webView) this.webView.clear()
|
||||
}
|
||||
|
||||
fun getInitialScale(context: Context): Int {
|
||||
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
val storedScale = preferences.getInt(INITIAL_SCALE, DEFAULT_INITIAL_SCALE)
|
||||
val scale = storedScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
|
||||
if (scale != storedScale) preferences.edit().putInt(INITIAL_SCALE, scale).apply()
|
||||
return scale
|
||||
}
|
||||
|
||||
fun setInitialScale(context: Context, initialScale: Int) {
|
||||
val nextScale = initialScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
|
||||
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(INITIAL_SCALE, nextScale)
|
||||
.apply()
|
||||
webView.get()?.let { currentWebView ->
|
||||
currentWebView.post { currentWebView.setInitialScale(nextScale) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.Person
|
||||
import androidx.core.content.LocusIdCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
|
||||
@Keep
|
||||
object NativeMtpBridge {
|
||||
private const val MESSAGE_CHANNEL = "tensamin-messages"
|
||||
|
||||
init {
|
||||
System.loadLibrary("mobile_lib")
|
||||
}
|
||||
|
||||
@JvmStatic external fun nativeAttach(context: Context)
|
||||
@JvmStatic external fun nativeStart(config: String)
|
||||
@JvmStatic external fun nativeStop()
|
||||
@JvmStatic external fun nativeSetUiState(visible: Boolean)
|
||||
@JvmStatic external fun nativeLog(level: Int, message: String, details: String)
|
||||
|
||||
fun log(level: Int, message: String, error: Throwable? = null) {
|
||||
val details = error?.stackTraceToString().orEmpty()
|
||||
Log.println(if (level == 0) Log.ERROR else Log.INFO, "TensaminAndroid", "$message $details")
|
||||
nativeLog(level, message, details)
|
||||
}
|
||||
|
||||
fun storeConfig(context: Context, config: String) {
|
||||
try {
|
||||
MtpSecureStore.saveConfig(context, config)
|
||||
if (MtpSecureStore.isEnabled(context)) startService(context)
|
||||
log(2, "Stored native MTP credentials")
|
||||
} catch (error: Throwable) {
|
||||
log(0, "Failed to store native MTP credentials", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fun hasConfig(context: Context): Boolean = MtpSecureStore.hasConfig(context)
|
||||
|
||||
fun setServiceEnabled(context: Context, enabled: Boolean) {
|
||||
MtpSecureStore.setEnabled(context, enabled)
|
||||
if (enabled && MtpSecureStore.hasConfig(context)) startService(context) else stopService(context)
|
||||
}
|
||||
|
||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean =
|
||||
context.getSystemService(PowerManager::class.java)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
|
||||
fun requestBatteryExemption(context: Context) {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, MtpForegroundService::class.java),
|
||||
)
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
if (!context.stopService(Intent(context, MtpForegroundService::class.java))) nativeStop()
|
||||
}
|
||||
|
||||
fun updateServiceStatus(context: Context, status: String) {
|
||||
MtpForegroundService.updateNotification(context, status)
|
||||
}
|
||||
|
||||
fun postMessageNotification(
|
||||
context: Context,
|
||||
senderId: Long,
|
||||
sender: String,
|
||||
body: String,
|
||||
avatar: ByteArray,
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
MESSAGE_CHANNEL,
|
||||
"Messages",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply { description = "Incoming Tensamin messages" },
|
||||
)
|
||||
}
|
||||
val openIntent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("tensamin://chat?id=$senderId"),
|
||||
context,
|
||||
MainActivity::class.java,
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
senderId.hashCode(),
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val avatarBitmap = avatar.takeIf { it.isNotEmpty() }?.let {
|
||||
BitmapFactory.decodeByteArray(it, 0, it.size)
|
||||
}
|
||||
val avatarIcon = avatarBitmap?.let(IconCompat::createWithAdaptiveBitmap)
|
||||
val person = Person.Builder()
|
||||
.setName(sender)
|
||||
.setKey(senderId.toString())
|
||||
.setIcon(avatarIcon)
|
||||
.build()
|
||||
val shortcutId = "chat-$senderId"
|
||||
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
|
||||
.setShortLabel(sender)
|
||||
.setLongLived(true)
|
||||
.setPerson(person)
|
||||
.setIntent(openIntent)
|
||||
.apply { if (avatarIcon != null) setIcon(avatarIcon) }
|
||||
.build()
|
||||
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
|
||||
|
||||
val style = NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("You").build(),
|
||||
).addMessage(body, System.currentTimeMillis(), person)
|
||||
val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_notification_small)
|
||||
.setContentTitle(sender)
|
||||
.setContentText(body)
|
||||
.setStyle(style)
|
||||
.setShortcutId(shortcutId)
|
||||
.setLocusId(LocusIdCompat(shortcutId))
|
||||
.setLargeIcon(avatarBitmap)
|
||||
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.notify(senderId.hashCode(), notification)
|
||||
}
|
||||
|
||||
fun cancelMessageNotification(context: Context, senderId: Long) {
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.cancel(senderId.hashCode())
|
||||
}
|
||||
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
import com.android.build.gradle.LibraryExtension
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
|
|
@ -7,7 +7,7 @@ buildscript {
|
|||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,4 +20,3 @@ dependencies {
|
|||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import org.gradle.api.GradleException
|
|||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.process.ExecOperations
|
||||
import javax.inject.Inject
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
open class BuildTask @Inject constructor(
|
||||
private val execOperations: ExecOperations,
|
||||
) : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
|
|
@ -50,7 +54,7 @@ open class BuildTask : DefaultTask() {
|
|||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
execOperations.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
|
|
@ -65,4 +69,4 @@ open class BuildTask : DefaultTask() {
|
|||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
135
apps/tauri/src-tauri/src/accessibility_backend.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#[cfg(not(target_os = "android"))]
|
||||
const DEFAULT_INITIAL_SCALE: i32 = 290;
|
||||
const MIN_INITIAL_SCALE: i32 = 210;
|
||||
const MAX_INITIAL_SCALE: i32 = 500;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn accessibility_get_initial_scale() -> Result<i32, String> {
|
||||
android_get_initial_scale()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn accessibility_set_initial_scale(initial_scale: i32) -> Result<(), String> {
|
||||
if !(MIN_INITIAL_SCALE..=MAX_INITIAL_SCALE).contains(&initial_scale) {
|
||||
return Err(format!(
|
||||
"Initial scale must be between {MIN_INITIAL_SCALE} and {MAX_INITIAL_SCALE}"
|
||||
));
|
||||
}
|
||||
|
||||
android_set_initial_scale(initial_scale)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn android_get_initial_scale() -> Result<i32, String> {
|
||||
Ok(DEFAULT_INITIAL_SCALE)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn android_set_initial_scale(_: i32) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jni::{
|
||||
jni_sig, jni_str,
|
||||
objects::{Global, JClass, JObject, JValue},
|
||||
Env, EnvUnowned, JavaVM,
|
||||
};
|
||||
|
||||
struct Host {
|
||||
vm: JavaVM,
|
||||
context: Global<JObject<'static>>,
|
||||
bridge: Global<JObject<'static>>,
|
||||
}
|
||||
|
||||
static HOST: OnceLock<Host> = OnceLock::new();
|
||||
|
||||
fn attach(env: &mut Env, context: JObject) -> Result<(), String> {
|
||||
if HOST.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let class = env
|
||||
.find_class(jni_str!("net/tensamin/client/NativeAccessibilityBridge"))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let bridge = env
|
||||
.get_static_field(
|
||||
class,
|
||||
jni_str!("INSTANCE"),
|
||||
jni_sig!("Lnet/tensamin/client/NativeAccessibilityBridge;"),
|
||||
)
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
HOST.set(Host {
|
||||
vm: env.get_java_vm().map_err(|error| error.to_string())?,
|
||||
context: env
|
||||
.new_global_ref(context)
|
||||
.map_err(|error| error.to_string())?,
|
||||
bridge: env
|
||||
.new_global_ref(bridge)
|
||||
.map_err(|error| error.to_string())?,
|
||||
})
|
||||
.map_err(|_| "Android accessibility host is already attached".to_string())
|
||||
}
|
||||
|
||||
fn with_env<T>(call: impl FnOnce(&mut Env, &Host) -> Result<T, String>) -> Result<T, String> {
|
||||
let host = HOST
|
||||
.get()
|
||||
.ok_or("Android accessibility host is not attached")?;
|
||||
host.vm
|
||||
.attach_current_thread(|env| Ok::<_, jni::errors::Error>(call(env, host)))
|
||||
.map_err(|error| error.to_string())?
|
||||
}
|
||||
|
||||
pub fn get_initial_scale() -> Result<i32, String> {
|
||||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
jni_str!("getInitialScale"),
|
||||
jni_sig!("(Landroid/content/Context;)I"),
|
||||
&[JValue::Object(host.context.as_obj())],
|
||||
)
|
||||
.and_then(|value| value.i())
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_initial_scale(initial_scale: i32) -> Result<(), String> {
|
||||
with_env(|env, host| {
|
||||
env.call_method(
|
||||
host.bridge.as_obj(),
|
||||
jni_str!("setInitialScale"),
|
||||
jni_sig!("(Landroid/content/Context;I)V"),
|
||||
&[
|
||||
JValue::Object(host.context.as_obj()),
|
||||
JValue::Int(initial_scale),
|
||||
],
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_net_tensamin_client_NativeAccessibilityBridge_nativeAttach<
|
||||
'caller,
|
||||
>(
|
||||
mut env: EnvUnowned<'caller>,
|
||||
_class: JClass,
|
||||
context: JObject<'caller>,
|
||||
) {
|
||||
let _ = env.with_env(|env| {
|
||||
let _ = attach(env, context);
|
||||
Ok::<_, jni::errors::Error>(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use android::{
|
||||
get_initial_scale as android_get_initial_scale, set_initial_scale as android_set_initial_scale,
|
||||
};
|
||||
|
|
@ -1,21 +1,37 @@
|
|||
mod accessibility_backend;
|
||||
mod mtp_backend;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(tauri_plugin_log::log::LevelFilter::Info)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_notification::init());
|
||||
|
||||
let builder = builder
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
|
||||
|
||||
#[cfg(any(target_os = "ios", target_os = "android"))]
|
||||
let builder = builder.plugin(tauri_plugin_app_events::init());
|
||||
|
||||
if let Err(error) = builder
|
||||
let app = builder
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
accessibility_backend::accessibility_get_initial_scale,
|
||||
accessibility_backend::accessibility_set_initial_scale,
|
||||
mtp_backend::mtp_request,
|
||||
mtp_backend::mtp_status,
|
||||
mtp_backend::mtp_store_credentials,
|
||||
mtp_backend::mtp_has_credentials,
|
||||
mtp_backend::mtp_load_keyring,
|
||||
mtp_backend::mtp_set_enabled,
|
||||
mtp_backend::mtp_set_ui_visible,
|
||||
mtp_backend::mtp_post_message_notification,
|
||||
mtp_backend::mtp_is_ignoring_battery_optimizations,
|
||||
mtp_backend::mtp_request_battery_exemption,
|
||||
])
|
||||
.setup(|_app| {
|
||||
mtp_backend::manager().attach_app(_app.handle().clone());
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
{
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
|
@ -30,9 +46,18 @@ pub fn run() {
|
|||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
{
|
||||
eprintln!("error while running tauri application: {error}");
|
||||
panic!("error while running tauri application: {error}");
|
||||
}
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|_app, event| {
|
||||
#[cfg(target_os = "android")]
|
||||
if let tauri::RunEvent::ExitRequested {
|
||||
api, code: None, ..
|
||||
} = event
|
||||
{
|
||||
if mtp_backend::manager().is_enabled() {
|
||||
api.prevent_exit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use lib::log;
|
||||
|
||||
fn main() {
|
||||
mobile_lib::run();
|
||||
log("Test test 123")
|
||||
}
|
||||
|
|
|
|||
1563
apps/tauri/src-tauri/src/mtp_backend.rs
Normal file
|
|
@ -7,13 +7,13 @@ import {
|
|||
} from "react";
|
||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
|
||||
type DeeplinkContextValue = {
|
||||
|
||||
|
||||
export const deeplinkContext = createContext<{
|
||||
deeplinks: readonly string[];
|
||||
};
|
||||
|
||||
export const deeplinkContext = createContext<DeeplinkContextValue | undefined>(
|
||||
} | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import {
|
||||
scan,
|
||||
Format,
|
||||
requestPermissions,
|
||||
} from "@tauri-apps/plugin-barcode-scanner";
|
||||
import { Button } from "@tensamin/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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,8 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="./favicon.ico" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>Tensamin</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -6,139 +6,58 @@
|
|||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"dev": "vite --port 3000 --host 0.0.0.0",
|
||||
"dev": "vite",
|
||||
"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 .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.0.0",
|
||||
"@base-ui/utils": "0.3.1",
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@floating-ui/core": "^1.7.0",
|
||||
"@floating-ui/dom": "^1.7.0",
|
||||
"@floating-ui/react-dom": "^2.1.8",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"@radix-ui/primitive": "^1.1.0",
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-context": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.11",
|
||||
"@radix-ui/react-focus-guards": "^1.1.4",
|
||||
"@radix-ui/react-focus-scope": "^1.1.11",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-portal": "^1.1.13",
|
||||
"@radix-ui/react-presence": "^1.1.6",
|
||||
"@radix-ui/react-primitive": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-use-callback-ref": "^1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.3",
|
||||
"@radix-ui/react-use-effect-event": "^0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "^1.1.0",
|
||||
"@reduxjs/toolkit": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/devtools-event-client": "^0.3.0",
|
||||
"@tanstack/query-core": "^5.0.0",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tanstack/history": "1.162.0",
|
||||
"@tanstack/react-store": "^0.9.3",
|
||||
"@tanstack/router-core": "^1.169.1",
|
||||
"@tanstack/store": "^0.9.3",
|
||||
"@tanstack/virtual-core": "^3.13.24",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@fontsource-variable/public-sans": "^5.3.0",
|
||||
"@methanium/ui": "*",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-router": "^1.170.21",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tensamin/pwa": "workspace:*",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/hotkeys": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/notifications": "workspace:*",
|
||||
"@tensamin/onboarding": "workspace:*",
|
||||
"@tensamin/settings": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/tauth": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"@tensamin/notifications": "workspace:*",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cookie-es": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-format": "^3.1.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-path": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"d3-timer": "^3.0.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"detect-node-es": "^1.1.0",
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"embla-carousel": "8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"embla-carousel-reactive-utils": "8.6.0",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"get-nonce": "^1.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"internmap": "^2.0.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pngjs": "^5.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-is": "^19.0.0",
|
||||
"react-redux": "^9.0.0",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"recharts": "3.8.1",
|
||||
"redux": "^5.0.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "5.1.1",
|
||||
"scheduler": "^0.27.0",
|
||||
"seroval": "^1.5.4",
|
||||
"seroval-plugins": "^1.5.4",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"lucide-react": "^1.29.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-is": "^19.2.8",
|
||||
"react-redux": "^9.3.0",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"tslib": "^2.8.1",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"vaul": "^1.1.2",
|
||||
"victory-vendor": "^37.0.2",
|
||||
"yargs": "^15.3.1",
|
||||
"zod": "^4.3.6"
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"esbuild": "^0.25.11",
|
||||
"eslint": "^10.0.3",
|
||||
"globals": "^17.4.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.8.0",
|
||||
"globals": "^17.9.0",
|
||||
"mtp": "*",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.10"
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^8.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
BIN
apps/web/public/sounds/call_join.wav
Normal file
BIN
apps/web/public/sounds/call_leave.wav
Normal file
BIN
apps/web/public/sounds/message.wav
Normal file
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
|
|
@ -6,54 +6,66 @@ import {
|
|||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@tensamin/ui";
|
||||
import { Card, CardHeader } from "@tensamin/ui";
|
||||
import { Skeleton } from "@tensamin/ui";
|
||||
Button,
|
||||
} from "@methanium/ui";
|
||||
import { Skeleton } from "@methanium/ui";
|
||||
import { getStatusColor } from "@tensamin/shared/data";
|
||||
|
||||
export function Basic({
|
||||
user,
|
||||
extra,
|
||||
}: {
|
||||
user: User;
|
||||
user: Pick<
|
||||
User,
|
||||
"Display" | "Username" | "Avatar" | "OnlineStatus" | "Status"
|
||||
>;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
const display = user.Display || user.Username || "Unknown";
|
||||
const onlineStatus = user.OnlineStatus || "user_borked";
|
||||
const avatar = user.Avatar
|
||||
? `data:image/webp;base64,${user.Avatar}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
|
||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||
<div className="relative shrink-0 overflow-visible">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getStatusColor(user.OnlineStatus),
|
||||
}}
|
||||
className="h-2.25 w-2.25 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{user.OnlineStatus.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||
<p>{user.Display}</p>
|
||||
</div>
|
||||
<div className="pr-1">{extra}</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Button
|
||||
render={<div />}
|
||||
nativeButton={false}
|
||||
variant="outline"
|
||||
className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!"
|
||||
>
|
||||
<div className="relative shrink-0 overflow-visible">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getStatusColor(onlineStatus),
|
||||
}}
|
||||
className="h-2.25 w-2.25 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{onlineStatus
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start justify-center text-[15px]">
|
||||
<p>{display}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.Status}</p>
|
||||
</div>
|
||||
<div className="pr-1">{extra}</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import type { User } from "@tensamin/user/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
|
||||
import { Text } from "@methanium/ui/markdown";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Profile({ user }: { user: User }) {
|
||||
export default function Profile({
|
||||
user,
|
||||
}: {
|
||||
user: Pick<
|
||||
User,
|
||||
| "UserId"
|
||||
| "Display"
|
||||
| "Username"
|
||||
| "Avatar"
|
||||
| "About"
|
||||
| "IotaId"
|
||||
| "PublicKey"
|
||||
>;
|
||||
}) {
|
||||
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -24,7 +37,7 @@ export default function Profile({ user }: { user: User }) {
|
|||
<Text value={user.About || ""} />
|
||||
<Button
|
||||
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
aria-expanded={showAdvancedInformation}
|
||||
onClick={() => setShowAdvancedInformation((show) => !show)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,29 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
||||
} from "@methanium/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
House,
|
||||
Phone,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { joinCall, useCall } from "@tensamin/call/store";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { Skeleton } from "@tensamin/ui";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
|
||||
import { Skeleton } from "@methanium/ui";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@methanium/ui";
|
||||
import { displayCallId } from "@tensamin/call/utils";
|
||||
import { useState } from "react";
|
||||
import { SidebarTrigger, useSidebar } from "@tensamin/ui";
|
||||
import { WindowControls as Controls } from "@tensamin/ui";
|
||||
import { SidebarTrigger, useSidebar } from "@methanium/ui";
|
||||
import { WindowControls as Controls } from "@methanium/ui";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import Profile from "./modals/profile";
|
||||
|
||||
|
|
@ -35,50 +47,71 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
|
||||
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
||||
|
||||
const { callId } = useCall();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
||||
data-pwa-navbar
|
||||
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">
|
||||
{forMobile ? (
|
||||
<SidebarTrigger
|
||||
className="w-9 h-9 aspect-square rounded-lg ml-2"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="size-4.5" />
|
||||
</SidebarTrigger>
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className="w-9 h-9! ml-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="size-4.5" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<House className="size-4.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/settings" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="size-4.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isMobile && pathname === "/call" && callId && (
|
||||
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
|
||||
)}
|
||||
{pathname === "/chat" && id && (
|
||||
<Wrapper
|
||||
userId={id}
|
||||
fields={[
|
||||
"UserId",
|
||||
"Display",
|
||||
"Username",
|
||||
"Avatar",
|
||||
"About",
|
||||
"IotaId",
|
||||
"PublicKey",
|
||||
]}
|
||||
component={(user) =>
|
||||
isMobile ? (
|
||||
<p className="font-medium text-[1.07rem]">{user?.Display}</p>
|
||||
) : (
|
||||
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="link"
|
||||
className="px-0! text-foreground"
|
||||
className="text-foreground px-0! border-0! bg-none! bg-transparent!"
|
||||
style={{
|
||||
textDecorationLine: "none",
|
||||
}}
|
||||
|
|
@ -87,7 +120,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
{user?.Display}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent side="bottom">
|
||||
<Profile user={user} />
|
||||
|
|
@ -109,7 +142,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
onClick={() => {
|
||||
void joinCall(id);
|
||||
}}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Phone />
|
||||
|
|
@ -124,7 +157,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
currentCalls[0].CallId,
|
||||
);
|
||||
}}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
>
|
||||
<Phone />
|
||||
</Button>
|
||||
|
|
@ -161,6 +194,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{isMobile && pathname === "/call" && callId && (
|
||||
<Button variant="ghost">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
)}
|
||||
<Controls className="mr-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,7 +213,7 @@ export function MobileNavbar() {
|
|||
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
|
||||
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
|
||||
<SidebarTrigger
|
||||
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
|
||||
variant="link"
|
||||
>
|
||||
<User className="size-4.5" />
|
||||
|
|
@ -186,7 +224,7 @@ export function MobileNavbar() {
|
|||
navigate({ to: "/" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||
variant="link"
|
||||
>
|
||||
<House className="size-4.5" />
|
||||
|
|
@ -197,7 +235,7 @@ export function MobileNavbar() {
|
|||
navigate({ to: "/settings" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||
variant="link"
|
||||
>
|
||||
<Settings className="size-4.5" />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,24 @@
|
|||
import { Button, cn, useIsMobile } from "@tensamin/ui";
|
||||
import { Input } from "@tensamin/ui";
|
||||
import { Label } from "@tensamin/ui";
|
||||
import { Button, cn, useIsMobile } from "@methanium/ui";
|
||||
import { Input } from "@methanium/ui";
|
||||
import { Label } from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { File } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { z } from "zod";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
|
||||
import {
|
||||
parseTuFileContent,
|
||||
persistMtpCredentials,
|
||||
} from "@tensamin/storage/credentials";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const fetchedUser = z.object({
|
||||
id: z.uuidv4(),
|
||||
|
|
@ -27,86 +38,36 @@ const formSchema = z.object({
|
|||
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, privateKey] = rawFileContent.split("::");
|
||||
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");
|
||||
}
|
||||
|
||||
console.log({
|
||||
domain,
|
||||
userId,
|
||||
});
|
||||
|
||||
return { userId, privateKey, domain };
|
||||
}
|
||||
|
||||
export default function Form() {
|
||||
const isMobile = useIsMobile();
|
||||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const { save } = useStorage();
|
||||
const loginPendingRef = React.useRef(false);
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { load, save } = useStorage();
|
||||
const navigate = useNavigate();
|
||||
const loginPendingRef = useRef(false);
|
||||
|
||||
const persistLogin = React.useCallback(
|
||||
const persistLogin = useCallback(
|
||||
async (userId: number, privateKey: string, domain?: string | null) => {
|
||||
if (loginPendingRef.current) return false;
|
||||
loginPendingRef.current = true;
|
||||
try {
|
||||
if (domain) await save("omega_url", `https://${domain}/`);
|
||||
await save("mtp_keyring", privateKey, { secure: true });
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.protocol === "file:" ? "#/" : "/",
|
||||
);
|
||||
window.location.reload();
|
||||
await persistMtpCredentials({
|
||||
storage: { load, save },
|
||||
userId,
|
||||
keyring: privateKey,
|
||||
domain,
|
||||
});
|
||||
await navigate({ to: "/", replace: true });
|
||||
return true;
|
||||
} finally {
|
||||
loginPendingRef.current = false;
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[load, navigate, save],
|
||||
);
|
||||
|
||||
// Process dropped files
|
||||
const processDroppedFile = React.useCallback(
|
||||
const processDroppedFile = useCallback(
|
||||
async (file: globalThis.File): Promise<void> => {
|
||||
try {
|
||||
if (!file.name.endsWith(".tu")) {
|
||||
|
|
@ -126,9 +87,14 @@ export default function Form() {
|
|||
[persistLogin],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => subscribeTuFileLaunch(processDroppedFile),
|
||||
[processDroppedFile],
|
||||
);
|
||||
|
||||
// Handle .tu files
|
||||
const handleFileInputChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const handleFileInputChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
|
|
@ -142,7 +108,7 @@ export default function Form() {
|
|||
);
|
||||
|
||||
// Drag and drop listener
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
let dragCounter = 0;
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
|
|
@ -212,8 +178,8 @@ export default function Form() {
|
|||
* @param event Form submit event.
|
||||
* @returns Promise that resolves after login processing.
|
||||
*/
|
||||
const handleCredentialsSubmit = React.useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleCredentialsSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
|
@ -264,32 +230,10 @@ export default function Form() {
|
|||
|
||||
return (
|
||||
<div className="relative flex md:flex-row flex-col gap-15">
|
||||
{isTauri() && 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()}>
|
||||
Select .tu file
|
||||
</Button>
|
||||
</>
|
||||
{isMobile ? (
|
||||
<Button onClick={() => uploadRef.current?.click()}>
|
||||
Select .tu file
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
|
|
|
|||
|
|
@ -26,33 +26,28 @@ import {
|
|||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@tensamin/ui";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
import { MobileNavbar } from "./navbar";
|
||||
|
||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
|
||||
import { Ellipsis, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
import type z from "zod";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { useUser, type User } from "@tensamin/user/context";
|
||||
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import {
|
||||
onlineStatusLabels,
|
||||
onlineStatusOptions,
|
||||
type OnlineStatus,
|
||||
} from "./status-options";
|
||||
|
||||
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
|
||||
|
||||
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
||||
user_online: "Online",
|
||||
user_offline: "Offline",
|
||||
user_dnd: "Do not disturb",
|
||||
user_idle: "Idle",
|
||||
user_wc: "Away",
|
||||
user_borked: "Borked",
|
||||
iota_offline: "Iota offline",
|
||||
iota_online: "Iota online",
|
||||
iota_borked: "Iota borked",
|
||||
};
|
||||
function accountPreference(status: User["OnlineStatus"]): OnlineStatus {
|
||||
return userPresencePreferenceSchema.safeParse(status).success
|
||||
? (status as OnlineStatus)
|
||||
: "user_online";
|
||||
}
|
||||
|
||||
function StatusDialog({
|
||||
user,
|
||||
|
|
@ -68,7 +63,7 @@ function StatusDialog({
|
|||
saveSucceeded,
|
||||
setSaveSucceeded,
|
||||
}: {
|
||||
user: User;
|
||||
user: Pick<User, "UserId" | "OnlineStatus" | "Status">;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
send: ReturnType<typeof useMTP>["send"];
|
||||
|
|
@ -81,13 +76,15 @@ function StatusDialog({
|
|||
saveSucceeded: boolean;
|
||||
setSaveSucceeded: (value: boolean) => void;
|
||||
}) {
|
||||
const { updateProfile, updateState } = useUser();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(accountPreference(user.OnlineStatus));
|
||||
setErrorMessage("");
|
||||
setSaveSucceeded(false);
|
||||
}
|
||||
|
|
@ -126,10 +123,11 @@ function StatusDialog({
|
|||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="user_online">Online</SelectItem>
|
||||
<SelectItem value="user_offline">Offline</SelectItem>
|
||||
<SelectItem value="user_idle">Idle</SelectItem>
|
||||
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
||||
{onlineStatusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -140,12 +138,10 @@ function StatusDialog({
|
|||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const payload = {
|
||||
...(draftStatus && { status: draftStatus }),
|
||||
const validation = mtp.ChangeUserData.request.safeParse({
|
||||
OnlineStatus: draftOnlineStatus,
|
||||
};
|
||||
|
||||
const validation = mtp.ChangeUserData.request.safeParse(payload);
|
||||
Status: draftStatus,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
setSaveSucceeded(false);
|
||||
|
|
@ -157,6 +153,8 @@ function StatusDialog({
|
|||
|
||||
try {
|
||||
await send("ChangeUserData", validation.data);
|
||||
updateState(user.UserId, draftOnlineStatus);
|
||||
await updateProfile(user.UserId, { Status: draftStatus });
|
||||
setSaveSucceeded(true);
|
||||
setErrorMessage("");
|
||||
} catch (err) {
|
||||
|
|
@ -195,16 +193,20 @@ export default function Sidebar() {
|
|||
|
||||
const content = (
|
||||
<>
|
||||
<SidebarContent
|
||||
className={
|
||||
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
|
||||
}
|
||||
>
|
||||
<SidebarContent className="pt-2">
|
||||
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
|
||||
<div>
|
||||
<Wrapper
|
||||
loading={<Loading />}
|
||||
userId={"own"}
|
||||
fields={[
|
||||
"UserId",
|
||||
"Display",
|
||||
"Username",
|
||||
"Avatar",
|
||||
"OnlineStatus",
|
||||
"Status",
|
||||
]}
|
||||
component={(user) => (
|
||||
<>
|
||||
<Basic
|
||||
|
|
@ -227,7 +229,9 @@ export default function Sidebar() {
|
|||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(
|
||||
accountPreference(user.OnlineStatus),
|
||||
);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
setDialogOpen(true);
|
||||
|
|
@ -246,7 +250,9 @@ export default function Sidebar() {
|
|||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.Status ?? "");
|
||||
setDraftOnlineStatus(user.OnlineStatus);
|
||||
setDraftOnlineStatus(
|
||||
accountPreference(user.OnlineStatus),
|
||||
);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
}
|
||||
|
|
@ -298,7 +304,7 @@ export default function Sidebar() {
|
|||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
|
||||
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={{
|
||||
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
|
||||
opacity: openMobile ? 1 : 0,
|
||||
|
|
|
|||
13
apps/web/src/components/status-options.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export const onlineStatusOptions = [
|
||||
{ label: "Online", value: "user_online" },
|
||||
{ label: "Idle", value: "user_idle" },
|
||||
{ label: "Do not disturb", value: "user_dnd" },
|
||||
{ label: "On the toilet", value: "user_wc" },
|
||||
{ label: "Offline", value: "user_invisible" },
|
||||
] as const;
|
||||
|
||||
export type OnlineStatus = (typeof onlineStatusOptions)[number]["value"];
|
||||
|
||||
export const onlineStatusLabels = Object.fromEntries(
|
||||
onlineStatusOptions.map((option) => [option.value, option.label]),
|
||||
) as Record<OnlineStatus, string>;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import Switch from "./switch";
|
||||
|
|
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
|
|||
import { useSession } from "@tensamin/storage/session";
|
||||
|
||||
export default function List() {
|
||||
const [category, setCategory] = React.useState<
|
||||
"conversations" | "communities"
|
||||
>("conversations");
|
||||
const [category, setCategory] = useState<"conversations" | "communities">(
|
||||
"conversations",
|
||||
);
|
||||
|
||||
const { contacts, communities } = useSession();
|
||||
const items = category === "conversations" ? contacts : communities;
|
||||
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
|
|
@ -31,7 +31,7 @@ export default function List() {
|
|||
<div
|
||||
ref={scrollRef}
|
||||
id="conversation-list"
|
||||
className="overflow-y-auto flex-1 h-full"
|
||||
className="overflow-y-auto flex-1 h-full p-px"
|
||||
>
|
||||
{items === null ? (
|
||||
<div className="flex items-center justify-center pt-5">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import {
|
|||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@tensamin/ui";
|
||||
import { useSidebar } from "@methanium/ui";
|
||||
|
||||
export default function ConversationModal({ userId }: { userId: number }) {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -28,6 +28,13 @@ export default function ConversationModal({ userId }: { userId: number }) {
|
|||
<Wrapper
|
||||
loading={<Loading />}
|
||||
userId={userId}
|
||||
fields={[
|
||||
"Display",
|
||||
"Username",
|
||||
"Avatar",
|
||||
"OnlineStatus",
|
||||
"Status",
|
||||
]}
|
||||
component={(user) => <Basic user={user} />}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,271 +0,0 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CreateScreen,
|
||||
ErrorScreen,
|
||||
Label,
|
||||
Link,
|
||||
Spinner,
|
||||
} from "@tensamin/ui";
|
||||
import { z } from "zod";
|
||||
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
|
||||
export default function Screen(props: { children: React.ReactNode }) {
|
||||
const { load, save } = useStorage();
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
const [remoteDocs, setRemoteDocs] = useState<
|
||||
z.infer<typeof legalDocsSchema> | undefined
|
||||
>(undefined);
|
||||
const [localDocs, setLocalDocs] = useState<
|
||||
z.infer<typeof legalDocsSchema> | undefined
|
||||
>(undefined);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
const [acceptedPP, acceptPP] = useState(false);
|
||||
const [acceptedTOS, acceptTOS] = useState(false);
|
||||
const [hasContinued, setHasContinued] = useState(false);
|
||||
|
||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
||||
|
||||
// Saves
|
||||
const handleContinueLegal = useCallback((): void => {
|
||||
const currentDocs = remoteDocs;
|
||||
if (!currentDocs) {
|
||||
return;
|
||||
}
|
||||
|
||||
save("accepted_privacy_policy", true);
|
||||
save("accepted_terms_of_service", true);
|
||||
save("legal_docs", currentDocs);
|
||||
setHasContinued(true);
|
||||
}, [remoteDocs, save]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let loadingTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const id = await load("user_id");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(id);
|
||||
|
||||
if (id === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (active) setShowLoading(true);
|
||||
}, 200);
|
||||
|
||||
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 safeCurrent = legalDocsSchema.safeParse(current);
|
||||
if (!safeCurrent.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",
|
||||
safeCurrent.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteDocs(safeCurrent.data);
|
||||
|
||||
const currentLocalDocs = await load("legal_docs");
|
||||
setLocalDocs(currentLocalDocs);
|
||||
|
||||
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
acceptPP(
|
||||
loadedAcceptedPP &&
|
||||
!!currentLocalDocs &&
|
||||
currentLocalDocs.pp.hash === safeCurrent.data.pp.hash,
|
||||
);
|
||||
acceptTOS(
|
||||
loadedAcceptedTOS &&
|
||||
!!currentLocalDocs &&
|
||||
currentLocalDocs.tos.hash === safeCurrent.data.tos.hash,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!active) return;
|
||||
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Failed to fetch legal documents", err);
|
||||
} finally {
|
||||
clearTimeout(loadingTimer);
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(loadingTimer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (loading || userId === undefined) {
|
||||
if (!showLoading) return null;
|
||||
|
||||
return (
|
||||
<CreateScreen>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Spinner className="size-8" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching legal documents...
|
||||
</p>
|
||||
<Link
|
||||
label="status.methanium.net"
|
||||
link="https://status.methanium.net"
|
||||
/>
|
||||
</div>
|
||||
</CreateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const docsMatch =
|
||||
localDocs !== undefined &&
|
||||
remoteDocs !== undefined &&
|
||||
localDocs.pp.hash === remoteDocs.pp.hash &&
|
||||
localDocs.tos.hash === remoteDocs.tos.hash;
|
||||
|
||||
if (
|
||||
(acceptedPP && acceptedTOS && (docsMatch || hasContinued)) ||
|
||||
userId === 0
|
||||
) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateScreen>
|
||||
<div className="h-full flex flex-col gap-15 p-10 py-20 md:p-40 w-full lg:w-2/3">
|
||||
<h1 className="text-3xl md:text-4xl font-bold">
|
||||
Privacy Policy & ToS
|
||||
<p className="text-muted-foreground text-[20px] font-normal pt-3">
|
||||
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
|
||||
</p>
|
||||
</h1>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
|
||||
<div className="justify-start items-start flex flex-col gap-2">
|
||||
<BigCheckbox
|
||||
id="acceptPP"
|
||||
checked={acceptedPP}
|
||||
onChange={acceptPP}
|
||||
label="I agree to the Privacy Policy"
|
||||
/>
|
||||
<BigCheckbox
|
||||
id="acceptTOS"
|
||||
checked={acceptedTOS}
|
||||
onChange={acceptTOS}
|
||||
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/${remoteDocs?.pp.version}`}
|
||||
/>
|
||||
<Link
|
||||
label="Terms of Service"
|
||||
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContinueButton
|
||||
disabled={!acceptedPP || !acceptedTOS}
|
||||
onClick={handleContinueLegal}
|
||||
/>
|
||||
</div>
|
||||
</CreateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// Components
|
||||
function ContinueButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
className="text-md w-full md:w-auto"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="size-5.5 rounded-md flex items-center justify-center"
|
||||
/>
|
||||
<Label htmlFor={id} className="text-lg">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,30 +9,31 @@ import {
|
|||
} from "@tanstack/react-router";
|
||||
|
||||
import "./index.css";
|
||||
import "@tensamin/ui/index.css";
|
||||
import "@methanium/ui/index.css";
|
||||
|
||||
import NotFound from "@/routes/404";
|
||||
|
||||
import AppLayout from "@/routes/app/layout";
|
||||
import { createSettingsRoute } from "@tensamin/settings";
|
||||
import OnboardingGate from "@tensamin/onboarding";
|
||||
|
||||
import Home from "@/routes/app/home";
|
||||
import ChatScreen from "@tensamin/chat/screen";
|
||||
import CallScreen from "@tensamin/call/screen";
|
||||
import Login from "@/routes/screens/login";
|
||||
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
import ChatContext from "@tensamin/chat/context";
|
||||
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||
import { Provider as MTPProvider } from "@tensamin/mtp";
|
||||
import UserProvider from "@tensamin/user/context";
|
||||
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
||||
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
|
||||
import NotificationsProvider from "@tensamin/notifications/context";
|
||||
import PwaRuntime from "@tensamin/pwa/runtime";
|
||||
|
||||
import TAuthWrapper from "@tensamin/tauth/context";
|
||||
|
||||
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui";
|
||||
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
|
||||
import z from "zod";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
|
@ -43,13 +44,12 @@ import Crypto from "@tensamin/crypto/context";
|
|||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
import LegalWrapper from "@/features/legal/screen";
|
||||
import CacheSync from "@tensamin/cache/sync";
|
||||
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
||||
import { HotkeysProvider } from "@tensamin/hotkeys";
|
||||
|
||||
const wrapper = document.getElementById("root");
|
||||
|
||||
|
|
@ -131,6 +131,11 @@ function ThemeStorageBridge() {
|
|||
setThemeBorderRadius,
|
||||
themeCustomCss,
|
||||
setThemeCustomCss,
|
||||
parentThemeId,
|
||||
setParentThemeId,
|
||||
applyThemePreset,
|
||||
themeDesign,
|
||||
setThemeDesign,
|
||||
} = useTheme();
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
|
|
@ -145,6 +150,8 @@ function ThemeStorageBridge() {
|
|||
load("theme_tint"),
|
||||
load("theme_border_radius"),
|
||||
load("theme_custom_css"),
|
||||
load("theme_parent"),
|
||||
load("theme_design"),
|
||||
]).then(
|
||||
([
|
||||
color,
|
||||
|
|
@ -154,18 +161,23 @@ function ThemeStorageBridge() {
|
|||
tint,
|
||||
borderRadius,
|
||||
customCss,
|
||||
parent,
|
||||
design,
|
||||
]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (customCss === "" && parent) applyThemePreset(parent);
|
||||
else setParentThemeId(parent || null);
|
||||
setThemeColor(color);
|
||||
setThemePalette(palette);
|
||||
setThemePrimaryColor(primaryColor);
|
||||
setThemePolarity(polarity);
|
||||
setThemeTint(tint);
|
||||
setThemeBorderRadius(borderRadius);
|
||||
setThemeCustomCss(customCss);
|
||||
if (customCss !== "") setThemeCustomCss(customCss);
|
||||
setThemeDesign(design);
|
||||
loadedRef.current = true;
|
||||
},
|
||||
);
|
||||
|
|
@ -175,9 +187,12 @@ function ThemeStorageBridge() {
|
|||
};
|
||||
}, [
|
||||
load,
|
||||
applyThemePreset,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setParentThemeId,
|
||||
setThemeDesign,
|
||||
setThemePalette,
|
||||
setThemePolarity,
|
||||
setThemePrimaryColor,
|
||||
|
|
@ -212,6 +227,14 @@ function ThemeStorageBridge() {
|
|||
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
|
||||
}, [save, themeCustomCss]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) save("theme_parent", parentThemeId ?? "");
|
||||
}, [parentThemeId, save]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedRef.current) save("theme_design", themeDesign);
|
||||
}, [save, themeDesign]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -228,30 +251,23 @@ function RootShell() {
|
|||
tintStorageKey={null}
|
||||
borderRadiusStorageKey={null}
|
||||
customCssStorageKey={null}
|
||||
parentThemeStorageKey={null}
|
||||
designStorageKey={null}
|
||||
>
|
||||
<div className="w-screen h-dvh overflow-hidden">
|
||||
<Toaster
|
||||
position={isMobile ? "top-center" : "bottom-right"}
|
||||
{...(isTauri() && isMobile
|
||||
? {
|
||||
mobileOffset: {
|
||||
top: "env(safe-area-inset-top)",
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
<div
|
||||
data-pwa-root
|
||||
className="box-border w-screen h-dvh overflow-hidden"
|
||||
>
|
||||
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
|
||||
<TooltipProvider>
|
||||
<Storage>
|
||||
<ThemeStorageBridge />
|
||||
<LoginWrapper>
|
||||
<LegalWrapper>
|
||||
<Crypto>
|
||||
<DesktopMediaProvider>
|
||||
<Outlet />
|
||||
</DesktopMediaProvider>
|
||||
</Crypto>
|
||||
</LegalWrapper>
|
||||
</LoginWrapper>
|
||||
<PwaRuntime />
|
||||
<HotkeysProvider>
|
||||
<ThemeStorageBridge />
|
||||
<LoginWrapper>
|
||||
<Outlet />
|
||||
</LoginWrapper>
|
||||
</HotkeysProvider>
|
||||
</Storage>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -262,27 +278,63 @@ function RootShell() {
|
|||
|
||||
function AppShell() {
|
||||
return (
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
<CallPopout />
|
||||
<TAuthWrapper>
|
||||
<AppLayout>
|
||||
<ChatContext>
|
||||
<NotificationsProvider>
|
||||
<Outlet />
|
||||
</NotificationsProvider>
|
||||
</ChatContext>
|
||||
</AppLayout>
|
||||
</TAuthWrapper>
|
||||
</UserProvider>
|
||||
</Session>
|
||||
</MTPProvider>
|
||||
<OnboardingGate>
|
||||
<Crypto>
|
||||
<DesktopMediaProvider>
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<DeeplinkNavigator />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
<TAuthWrapper>
|
||||
<AppLayout>
|
||||
<ChatContext>
|
||||
<NotificationsProvider>
|
||||
<Outlet />
|
||||
</NotificationsProvider>
|
||||
</ChatContext>
|
||||
</AppLayout>
|
||||
</TAuthWrapper>
|
||||
</UserProvider>
|
||||
</Session>
|
||||
</MTPProvider>
|
||||
</DesktopMediaProvider>
|
||||
</Crypto>
|
||||
</OnboardingGate>
|
||||
);
|
||||
}
|
||||
|
||||
function DeeplinkNavigator() {
|
||||
const { deeplinks } = useDeeplinks();
|
||||
const navigate = useNavigate();
|
||||
const handledCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const links = deeplinks.slice(handledCount.current);
|
||||
handledCount.current = deeplinks.length;
|
||||
|
||||
for (const link of links) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
const id = Number(url.searchParams.get("id"));
|
||||
if (
|
||||
url.protocol === "tensamin:" &&
|
||||
url.hostname === "chat" &&
|
||||
Number.isSafeInteger(id) &&
|
||||
id > 0
|
||||
) {
|
||||
void navigate({ to: "/chat", search: { id } });
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs delivered by the platform.
|
||||
}
|
||||
}
|
||||
}, [deeplinks, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createCallTrayIcon(color: string, speaking: boolean) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 32;
|
||||
|
|
@ -307,7 +359,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
|
|||
}
|
||||
|
||||
function CallInit() {
|
||||
useInitializeCall();
|
||||
const callInvitePopup = useInitializeCall();
|
||||
|
||||
const { load } = useStorage();
|
||||
const {
|
||||
|
|
@ -370,7 +422,7 @@ function CallInit() {
|
|||
});
|
||||
}, [inCall, primaryColor, speaking]);
|
||||
|
||||
return null;
|
||||
return callInvitePopup;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Input,
|
||||
Button,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import z from "zod";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { useState } from "react";
|
||||
|
|
@ -79,7 +79,7 @@ function AddConversationButton() {
|
|||
Username: result.data,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.data.UserId === 0) {
|
||||
if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ function AddConversationButton() {
|
|||
const timeout = setTimeout(() => setLoading(true), 500);
|
||||
|
||||
send("AddConversation", {
|
||||
ChatPartnerName: result.data,
|
||||
ChatPartnerId: user.data.UserId,
|
||||
})
|
||||
.then(() => {
|
||||
insertContact(user.data.UserId);
|
||||
|
|
@ -131,8 +131,12 @@ function AddConversationButton() {
|
|||
setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button>Add Conversation</Button>} />
|
||||
<DialogContent>
|
||||
<DialogTrigger
|
||||
render={({ onClick }) => (
|
||||
<Button onClick={onClick}>Add Conversation</Button>
|
||||
)}
|
||||
/>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -160,7 +164,13 @@ function AddConversationButton() {
|
|||
{error && (
|
||||
<p className="text-sm text-destructive w-full">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button variant="outline">Cancel</Button>} />
|
||||
<DialogClose
|
||||
render={({ onClick }) => (
|
||||
<Button onClick={onClick} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Button disabled={loading} type="submit">
|
||||
{loading && <Loader2 className="animate-spin" />} Continue
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { type ReactNode } from "react";
|
|||
import Sidebar from "@/components/sidebar";
|
||||
import Navbar, { MobileNavbar } from "@/components/navbar";
|
||||
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
|
||||
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
|
||||
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
|
||||
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
|
|
@ -16,7 +17,9 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
||||
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
||||
<Sidebar />
|
||||
<CallPopout />
|
||||
<div
|
||||
data-app-layout
|
||||
// Background of ui that is overlapping with the system ui
|
||||
className={cn(
|
||||
"w-full h-full min-h-0 flex flex-col overflow-hidden",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Form from "@/components/screens/login/form";
|
||||
import { CreateScreen } from "@tensamin/ui";
|
||||
import { CreateScreen } from "@methanium/ui";
|
||||
|
||||
/**
|
||||
* Executes Page.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createReadStream, realpathSync, statSync } from "node:fs";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -7,14 +7,11 @@ import { defineConfig, type Plugin } from "vite";
|
|||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
import { methaniumUi } from "@methanium/ui/vite";
|
||||
import { tensaminPwa } from "@tensamin/pwa/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const appDir = dirname(fileURLToPath(import.meta.url));
|
||||
const markdownPackageDir = resolve(appDir, "../../packages/markdown");
|
||||
|
||||
function resolveMarkdownDependency(packageName: string): string {
|
||||
return realpathSync(resolve(markdownPackageDir, "node_modules", packageName));
|
||||
}
|
||||
|
||||
function deepFilterAssetHeaders(rootDir: string): Plugin {
|
||||
const serveModel = (
|
||||
|
|
@ -56,24 +53,6 @@ export default defineConfig({
|
|||
clearScreen: false,
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
alias: [
|
||||
{
|
||||
find: "@codemirror/commands",
|
||||
replacement: resolveMarkdownDependency("@codemirror/commands"),
|
||||
},
|
||||
{
|
||||
find: "@codemirror/lang-markdown",
|
||||
replacement: resolveMarkdownDependency("@codemirror/lang-markdown"),
|
||||
},
|
||||
{
|
||||
find: "@codemirror/state",
|
||||
replacement: resolveMarkdownDependency("@codemirror/state"),
|
||||
},
|
||||
{
|
||||
find: "@codemirror/view",
|
||||
replacement: resolveMarkdownDependency("@codemirror/view"),
|
||||
},
|
||||
],
|
||||
dedupe: [
|
||||
"react",
|
||||
"react-dom",
|
||||
|
|
@ -81,9 +60,7 @@ export default defineConfig({
|
|||
"use-sync-external-store",
|
||||
"@tanstack/history",
|
||||
"@tanstack/react-router",
|
||||
"@tanstack/react-store",
|
||||
"@tanstack/router-core",
|
||||
"@tanstack/store",
|
||||
"@tensamin/crypto",
|
||||
"@tensamin/settings",
|
||||
"@tensamin/storage",
|
||||
|
|
@ -91,21 +68,17 @@ export default defineConfig({
|
|||
"@tensamin/user",
|
||||
"@tensamin/tauri",
|
||||
"@tensamin/chat",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/lang-markdown",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
host: host || "0.0.0.0",
|
||||
host: "0.0.0.0",
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
clientPort: 3000,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
|
|
@ -133,9 +106,10 @@ export default defineConfig({
|
|||
"@tensamin/chat",
|
||||
"@tensamin/crypto",
|
||||
"@tensamin/crypto/context",
|
||||
"@tensamin/markdown",
|
||||
"@tensamin/hotkeys",
|
||||
"@tensamin/mtp",
|
||||
"@tensamin/notifications",
|
||||
"@tensamin/onboarding",
|
||||
"@tensamin/shared",
|
||||
"@tensamin/shared/data",
|
||||
"@tensamin/shared/log",
|
||||
|
|
@ -152,8 +126,10 @@ export default defineConfig({
|
|||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
plugins: [
|
||||
...tensaminPwa(),
|
||||
methaniumUi({ defaultThemeId: "tensamin" }),
|
||||
deepFilterAssetHeaders(resolve(appDir, "public")),
|
||||
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
|
||||
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
|
||||
{
|
||||
name: "workspace-realpath-resolution",
|
||||
enforce: "post",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import reactHooks from "eslint-plugin-react-hooks";
|
|||
import * as tsParser from "@typescript-eslint/parser";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
inlineSingleUseDeclarations,
|
||||
noReactNamespaceImport,
|
||||
noWindowLocationReload,
|
||||
} from "./utils/eslint-rules/index.js";
|
||||
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -28,10 +33,20 @@ export default [
|
|||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
tensamin: {
|
||||
rules: {
|
||||
"inline-single-use-declarations": inlineSingleUseDeclarations,
|
||||
"no-react-namespace-import": noReactNamespaceImport,
|
||||
"no-window-location-reload": noWindowLocationReload,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"tensamin/inline-single-use-declarations": "error",
|
||||
"tensamin/no-react-namespace-import": "error",
|
||||
"tensamin/no-window-location-reload": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
185
flake.nix
|
|
@ -16,9 +16,7 @@
|
|||
let
|
||||
systems = [ "x86_64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
version = "0.0.10";
|
||||
x86_64DebHash = "sha256-TK7XmPWFbAVwlrE8hX8x+QsGFa0FTH9Mctmxu9JsHb4=";
|
||||
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
|
||||
version = "0.0.11";
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
|
|
@ -32,8 +30,6 @@
|
|||
android_sdk.accept_license = true;
|
||||
};
|
||||
};
|
||||
debArtifact = "Tensamin-${version}-linux-amd64.deb";
|
||||
|
||||
electronRuntimeLibs = with pkgs; [
|
||||
alsa-lib
|
||||
at-spi2-atk
|
||||
|
|
@ -75,71 +71,130 @@
|
|||
libXtst
|
||||
libxcb
|
||||
];
|
||||
packageDeb =
|
||||
src:
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "tensamin";
|
||||
inherit version src;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
autoPatchelfHook
|
||||
dpkg
|
||||
makeWrapper
|
||||
];
|
||||
buildInputs = electronRuntimeLibs;
|
||||
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
unpackPhase = ''
|
||||
runHook preUnpack
|
||||
dpkg-deb -x "$src" .
|
||||
runHook postUnpack
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p "$out"
|
||||
cp -r opt "$out/"
|
||||
cp -r usr/* "$out/"
|
||||
|
||||
mkdir -p "$out/bin"
|
||||
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \
|
||||
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
|
||||
|
||||
substituteInPlace "$out/share/applications/Tensamin.desktop" \
|
||||
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
electron = pkgs.electron;
|
||||
pnpm = pkgs.pnpm;
|
||||
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=";
|
||||
};
|
||||
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;
|
||||
}
|
||||
);
|
||||
};
|
||||
desktopItem = pkgs.makeDesktopItem {
|
||||
name = "tensamin";
|
||||
desktopName = "Tensamin";
|
||||
exec = "tensamin";
|
||||
icon = "tensamin";
|
||||
startupWMClass = "Tensamin";
|
||||
categories = [ "Network" ];
|
||||
};
|
||||
defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "tensamin";
|
||||
inherit version;
|
||||
src = self;
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
inherit pnpm;
|
||||
fetcherVersion = 4;
|
||||
hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
copyDesktopItems
|
||||
makeWrapper
|
||||
nodejs_22
|
||||
pnpm
|
||||
pnpmConfigHook
|
||||
cargo
|
||||
lld
|
||||
rustc
|
||||
wasm-pack
|
||||
wasmBindgenCli
|
||||
binaryen
|
||||
];
|
||||
|
||||
env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
postPatch = ''
|
||||
rm -rf mtp-type-maps
|
||||
ln -s ${mtpTypeMaps} mtp-type-maps
|
||||
|
||||
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 = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p "$out/lib/tensamin" "$out/bin"
|
||||
cp -r apps/electron/release/linux-unpacked/. "$out/lib/tensamin/"
|
||||
|
||||
makeWrapper "$out/lib/tensamin/tensamin" "$out/bin/tensamin" \
|
||||
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
|
||||
|
||||
install -Dm644 apps/electron/build/icons/icon.png \
|
||||
"$out/share/icons/hicolor/512x512/apps/tensamin.png"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [ desktopItem ];
|
||||
|
||||
meta = {
|
||||
description = "Tensamin desktop client";
|
||||
homepage = "https://git.methanium.net/tensamin/client";
|
||||
mainProgram = "tensamin";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
});
|
||||
in
|
||||
{
|
||||
default = defaultPackage;
|
||||
tensamin = defaultPackage;
|
||||
electron = defaultPackage;
|
||||
localPathForDev = localPathPackage;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -376,6 +431,8 @@
|
|||
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
|
||||
fi
|
||||
fi
|
||||
|
||||
adb devices
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2014-present Sebastian McKenzie and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019 Material-UI SAS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019 Material-UI SAS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Floating UI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Floating UI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021-present Floating UI contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||