Compare commits

..
351 changed files with 15871 additions and 22115 deletions

View file

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

View file

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

View file

@ -1,60 +0,0 @@
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

View file

@ -1,21 +1,23 @@
on: on:
workflow_dispatch:
push: push:
branches: branches:
- dev - dev
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
build-web: build-web:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -34,12 +36,12 @@ jobs:
build-mobile: build-mobile:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile run: nix develop .#tauri --command pnpm install --frozen-lockfile
@ -54,6 +56,8 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: | run: |
nix profile add nixpkgs#gnused
set -euo pipefail set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then if [ -z "$KEYSTORE_BASE64" ]; then
@ -118,12 +122,12 @@ jobs:
matrix: matrix:
target: [linux] target: [linux]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -168,14 +172,14 @@ jobs:
runs-on: nixos runs-on: nixos
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile

View file

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

2
.gitignore vendored
View file

@ -2,5 +2,3 @@ node_modules
releases releases
.fallow .fallow
.direnv .direnv
keystore.jks
keystore.properties

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "mtp-type-maps"]
path = mtp-type-maps
url = https://git.methanium.net/tensamin/mtp-type-maps

25
LICENSE
View file

@ -1,15 +1,16 @@
Copyright (c) 2025 Methanium Copyright (c) [2025] [Methanium]
All rights reserved. All rights reserved.
No part of this software, source code, documentation, or This software is protected by copyright. Copying, editing,
associated materials may be copied, reproduced, modified, distributing, publicly performing, or any other use of this software
distributed, published, sublicensed, sold, or used to create or its components, in source or binary form, is strictly prohibited without the express
derivative works without prior written permission from the written permission of the copyright holder.
copyright holder.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY FUTURE LICENSE ACCEPTANCE:
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM It is the copyright holder's intention to release this software in the future
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL under a license yet to be defined, which will, among other things,
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER allow private, non-commercial use. This statement does not constitute
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE. 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.

6
TODO
View file

@ -1,6 +0,0 @@
- 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -28,11 +28,12 @@
"validate:raw": "pnpm run build && pnpm run package:linux:raw", "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" "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": { "devDependencies": {
"@types/node": "^26.1.2", "@types/node": "^25.9.1",
"electron": "^43.3.0", "electron": "^39.2.7",
"electron-builder": "^26.15.3", "electron-builder": "^26.0.12",
"esbuild": "^0.28.1", "esbuild": "^0.25.11",
"typescript": "~6.0.3" "typescript": "~6.0.3"
}, },
"build": { "build": {
@ -94,11 +95,7 @@
"target": [ "target": [
"dmg" "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 "publish": null
} }

View file

@ -5,7 +5,6 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
globalShortcut,
ipcMain, ipcMain,
session, session,
shell, shell,
@ -14,7 +13,6 @@ import { checkForUpdates } from "./updates.js";
import { import {
ipcChannels, ipcChannels,
type DesktopCallStatus, type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareAudioOutput, type DesktopScreenShareAudioOutput,
type DesktopScreenShareCapabilities, type DesktopScreenShareCapabilities,
} from "../shared/ipc.js"; } from "../shared/ipc.js";
@ -31,11 +29,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose"); const verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | 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"));
if (verbose) { if (verbose) {
app.commandLine.appendSwitch("enable-logging", "stderr"); app.commandLine.appendSwitch("enable-logging", "stderr");
@ -43,20 +36,6 @@ if (verbose) {
app.commandLine.appendSwitch("log-level", "0"); app.commandLine.appendSwitch("log-level", "0");
} }
if (
process.platform === "linux" &&
!app.commandLine.hasSwitch("password-store")
) {
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
}
if (
process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland"
) {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
}
if ( if (
process.platform === "linux" && process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland" && process.env.XDG_SESSION_TYPE === "wayland" &&
@ -191,29 +170,6 @@ 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() { function registerIpc() {
verboseLog("registering ipc handlers"); verboseLog("registering ipc handlers");
@ -253,24 +209,6 @@ function registerIpc() {
deleteSecureStorage(key), deleteSecureStorage(key),
); );
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage); 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) => { ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
if ( if (
typeof status !== "object" || typeof status !== "object" ||
@ -311,90 +249,6 @@ 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() { async function createWindow() {
const rendererIndex = getRendererIndex(); const rendererIndex = getRendererIndex();
verboseLog("creating main window", { verboseLog("creating main window", {
@ -480,10 +334,6 @@ app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow(); if (BrowserWindow.getAllWindows().length === 0) void createWindow();
}); });
app.on("will-quit", () => {
globalShortcut.unregisterAll();
});
if (verbose) { if (verbose) {
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error); console.error("[tensamin:electron] uncaught exception", error);
@ -500,7 +350,6 @@ async function start() {
verboseLog("app ready"); verboseLog("app ready");
registerIpc(); registerIpc();
registerDisplayMediaHandler(); registerDisplayMediaHandler();
registerMediaPermissionHandler();
initTray(() => mainWindow); initTray(() => mainWindow);
await createWindow(); await createWindow();
} }

View file

@ -34,6 +34,10 @@ function validateValue(value: unknown): asserts value is string {
} }
export function getSecureStorageStatus(): DesktopSecureStorageStatus { export function getSecureStorageStatus(): DesktopSecureStorageStatus {
if (!safeStorage.isEncryptionAvailable()) {
return { available: false, backend: null };
}
const backend = const backend =
process.platform === "linux" process.platform === "linux"
? safeStorage.getSelectedStorageBackend() ? safeStorage.getSelectedStorageBackend()
@ -44,9 +48,7 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
: null; : null;
return { return {
available: available: process.platform !== "linux" || backend !== "basic_text",
safeStorage.isEncryptionAvailable() &&
(process.platform !== "linux" || backend !== "basic_text"),
backend, backend,
}; };
} }

View file

@ -8,12 +8,14 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
function getTrayIconPath(filename: string) { function getTrayIconPath(filename: string) {
if (app.isPackaged) if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
return path.join(process.resourcesPath, "icons", filename);
return path.resolve(__dirname, "../../build/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 (!tray) return;
if (inCall && iconDataUrl) { if (inCall && iconDataUrl) {

View file

@ -2,7 +2,6 @@ import { contextBridge, ipcRenderer } from "electron";
import { import {
ipcChannels, ipcChannels,
type DesktopCallStatus, type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareSource, type DesktopScreenShareSource,
secureStorageLimits, secureStorageLimits,
} from "../shared/ipc.js"; } from "../shared/ipc.js";
@ -56,23 +55,6 @@ const desktopApi = {
return ipcRenderer.invoke(ipcChannels.setCallStatus, status); 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: { secureStorage: {
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus), getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
load: (key: string) => load: (key: string) =>

View file

@ -31,11 +31,6 @@ export type DesktopSecureStorageStatus = {
backend: string | null; backend: string | null;
}; };
export type DesktopGlobalHotkeyBinding = {
id: string;
accelerator: string;
};
export const secureStorageLimits = { export const secureStorageLimits = {
maxKeyBytes: 256, maxKeyBytes: 256,
maxValueBytes: 1024 * 1024, maxValueBytes: 1024 * 1024,
@ -82,7 +77,4 @@ export const ipcChannels = {
saveSecureStorage: "secureStorage:save", saveSecureStorage: "secureStorage:save",
deleteSecureStorage: "secureStorage:delete", deleteSecureStorage: "secureStorage:delete",
clearSecureStorage: "secureStorage:clear", clearSecureStorage: "secureStorage:clear",
setGlobalHotkeyBindings: "hotkeys:setBindings",
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
globalHotkeyTriggered: "hotkeys:triggered",
} as const; } as const;

View file

@ -1,36 +0,0 @@
{
"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"
}
}

View file

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

View file

@ -1,161 +0,0 @@
/// <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();
}
});

View file

@ -1,18 +0,0 @@
@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))
);
}
}

View file

@ -1,218 +0,0 @@
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",
},
}),
];
}

View file

@ -1,33 +0,0 @@
# 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.

View file

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

View file

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

View file

@ -24,3 +24,6 @@ dist-ssr
*.sw? *.sw?
.android .android
/src-tauri/gen/android/keystore.properties
/src-tauri/gen/android/keystore.jks

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -4,16 +4,23 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./controls": {
"types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx"
},
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
},
"./qrCodeScanner": {
"types": "./src/qrCodeScanner.tsx",
"default": "./src/qrCodeScanner.tsx"
} }
}, },
"scripts": { "scripts": {
"dev:mobile:raw": "adb reverse tcp:3000 tcp:3000 && tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}", "dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
"start-adb:mobile:raw": "adb devices", "start-adb:mobile:raw": "adb devices",
"build:mobile:raw": "tauri android build", "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", "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", "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", "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",
@ -22,15 +29,18 @@
"lint": "eslint src" "lint": "eslint src"
}, },
"dependencies": { "dependencies": {
"@methanium/ui": "*", "@tauri-apps/api": "^2",
"@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-barcode-scanner": "~2",
"@tauri-apps/plugin-deep-link": "~2.4.9", "@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"react": "^19.2.8", "@tensamin/ui": "*",
"react-dom": "^19.2.8" "react": "^19.2.0",
"react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2",
"@types/node": "^26.1.2" "@types/node": "^25.9.1"
} }
} }

View file

@ -1,53 +0,0 @@
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);

File diff suppressed because it is too large Load diff

View file

@ -15,17 +15,12 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] } tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] }
[dependencies] [dependencies]
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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-deep-link = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-log = "2" tauri-plugin-log = "2"
@ -35,14 +30,10 @@ version = "2"
features = [] features = []
default-features = true default-features = true
[target.'cfg(not(target_os = "android"))'.dependencies.tauri] [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
version = "2" tauri-plugin-barcode-scanner = "2"
features = [] tauri-plugin-app-events = "0.2"
default-features = true
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.22"
[patch.crates-io.tauri] [patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri" git = "https://github.com/tauri-apps/tauri"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41" branch = "feat/cef"

View file

@ -6,7 +6,10 @@
"main" "main"
], ],
"permissions": [ "permissions": [
"core:default",
"opener:default", "opener:default",
"core:window:default",
"core:event:default",
"deep-link:default", "deep-link:default",
"notification:default", "notification:default",
"log:default" "log:default"

View file

@ -1,10 +1,18 @@
{ {
"identifier": "mobile-capability", "identifier": "mobile-capability",
"platforms": ["android", "iOS"], "platforms": [
"windows": ["main"], "android",
"iOS"
],
"windows": [
"main"
],
"permissions": [ "permissions": [
"core:event:default",
"deep-link:default", "deep-link:default",
"app-events:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"notification:default", "notification:default",
"log:default" "log:default"
] ]

View file

@ -1,4 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties import java.util.Properties
import java.io.FileInputStream import java.io.FileInputStream
@ -62,29 +61,26 @@ android {
) )
} }
} }
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
} }
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
rust { rust {
rootDirRel = "../../../" rootDirRel = "../../../"
} }
dependencies { dependencies {
implementation("androidx.webkit:webkit:1.16.0") implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.13.0") implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.14.0") implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
} }
apply(from = "tauri.build.gradle.kts") apply(from = "tauri.build.gradle.kts")

View file

@ -18,4 +18,4 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile

View file

@ -1,16 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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 --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
@ -49,30 +39,6 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. --> <!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity> </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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View file

@ -1,114 +1,28 @@
package net.tensamin.client package net.tensamin.client
import android.Manifest import android.graphics.Rect
import android.app.Activity
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.view.MotionEvent import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowManager import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null private var contentChild: android.view.View? = null
private var previousUsableHeight = 0 private var previousUsableHeight = 0
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null 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?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this)
}
NativeAccessibilityBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround() installKeyboardResizeWorkaround()
} }
override fun onResume() {
super.onResume()
NativeMtpBridge.nativeSetUiState(true)
}
override fun onPause() {
NativeMtpBridge.nativeSetUiState(false)
super.onPause()
}
override fun onDestroy() { override fun onDestroy() {
attachLayoutListener?.let { listener -> attachLayoutListener?.let { listener ->
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener) contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
@ -116,87 +30,9 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null attachLayoutListener = null
contentRoot = null contentRoot = null
contentChild = null contentChild = null
mediaWebView?.let {
NativeAccessibilityBridge.detach(it)
it.removeJavascriptInterface("tensaminMobileMedia")
}
mediaWebView = null
MobileMediaEvents.detach()
super.onDestroy() 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() { private fun installKeyboardResizeWorkaround() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content) val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
contentRoot = content contentRoot = content
@ -232,20 +68,16 @@ class MainActivity : TauriActivity() {
child: android.view.View, child: android.view.View,
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child), insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
) { ) {
val visibleFrame = Rect()
child.getWindowVisibleDisplayFrame(visibleFrame)
val rootHeight = child.rootView.height val rootHeight = child.rootView.height
if (rootHeight <= 0) return if (rootHeight <= 0) return
val imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
val imeHeight = if (imeVisible) { val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom)
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom val keyboardVisible = keyboardHeight > rootHeight * 0.15
} else { val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT
0
}
val usableHeight = if (imeHeight in 1 until rootHeight) {
rootHeight - imeHeight
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
if (previousUsableHeight == usableHeight) return if (previousUsableHeight == usableHeight) return

View file

@ -1,345 +0,0 @@
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),
)
}
}
}

View file

@ -1,29 +0,0 @@
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) }
}
}

View file

@ -1,17 +0,0 @@
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)
}
}
}

View file

@ -1,167 +0,0 @@
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()
}
}
}

View file

@ -1,74 +0,0 @@
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()
}
}

View file

@ -1,51 +0,0 @@
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) }
}
}
}

View file

@ -1,161 +0,0 @@
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())
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -1,4 +1,4 @@
import com.android.build.api.dsl.LibraryExtension import com.android.build.gradle.LibraryExtension
buildscript { buildscript {
repositories { repositories {
@ -7,7 +7,7 @@ buildscript {
} }
dependencies { dependencies {
classpath("com.android.tools.build:gradle:8.11.0") classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
} }
} }

View file

@ -20,3 +20,4 @@ dependencies {
compileOnly(gradleApi()) compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0") implementation("com.android.tools.build:gradle:8.11.0")
} }

View file

@ -5,12 +5,8 @@ import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import javax.inject.Inject
open class BuildTask @Inject constructor( open class BuildTask : DefaultTask() {
private val execOperations: ExecOperations,
) : DefaultTask() {
@Input @Input
var rootDirRel: String? = null var rootDirRel: String? = null
@Input @Input
@ -54,7 +50,7 @@ open class BuildTask @Inject constructor(
val release = release ?: throw GradleException("release cannot be null") val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script"); val args = listOf("tauri", "android", "android-studio-script");
execOperations.exec { project.exec {
workingDir(File(project.projectDir, rootDirRel)) workingDir(File(project.projectDir, rootDirRel))
executable(executable) executable(executable)
args(args) args(args)
@ -69,4 +65,4 @@ open class BuildTask @Inject constructor(
args(listOf("--target", target)) args(listOf("--target", target))
}.assertNormalExitValue() }.assertNormalExitValue()
} }
} }

View file

@ -1,6 +1,6 @@
#Tue May 10 19:22:52 CST 2022 #Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

Binary file not shown.

View file

@ -1,135 +0,0 @@
#[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,
};

View file

@ -1,37 +1,21 @@
mod accessibility_backend;
mod mtp_backend;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin( .plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
tauri_plugin_log::Builder::new()
.level(tauri_plugin_log::log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_notification::init()); .plugin(tauri_plugin_notification::init());
let builder = builder let builder = builder
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_opener::init()); .plugin(tauri_plugin_opener::init());
let app = builder #[cfg(any(target_os = "ios", target_os = "android"))]
.invoke_handler(tauri::generate_handler![ let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
accessibility_backend::accessibility_get_initial_scale,
accessibility_backend::accessibility_set_initial_scale, #[cfg(any(target_os = "ios", target_os = "android"))]
mtp_backend::mtp_request, let builder = builder.plugin(tauri_plugin_app_events::init());
mtp_backend::mtp_status,
mtp_backend::mtp_store_credentials, if let Err(error) = builder
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| { .setup(|_app| {
mtp_backend::manager().attach_app(_app.handle().clone());
#[cfg(any(target_os = "linux", windows))] #[cfg(any(target_os = "linux", windows))]
{ {
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
@ -46,18 +30,9 @@ pub fn run() {
} }
Ok(()) Ok(())
}) })
.build(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while building tauri application"); {
eprintln!("error while running tauri application: {error}");
app.run(|_app, event| { panic!("error while running tauri application: {error}");
#[cfg(target_os = "android")] }
if let tauri::RunEvent::ExitRequested {
api, code: None, ..
} = event
{
if mtp_backend::manager().is_enabled() {
api.prevent_exit();
}
}
});
} }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -7,13 +7,13 @@ import {
} from "react"; } from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; import { useIsMobile } from "@tensamin/ui";
type DeeplinkContextValue = {
export const deeplinkContext = createContext<{
deeplinks: readonly string[]; deeplinks: readonly string[];
} | undefined>( };
export const deeplinkContext = createContext<DeeplinkContextValue | undefined>(
undefined, undefined,
); );

View file

@ -0,0 +1,38 @@
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>
);
}

View file

@ -2,11 +2,8 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="./favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0" />
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Tensamin</title> <title>Tensamin</title>
</head> </head>
<body> <body>

View file

@ -6,58 +6,139 @@
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"dev": "vite", "dev": "vite --port 3000 --host 0.0.0.0",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"build": "pnpm --filter @tensamin/pwa build && pnpm run test && tsc -b && vite build", "build": "pnpm run test && tsc -b && vite build",
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/public-sans": "^5.3.0", "@base-ui/react": "^1.0.0",
"@methanium/ui": "*", "@base-ui/utils": "0.3.1",
"@tailwindcss/vite": "^4.3.3", "@babel/runtime": "^7.29.2",
"@tanstack/react-router": "^1.170.21", "@fontsource-variable/inter": "^5.2.8",
"@tanstack/react-virtual": "^3.14.9", "@fontsource-variable/public-sans": "^5.2.7",
"@tauri-apps/api": "^2.11.1", "@floating-ui/core": "^1.7.0",
"@tensamin/pwa": "workspace:*", "@floating-ui/dom": "^1.7.0",
"@tensamin/cache": "workspace:*", "@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:*", "@tensamin/call": "workspace:*",
"@tensamin/cache": "workspace:*",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",
"@tensamin/hotkeys": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "workspace:*",
"@tensamin/settings": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/settings": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*", "@tensamin/tauri": "workspace:*",
"@tensamin/mtp": "workspace:*",
"@tensamin/tauth": "workspace:*", "@tensamin/tauth": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/ui": "*",
"@tensamin/user": "workspace:*", "@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", "decimal.js-light": "^2.5.1",
"eventemitter3": "^5.0.4", "detect-node-es": "^1.1.0",
"lucide-react": "^1.29.0", "dijkstrajs": "^1.0.1",
"react": "^19.2.8", "embla-carousel": "8.6.0",
"react-dom": "^19.2.8", "embla-carousel-react": "^8.6.0",
"react-is": "^19.2.8", "embla-carousel-reactive-utils": "8.6.0",
"react-redux": "^9.3.0", "es-toolkit": "^1.39.3",
"tailwind-scrollbar-hide": "^4.0.0", "eventemitter3": "^5.0.1",
"tailwindcss": "^4.3.3", "get-nonce": "^1.0.1",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"internmap": "^2.0.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"use-sync-external-store": "^1.6.0", "lucide-react": "^1.14.0",
"zod": "^4.4.3" "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",
"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"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.1",
"esbuild": "^0.28.1", "esbuild": "^0.25.11",
"eslint": "^10.8.0", "eslint": "^10.0.3",
"globals": "^17.9.0", "globals": "^17.4.0",
"mtp": "*",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.66.0", "typescript-eslint": "^8.57.0",
"vite": "^8.2.1" "vite": "^8.0.10"
} }
} }

Binary file not shown.

Binary file not shown.

View file

@ -6,66 +6,54 @@ import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
Button, } from "@tensamin/ui";
} from "@methanium/ui"; import { Card, CardHeader } from "@tensamin/ui";
import { Skeleton } from "@methanium/ui"; import { Skeleton } from "@tensamin/ui";
import { getStatusColor } from "@tensamin/shared/data"; import { getStatusColor } from "@tensamin/shared/data";
export function Basic({ export function Basic({
user, user,
extra, extra,
}: { }: {
user: Pick< user: User;
User,
"Display" | "Username" | "Avatar" | "OnlineStatus" | "Status"
>;
extra?: React.ReactNode; 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 ( return (
<Button <Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
render={<div />} <CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
nativeButton={false} <div className="relative shrink-0 overflow-visible">
variant="outline" <Avatar>
className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!" <AvatarImage src={user.Avatar} />
> <AvatarFallback>
<div className="relative shrink-0 overflow-visible"> {user.Display.slice(0, 2).toUpperCase()}
<Avatar> </AvatarFallback>
<AvatarImage src={avatar} /> </Avatar>
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback> <Tooltip>
</Avatar> <TooltipTrigger
<Tooltip> render={
<TooltipTrigger <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">
render={ <div
<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"> style={{
<div backgroundColor: getStatusColor(user.OnlineStatus),
style={{ }}
backgroundColor: getStatusColor(onlineStatus), className="h-2.25 w-2.25 rounded-full"
}} />
className="h-2.25 w-2.25 rounded-full" </div>
/> }
</div> />
} <TooltipContent>
/> {user.OnlineStatus.split("_")
<TooltipContent> .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
{onlineStatus .join(" ")}
.split("_") </TooltipContent>
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) </Tooltip>
.join(" ")} </div>
</TooltipContent> <div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
</Tooltip> <p>{user.Display}</p>
</div> </div>
<div className="flex w-full flex-col items-start justify-center text-[15px]"> <div className="pr-1">{extra}</div>
<p>{display}</p> </CardHeader>
<p className="text-xs text-muted-foreground">{user.Status}</p> </Card>
</div>
<div className="pr-1">{extra}</div>
</Button>
); );
} }

View file

@ -1,23 +1,10 @@
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui"; import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui";
import { Text } from "@methanium/ui/markdown"; import Text from "@tensamin/markdown/text";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
export default function Profile({ export default function Profile({ user }: { user: User }) {
user,
}: {
user: Pick<
User,
| "UserId"
| "Display"
| "Username"
| "Avatar"
| "About"
| "IotaId"
| "PublicKey"
>;
}) {
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false); const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
return ( return (
@ -37,7 +24,7 @@ export default function Profile({
<Text value={user.About || ""} /> <Text value={user.About || ""} />
<Button <Button
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline" className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
variant="ghost" variant="link"
aria-expanded={showAdvancedInformation} aria-expanded={showAdvancedInformation}
onClick={() => setShowAdvancedInformation((show) => !show)} onClick={() => setShowAdvancedInformation((show) => !show)}
> >

View file

@ -4,29 +4,17 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
useIsMobile, useIsMobile,
} from "@methanium/ui"; } from "@tensamin/ui";
import { import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
ArrowLeft,
EllipsisVertical,
House,
Phone,
Settings,
User,
} from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store"; import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { Skeleton } from "@methanium/ui"; import { Skeleton } from "@tensamin/ui";
import { import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@methanium/ui";
import { displayCallId } from "@tensamin/call/utils"; import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react"; import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@methanium/ui"; import { SidebarTrigger, useSidebar } from "@tensamin/ui";
import { WindowControls as Controls } from "@methanium/ui"; import { WindowControls as Controls } from "@tensamin/ui";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile"; import Profile from "./modals/profile";
@ -47,71 +35,50 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [userInfoOpen, setUserInfoOpen] = useState(false); const [userInfoOpen, setUserInfoOpen] = useState(false);
const { callId } = useCall();
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
data-pwa-navbar className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{forMobile ? ( {forMobile ? (
<SidebarTrigger <SidebarTrigger
render={({ onClick }) => ( className="w-9 h-9 aspect-square rounded-lg ml-2"
<Button variant="outline"
onClick={onClick} >
className="w-9 h-9! ml-2" <ArrowLeft className="size-4.5" />
variant="ghost" </SidebarTrigger>
>
<ArrowLeft className="size-4.5" />
</Button>
)}
/>
) : ( ) : (
<> <>
<Button <Button
onClick={() => navigate({ to: "/" })} onClick={() => navigate({ to: "/" })}
className="w-9 h-9! aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<House className="size-4.5" /> <House className="size-4.5" />
</Button> </Button>
<Button <Button
onClick={() => navigate({ to: "/settings" })} onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9! aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<Settings className="size-4.5" /> <Settings className="size-4.5" />
</Button> </Button>
</> </>
)} )}
{isMobile && pathname === "/call" && callId && (
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
)}
{pathname === "/chat" && id && ( {pathname === "/chat" && id && (
<Wrapper <Wrapper
userId={id} userId={id}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"About",
"IotaId",
"PublicKey",
]}
component={(user) => component={(user) =>
isMobile ? ( isMobile ? (
<p className="font-medium text-[1.07rem]">{user?.Display}</p> <p className="font-medium text-[1.07rem]">{user?.Display}</p>
) : ( ) : (
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}> <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger <PopoverTrigger
render={({ onClick }) => ( render={
<Button <Button
onClick={onClick}
variant="link" variant="link"
className="text-foreground px-0! border-0! bg-none! bg-transparent!" className="px-0! text-foreground"
style={{ style={{
textDecorationLine: "none", textDecorationLine: "none",
}} }}
@ -120,7 +87,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
{user?.Display} {user?.Display}
</p> </p>
</Button> </Button>
)} }
/> />
<PopoverContent side="bottom"> <PopoverContent side="bottom">
<Profile user={user} /> <Profile user={user} />
@ -142,7 +109,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => { onClick={() => {
void joinCall(id); void joinCall(id);
}} }}
className="w-9 h-9! aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<Phone /> <Phone />
@ -157,7 +124,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
currentCalls[0].CallId, currentCalls[0].CallId,
); );
}} }}
className="w-9 h-9! aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
> >
<Phone /> <Phone />
</Button> </Button>
@ -194,11 +161,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)} )}
</> </>
)} )}
{isMobile && pathname === "/call" && callId && (
<Button variant="ghost">
<EllipsisVertical />
</Button>
)}
<Controls className="mr-2" /> <Controls className="mr-2" />
</div> </div>
</div> </div>
@ -213,7 +175,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 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"> <div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
<SidebarTrigger <SidebarTrigger
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!" className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
variant="link" variant="link"
> >
<User className="size-4.5" /> <User className="size-4.5" />
@ -224,7 +186,7 @@ export function MobileNavbar() {
navigate({ to: "/" }); navigate({ to: "/" });
setOpenMobile(false); setOpenMobile(false);
}} }}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!" className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
variant="link" variant="link"
> >
<House className="size-4.5" /> <House className="size-4.5" />
@ -235,7 +197,7 @@ export function MobileNavbar() {
navigate({ to: "/settings" }); navigate({ to: "/settings" });
setOpenMobile(false); setOpenMobile(false);
}} }}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!" className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
variant="link" variant="link"
> >
<Settings className="size-4.5" /> <Settings className="size-4.5" />

View file

@ -1,24 +1,13 @@
import { Button, cn, useIsMobile } from "@methanium/ui"; import { Button, cn, useIsMobile } from "@tensamin/ui";
import { Input } from "@methanium/ui"; import { Input } from "@tensamin/ui";
import { Label } from "@methanium/ui"; import { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { File } from "lucide-react"; import { File } from "lucide-react";
import { import * as React from "react";
type ChangeEvent,
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { z } from "zod"; import { z } from "zod";
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime"; import { isTauri } from "@tauri-apps/api/core";
import { import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
id: z.uuidv4(), id: z.uuidv4(),
@ -38,36 +27,86 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92), mtp_keyring: z.string().min(1).max(92),
}); });
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, 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() { export default function Form() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const uploadRef = useRef<HTMLInputElement | null>(null); const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = React.useState(false);
const { load, save } = useStorage(); const { save } = useStorage();
const navigate = useNavigate(); const loginPendingRef = React.useRef(false);
const loginPendingRef = useRef(false);
const persistLogin = useCallback( const persistLogin = React.useCallback(
async (userId: number, privateKey: string, domain?: string | null) => { async (userId: number, privateKey: string, domain?: string | null) => {
if (loginPendingRef.current) return false; if (loginPendingRef.current) return false;
loginPendingRef.current = true; loginPendingRef.current = true;
try { try {
await persistMtpCredentials({ if (domain) await save("omega_url", `https://${domain}/`);
storage: { load, save }, await save("mtp_keyring", privateKey, { secure: true });
userId, await save("session_id", Date.now());
keyring: privateKey, await save("user_id", userId);
domain, window.history.replaceState(
}); null,
await navigate({ to: "/", replace: true }); "",
window.location.protocol === "file:" ? "#/" : "/",
);
window.location.reload();
return true; return true;
} finally { } finally {
loginPendingRef.current = false; loginPendingRef.current = false;
} }
}, },
[load, navigate, save], [save],
); );
// Process dropped files // Process dropped files
const processDroppedFile = useCallback( const processDroppedFile = React.useCallback(
async (file: globalThis.File): Promise<void> => { async (file: globalThis.File): Promise<void> => {
try { try {
if (!file.name.endsWith(".tu")) { if (!file.name.endsWith(".tu")) {
@ -87,14 +126,9 @@ export default function Form() {
[persistLogin], [persistLogin],
); );
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files // Handle .tu files
const handleFileInputChange = useCallback( const handleFileInputChange = React.useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.currentTarget.files?.[0]; const file = event.currentTarget.files?.[0];
if (!file) { if (!file) {
@ -108,7 +142,7 @@ export default function Form() {
); );
// Drag and drop listener // Drag and drop listener
useEffect(() => { React.useEffect(() => {
let dragCounter = 0; let dragCounter = 0;
const handleDragEnter = (event: DragEvent) => { const handleDragEnter = (event: DragEvent) => {
@ -178,8 +212,8 @@ export default function Form() {
* @param event Form submit event. * @param event Form submit event.
* @returns Promise that resolves after login processing. * @returns Promise that resolves after login processing.
*/ */
const handleCredentialsSubmit = useCallback( const handleCredentialsSubmit = React.useCallback(
async (event: FormEvent<HTMLFormElement>): Promise<void> => { async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@ -230,10 +264,32 @@ export default function Form() {
return ( return (
<div className="relative flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isMobile ? ( {isTauri() && isMobile ? (
<Button onClick={() => uploadRef.current?.click()}> <>
Select .tu file <QrCodeScanner
</Button> 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>
</>
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}

View file

@ -26,28 +26,33 @@ import {
SelectValue, SelectValue,
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@methanium/ui"; } from "@tensamin/ui";
import { useIsMobile } from "@methanium/ui"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui";
import { MobileNavbar } from "./navbar"; import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox"; import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar"; import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react"; import { Ellipsis, Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useUser, type User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data"; import type z from "zod";
import { mtp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import {
onlineStatusLabels,
onlineStatusOptions,
type OnlineStatus,
} from "./status-options";
function accountPreference(status: User["OnlineStatus"]): OnlineStatus { type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
return userPresencePreferenceSchema.safeParse(status).success
? (status as OnlineStatus) const onlineStatusLabels: Record<OnlineStatus, string> = {
: "user_online"; 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 StatusDialog({ function StatusDialog({
user, user,
@ -63,7 +68,7 @@ function StatusDialog({
saveSucceeded, saveSucceeded,
setSaveSucceeded, setSaveSucceeded,
}: { }: {
user: Pick<User, "UserId" | "OnlineStatus" | "Status">; user: User;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useMTP>["send"]; send: ReturnType<typeof useMTP>["send"];
@ -76,15 +81,13 @@ function StatusDialog({
saveSucceeded: boolean; saveSucceeded: boolean;
setSaveSucceeded: (value: boolean) => void; setSaveSucceeded: (value: boolean) => void;
}) { }) {
const { updateProfile, updateState } = useUser();
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(accountPreference(user.OnlineStatus)); setDraftOnlineStatus(user.OnlineStatus);
setErrorMessage(""); setErrorMessage("");
setSaveSucceeded(false); setSaveSucceeded(false);
} }
@ -123,11 +126,10 @@ function StatusDialog({
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="p-1"> <SelectContent className="p-1">
{onlineStatusOptions.map((option) => ( <SelectItem value="user_online">Online</SelectItem>
<SelectItem key={option.value} value={option.value}> <SelectItem value="user_offline">Offline</SelectItem>
{option.label} <SelectItem value="user_idle">Idle</SelectItem>
</SelectItem> <SelectItem value="user_dnd">Do not disturb</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -138,10 +140,12 @@ function StatusDialog({
<DialogClose render={<Button variant="destructive">Cancel</Button>} /> <DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button <Button
onClick={async () => { onClick={async () => {
const validation = mtp.ChangeUserData.request.safeParse({ const payload = {
...(draftStatus && { status: draftStatus }),
OnlineStatus: draftOnlineStatus, OnlineStatus: draftOnlineStatus,
Status: draftStatus, };
});
const validation = mtp.ChangeUserData.request.safeParse(payload);
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -153,8 +157,6 @@ function StatusDialog({
try { try {
await send("ChangeUserData", validation.data); await send("ChangeUserData", validation.data);
updateState(user.UserId, draftOnlineStatus);
await updateProfile(user.UserId, { Status: draftStatus });
setSaveSucceeded(true); setSaveSucceeded(true);
setErrorMessage(""); setErrorMessage("");
} catch (err) { } catch (err) {
@ -193,20 +195,16 @@ export default function Sidebar() {
const content = ( const content = (
<> <>
<SidebarContent className="pt-2"> <SidebarContent
className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
>
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!"> <div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div> <div>
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={"own"} userId={"own"}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => ( component={(user) => (
<> <>
<Basic <Basic
@ -229,9 +227,7 @@ export default function Sidebar() {
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus( setDraftOnlineStatus(user.OnlineStatus);
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
setDialogOpen(true); setDialogOpen(true);
@ -250,9 +246,7 @@ export default function Sidebar() {
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.Status ?? "");
setDraftOnlineStatus( setDraftOnlineStatus(user.OnlineStatus);
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
} }
@ -304,7 +298,7 @@ export default function Sidebar() {
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar pt-[var(--ui-safe-area-top)] pr-[var(--ui-safe-area-right)] pl-[var(--ui-safe-area-left)] text-sidebar-foreground" 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"
style={{ style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)", transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0, opacity: openMobile ? 1 : 0,

View file

@ -1,13 +0,0 @@
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>;

View file

@ -1,4 +1,4 @@
import { useRef, useState } from "react"; import * as React from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import Switch from "./switch"; import Switch from "./switch";
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
export default function List() { export default function List() {
const [category, setCategory] = useState<"conversations" | "communities">( const [category, setCategory] = React.useState<
"conversations", "conversations" | "communities"
); >("conversations");
const { contacts, communities } = useSession(); const { contacts, communities } = useSession();
const items = category === "conversations" ? contacts : communities; const items = category === "conversations" ? contacts : communities;
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = React.useRef<HTMLDivElement | null>(null);
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped. // TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
@ -31,7 +31,7 @@ export default function List() {
<div <div
ref={scrollRef} ref={scrollRef}
id="conversation-list" id="conversation-list"
className="overflow-y-auto flex-1 h-full p-px" className="overflow-y-auto flex-1 h-full"
> >
{items === null ? ( {items === null ? (
<div className="flex items-center justify-center pt-5"> <div className="flex items-center justify-center pt-5">

View file

@ -6,9 +6,9 @@ import {
ContextMenuGroup, ContextMenuGroup,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@methanium/ui"; } from "@tensamin/ui";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useSidebar } from "@methanium/ui"; import { useSidebar } from "@tensamin/ui";
export default function ConversationModal({ userId }: { userId: number }) { export default function ConversationModal({ userId }: { userId: number }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -28,13 +28,6 @@ export default function ConversationModal({ userId }: { userId: number }) {
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={userId} userId={userId}
fields={[
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => <Basic user={user} />} component={(user) => <Basic user={user} />}
/> />
</div> </div>

View file

@ -0,0 +1,247 @@
import { useState, useCallback, useEffect } from "react";
import { useStorage } from "@tensamin/storage/context";
import { Button } from "@tensamin/ui";
import { Checkbox } from "@tensamin/ui";
import { z } from "zod";
import { ErrorScreen } from "@tensamin/ui";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import { log } from "@tensamin/shared/log";
import { Link } from "@tensamin/ui";
import { Label } from "@tensamin/ui";
import { CreateScreen } from "@tensamin/ui";
// 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 [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;
void (async () => {
try {
const id = await load("user_id");
if (!active) {
return;
}
setUserId(id);
if (id === 0) {
return;
}
const current = await fetch("https://legal.tensamin.net/api/current")
.then((res) => res.json())
.catch((err) => {
if (!active) {
return undefined;
}
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);
return undefined;
});
if (!active || current === undefined) {
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,
);
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
}, [load]);
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}
if (loading || userId === undefined) {
return null;
}
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>
);
}

View file

@ -9,31 +9,30 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import "./index.css"; import "./index.css";
import "@methanium/ui/index.css"; import "@tensamin/ui/index.css";
import NotFound from "@/routes/404"; import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout"; import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings"; import { createSettingsRoute } from "@tensamin/settings";
import OnboardingGate from "@tensamin/onboarding";
import Home from "@/routes/app/home"; import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen"; import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen"; import CallScreen from "@tensamin/call/screen";
import Login from "@/routes/screens/login"; import Login from "@/routes/screens/login";
import CallPopout from "@tensamin/call/popout";
import ChatContext from "@tensamin/chat/context"; import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState"; import { useIsSpeaking } from "@tensamin/call/speakingState";
import { Provider as MTPProvider } from "@tensamin/mtp"; import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context"; import UserProvider from "@tensamin/user/context";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui"; import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui";
import z from "zod"; import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
@ -44,12 +43,13 @@ import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia"; import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import LegalWrapper from "@/features/legal/screen";
import CacheSync from "@tensamin/cache/sync"; import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui"; import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui";
import { HotkeysProvider } from "@tensamin/hotkeys"; import { isTauri } from "@tauri-apps/api/core";
const wrapper = document.getElementById("root"); const wrapper = document.getElementById("root");
@ -131,11 +131,6 @@ function ThemeStorageBridge() {
setThemeBorderRadius, setThemeBorderRadius,
themeCustomCss, themeCustomCss,
setThemeCustomCss, setThemeCustomCss,
parentThemeId,
setParentThemeId,
applyThemePreset,
themeDesign,
setThemeDesign,
} = useTheme(); } = useTheme();
const loadedRef = useRef(false); const loadedRef = useRef(false);
@ -150,8 +145,6 @@ function ThemeStorageBridge() {
load("theme_tint"), load("theme_tint"),
load("theme_border_radius"), load("theme_border_radius"),
load("theme_custom_css"), load("theme_custom_css"),
load("theme_parent"),
load("theme_design"),
]).then( ]).then(
([ ([
color, color,
@ -161,23 +154,18 @@ function ThemeStorageBridge() {
tint, tint,
borderRadius, borderRadius,
customCss, customCss,
parent,
design,
]) => { ]) => {
if (!active) { if (!active) {
return; return;
} }
if (customCss === "" && parent) applyThemePreset(parent);
else setParentThemeId(parent || null);
setThemeColor(color); setThemeColor(color);
setThemePalette(palette); setThemePalette(palette);
setThemePrimaryColor(primaryColor); setThemePrimaryColor(primaryColor);
setThemePolarity(polarity); setThemePolarity(polarity);
setThemeTint(tint); setThemeTint(tint);
setThemeBorderRadius(borderRadius); setThemeBorderRadius(borderRadius);
if (customCss !== "") setThemeCustomCss(customCss); setThemeCustomCss(customCss);
setThemeDesign(design);
loadedRef.current = true; loadedRef.current = true;
}, },
); );
@ -187,12 +175,9 @@ function ThemeStorageBridge() {
}; };
}, [ }, [
load, load,
applyThemePreset,
setThemeBorderRadius, setThemeBorderRadius,
setThemeColor, setThemeColor,
setThemeCustomCss, setThemeCustomCss,
setParentThemeId,
setThemeDesign,
setThemePalette, setThemePalette,
setThemePolarity, setThemePolarity,
setThemePrimaryColor, setThemePrimaryColor,
@ -227,14 +212,6 @@ function ThemeStorageBridge() {
if (loadedRef.current) save("theme_custom_css", themeCustomCss); if (loadedRef.current) save("theme_custom_css", themeCustomCss);
}, [save, 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; return null;
} }
@ -251,23 +228,30 @@ function RootShell() {
tintStorageKey={null} tintStorageKey={null}
borderRadiusStorageKey={null} borderRadiusStorageKey={null}
customCssStorageKey={null} customCssStorageKey={null}
parentThemeStorageKey={null}
designStorageKey={null}
> >
<div <div className="w-screen h-dvh overflow-hidden">
data-pwa-root <Toaster
className="box-border w-screen h-dvh overflow-hidden" position={isMobile ? "top-center" : "bottom-right"}
> {...(isTauri() && isMobile
<Toaster position={isMobile ? "top-center" : "bottom-right"} /> ? {
mobileOffset: {
top: "env(safe-area-inset-top)",
},
}
: {})}
/>
<TooltipProvider> <TooltipProvider>
<Storage> <Storage>
<PwaRuntime /> <ThemeStorageBridge />
<HotkeysProvider> <LoginWrapper>
<ThemeStorageBridge /> <LegalWrapper>
<LoginWrapper> <Crypto>
<Outlet /> <DesktopMediaProvider>
</LoginWrapper> <Outlet />
</HotkeysProvider> </DesktopMediaProvider>
</Crypto>
</LegalWrapper>
</LoginWrapper>
</Storage> </Storage>
</TooltipProvider> </TooltipProvider>
</div> </div>
@ -278,63 +262,27 @@ function RootShell() {
function AppShell() { function AppShell() {
return ( return (
<OnboardingGate> <MTPProvider>
<Crypto> <CacheSync />
<DesktopMediaProvider> <Session>
<MTPProvider> <UserProvider>
<CacheSync /> <CallInit />
<DeeplinkNavigator /> <CallPopout />
<Session> <TAuthWrapper>
<UserProvider> <AppLayout>
<CallInit /> <ChatContext>
<TAuthWrapper> <NotificationsProvider>
<AppLayout> <Outlet />
<ChatContext> </NotificationsProvider>
<NotificationsProvider> </ChatContext>
<Outlet /> </AppLayout>
</NotificationsProvider> </TAuthWrapper>
</ChatContext> </UserProvider>
</AppLayout> </Session>
</TAuthWrapper> </MTPProvider>
</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) { function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = 32; canvas.width = 32;
@ -359,7 +307,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
} }
function CallInit() { function CallInit() {
const callInvitePopup = useInitializeCall(); useInitializeCall();
const { load } = useStorage(); const { load } = useStorage();
const { const {
@ -422,7 +370,7 @@ function CallInit() {
}); });
}, [inCall, primaryColor, speaking]); }, [inCall, primaryColor, speaking]);
return callInvitePopup; return null;
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({

View file

@ -9,7 +9,7 @@ import {
Input, Input,
Button, Button,
useIsMobile, useIsMobile,
} from "@methanium/ui"; } from "@tensamin/ui";
import z from "zod"; import z from "zod";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { useState } from "react"; import { useState } from "react";
@ -79,7 +79,7 @@ function AddConversationButton() {
Username: result.data, Username: result.data,
}) })
.then((data) => { .then((data) => {
if (data.type === "ErrorNotFound" || data.data.UserId === 0) { if (data.data.UserId === 0) {
throw new Error(); throw new Error();
} }
@ -101,7 +101,7 @@ function AddConversationButton() {
const timeout = setTimeout(() => setLoading(true), 500); const timeout = setTimeout(() => setLoading(true), 500);
send("AddConversation", { send("AddConversation", {
ChatPartnerId: user.data.UserId, ChatPartnerName: result.data,
}) })
.then(() => { .then(() => {
insertContact(user.data.UserId); insertContact(user.data.UserId);
@ -131,12 +131,8 @@ function AddConversationButton() {
setOpen(value); setOpen(value);
}} }}
> >
<DialogTrigger <DialogTrigger render={<Button>Add Conversation</Button>} />
render={({ onClick }) => ( <DialogContent>
<Button onClick={onClick}>Add Conversation</Button>
)}
/>
<DialogContent showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
</DialogHeader> </DialogHeader>
@ -164,13 +160,7 @@ function AddConversationButton() {
{error && ( {error && (
<p className="text-sm text-destructive w-full">{error}</p> <p className="text-sm text-destructive w-full">{error}</p>
)} )}
<DialogClose <DialogClose render={<Button variant="outline">Cancel</Button>} />
render={({ onClick }) => (
<Button onClick={onClick} variant="outline">
Cancel
</Button>
)}
/>
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Loader2 className="animate-spin" />} Continue {loading && <Loader2 className="animate-spin" />} Continue
</Button> </Button>

View file

@ -3,9 +3,8 @@ import { type ReactNode } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import Navbar, { MobileNavbar } from "@/components/navbar"; import Navbar, { MobileNavbar } from "@/components/navbar";
import { useShowMobileNavbar } from "./useShowMobileNavbar"; import { useShowMobileNavbar } from "./useShowMobileNavbar";
import CallPopout from "@tensamin/call/popout";
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui"; import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
@ -17,9 +16,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar"> <div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
<SidebarProvider className="h-full min-h-0 overflow-hidden"> <SidebarProvider className="h-full min-h-0 overflow-hidden">
<Sidebar /> <Sidebar />
<CallPopout />
<div <div
data-app-layout
// Background of ui that is overlapping with the system ui // Background of ui that is overlapping with the system ui
className={cn( className={cn(
"w-full h-full min-h-0 flex flex-col overflow-hidden", "w-full h-full min-h-0 flex flex-col overflow-hidden",

View file

@ -1,5 +1,5 @@
import Form from "@/components/screens/login/form"; import Form from "@/components/screens/login/form";
import { CreateScreen } from "@methanium/ui"; import { CreateScreen } from "@tensamin/ui";
/** /**
* Executes Page. * Executes Page.

View file

@ -1,4 +1,4 @@
import { createReadStream, statSync } from "node:fs"; import { createReadStream, realpathSync, statSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -7,11 +7,14 @@ import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
const markdownPackageDir = resolve(appDir, "../../packages/markdown");
function resolveMarkdownDependency(packageName: string): string {
return realpathSync(resolve(markdownPackageDir, "node_modules", packageName));
}
function deepFilterAssetHeaders(rootDir: string): Plugin { function deepFilterAssetHeaders(rootDir: string): Plugin {
const serveModel = ( const serveModel = (
@ -53,6 +56,24 @@ export default defineConfig({
clearScreen: false, clearScreen: false,
resolve: { resolve: {
tsconfigPaths: true, 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: [ dedupe: [
"react", "react",
"react-dom", "react-dom",
@ -60,7 +81,9 @@ export default defineConfig({
"use-sync-external-store", "use-sync-external-store",
"@tanstack/history", "@tanstack/history",
"@tanstack/react-router", "@tanstack/react-router",
"@tanstack/react-store",
"@tanstack/router-core", "@tanstack/router-core",
"@tanstack/store",
"@tensamin/crypto", "@tensamin/crypto",
"@tensamin/settings", "@tensamin/settings",
"@tensamin/storage", "@tensamin/storage",
@ -68,17 +91,21 @@ export default defineConfig({
"@tensamin/user", "@tensamin/user",
"@tensamin/tauri", "@tensamin/tauri",
"@tensamin/chat", "@tensamin/chat",
"@codemirror/commands",
"@codemirror/lang-markdown",
"@codemirror/state",
"@codemirror/view",
], ],
}, },
server: { server: {
port: 3000, port: 5173,
strictPort: true, strictPort: true,
host: "0.0.0.0", host: host || "0.0.0.0",
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",
host, host,
clientPort: 3000, port: 1421,
} }
: undefined, : undefined,
watch: { watch: {
@ -106,10 +133,9 @@ export default defineConfig({
"@tensamin/chat", "@tensamin/chat",
"@tensamin/crypto", "@tensamin/crypto",
"@tensamin/crypto/context", "@tensamin/crypto/context",
"@tensamin/hotkeys", "@tensamin/markdown",
"@tensamin/mtp", "@tensamin/mtp",
"@tensamin/notifications", "@tensamin/notifications",
"@tensamin/onboarding",
"@tensamin/shared", "@tensamin/shared",
"@tensamin/shared/data", "@tensamin/shared/data",
"@tensamin/shared/log", "@tensamin/shared/log",
@ -126,10 +152,8 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
{ {
name: "workspace-realpath-resolution", name: "workspace-realpath-resolution",
enforce: "post", enforce: "post",

View file

@ -5,11 +5,6 @@ import reactHooks from "eslint-plugin-react-hooks";
import * as tsParser from "@typescript-eslint/parser"; import * as tsParser from "@typescript-eslint/parser";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
inlineSingleUseDeclarations,
noReactNamespaceImport,
noWindowLocationReload,
} from "./utils/eslint-rules/index.js";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
@ -33,20 +28,10 @@ export default [
}, },
plugins: { plugins: {
"react-hooks": reactHooks, "react-hooks": reactHooks,
tensamin: {
rules: {
"inline-single-use-declarations": inlineSingleUseDeclarations,
"no-react-namespace-import": noReactNamespaceImport,
"no-window-location-reload": noWindowLocationReload,
},
},
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off", "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",
}, },
}, },
]; ];

720
flake.nix
View file

@ -6,436 +6,340 @@
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
}; };
outputs = outputs = {
{ self,
self, nixpkgs,
nixpkgs, rust-overlay,
rust-overlay, ...
... }: let
}: systems = ["x86_64-linux"];
let forAllSystems = nixpkgs.lib.genAttrs systems;
systems = [ "x86_64-linux" ]; version = "0.0.10";
forAllSystems = nixpkgs.lib.genAttrs systems; x86_64DebHash = "sha256-VVKkZ9yQ7BoLnYnCf5OSHcrakoBNfUx/uWxBmm3gBZE=";
version = "0.0.11"; forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
in in {
{ packages = forAllSystems (system: let
packages = forAllSystems ( pkgs = import nixpkgs {
system: inherit system;
let overlays = [(import rust-overlay)];
pkgs = import nixpkgs { config = {
inherit system; allowUnfree = true;
overlays = [ (import rust-overlay) ]; android_sdk.accept_license = true;
config = { };
allowUnfree = true; };
android_sdk.accept_license = true; debArtifact = "Tensamin-${version}-linux-amd64.deb";
};
}; electronRuntimeLibs = with pkgs; [
electronRuntimeLibs = with pkgs; [ alsa-lib
alsa-lib at-spi2-atk
at-spi2-atk at-spi2-core
at-spi2-core atk
atk cairo
cairo cups
cups dbus
dbus expat
expat fontconfig
fontconfig freetype
freetype gdk-pixbuf
gdk-pixbuf glib
glib gtk3
gtk3 libdrm
libdrm libgbm
libgbm libglvnd
libglvnd libnotify
libnotify libpulseaudio
libpulseaudio libuuid
libsecret libxkbcommon
libuuid mesa
libxkbcommon nspr
mesa nss
nspr pango
nss pipewire
pango systemd
pipewire wayland
systemd libX11
wayland libXScrnSaver
libX11 libXcomposite
libXScrnSaver libXcursor
libXcomposite libXdamage
libXcursor libXext
libXdamage libXfixes
libXext libXi
libXfixes libXrandr
libXi libXtst
libXrandr libxcb
libXtst ];
libxcb packageDeb = src:
pkgs.stdenv.mkDerivation {
pname = "tensamin";
inherit version src;
nativeBuildInputs = with pkgs; [
autoPatchelfHook
dpkg
makeWrapper
]; ];
electron = pkgs.electron; buildInputs = electronRuntimeLibs;
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=";
};
};
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 { dontConfigure = true;
inherit (finalAttrs) pname version src; dontBuild = true;
inherit pnpm;
fetcherVersion = 4;
hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg=";
};
nativeBuildInputs = with pkgs; [ unpackPhase = ''
copyDesktopItems runHook preUnpack
makeWrapper dpkg-deb -x "$src" .
nodejs_22 runHook postUnpack
pnpm '';
pnpmConfigHook
cargo
lld
rustc
wasm-pack
wasmBindgenCli
binaryen
];
env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1; installPhase = ''
runHook preInstall
postPatch = '' mkdir -p "$out"
rm -rf mtp-type-maps cp -r opt "$out/"
ln -s ${mtpTypeMaps} mtp-type-maps cp -r usr/* "$out/"
node -e ' mkdir -p "$out/bin"
const fs = require("fs"); makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin"
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 = '' substituteInPlace "$out/share/applications/tensamin.desktop" \
runHook preBuild --replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
mkdir -p "$HOME/.cargo" runHook postInstall
substitute ${mtpCargoDeps}/.cargo/config.toml "$HOME/.cargo/config.toml" \ '';
--replace-fail @vendor@ ${mtpCargoDeps} };
defaultPackage = packageDeb (pkgs.fetchurl {
pnpm run copy-licenses url = "${forgejoBaseUrl}/${debArtifact}";
pnpm run build:packages hash = x86_64DebHash;
pnpm run build:web });
pnpm --dir apps/tauri run gen-icons localDebPath = builtins.getEnv "TENSAMIN_DEB";
pnpm --dir apps/electron run build localPathPackage =
pnpm --dir apps/electron exec electron-builder --dir --linux --publish never \ if localDebPath == ""
--config.electronDist=${electron.dist} \ then
--config.electronVersion=${electron.version} pkgs.writeShellScriptBin "tensamin" ''
echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2
runHook postBuild exit 1
''; ''
else
installPhase = '' packageDeb (builtins.path {
runHook preInstall path = localDebPath;
name = debArtifact;
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 in {
{ default = defaultPackage;
default = defaultPackage; tensamin = defaultPackage;
tensamin = defaultPackage; electron = defaultPackage;
electron = defaultPackage; localPathForDev = localPathPackage;
} });
);
devShells = forAllSystems ( devShells = forAllSystems (system: let
system: pkgs = import nixpkgs {
let inherit system;
pkgs = import nixpkgs { overlays = [(import rust-overlay)];
inherit system; config = {
overlays = [ (import rust-overlay) ]; allowUnfree = true;
config = { android_sdk.accept_license = true;
allowUnfree = true; };
android_sdk.accept_license = true; };
};
};
electronRuntimeLibs = with pkgs; [ electronRuntimeLibs = with pkgs; [
alsa-lib alsa-lib
at-spi2-atk at-spi2-atk
at-spi2-core at-spi2-core
atk atk
cairo cairo
cups cups
dbus dbus
expat expat
fontconfig fontconfig
freetype freetype
gdk-pixbuf gdk-pixbuf
glib glib
gtk3 gtk3
libdrm libdrm
libgbm libgbm
libglvnd libglvnd
libnotify libnotify
libpulseaudio libpulseaudio
libsecret libuuid
libuuid libxkbcommon
libxkbcommon mesa
mesa nspr
nspr nss
nss pango
pango pipewire
pipewire systemd
systemd wayland
wayland libX11
libX11 libXScrnSaver
libXScrnSaver libXcomposite
libXcomposite libXcursor
libXcursor libXdamage
libXdamage libXext
libXext libXfixes
libXfixes libXi
libXi libXrandr
libXrandr libXtst
libXtst libxcb
libxcb ];
];
buildToolsVersion = "35.0.0"; buildToolsVersion = "35.0.0";
ndkVersion = "29.0.14206865"; ndkVersion = "29.0.14206865";
android = pkgs.androidenv.composeAndroidPackages { android = pkgs.androidenv.composeAndroidPackages {
cmdLineToolsVersion = "8.0"; cmdLineToolsVersion = "8.0";
toolsVersion = "26.1.1"; toolsVersion = "26.1.1";
platformToolsVersion = "35.0.2"; platformToolsVersion = "35.0.2";
buildToolsVersions = [ buildToolsVersion ]; buildToolsVersions = [buildToolsVersion];
platformVersions = [ platformVersions = ["35" "36"];
"35" includeSources = false;
"36" includeSystemImages = false;
]; includeNDK = true;
includeSources = false; ndkVersions = [ndkVersion];
includeSystemImages = false; useGoogleAPIs = false;
includeNDK = true; };
ndkVersions = [ ndkVersion ]; rustToolchain = pkgs.rust-bin.stable.latest.default.override {
useGoogleAPIs = false; extensions = ["rust-src" "rust-analyzer"];
}; targets = [
rustToolchain = pkgs.rust-bin.stable.latest.default.override { "aarch64-linux-android"
extensions = [ "armv7-linux-androideabi"
"rust-src" "i686-linux-android"
"rust-analyzer" "x86_64-linux-android"
]; "wasm32-unknown-unknown"
targets = [ ];
"aarch64-linux-android" };
"armv7-linux-androideabi"
"i686-linux-android"
"x86_64-linux-android"
"wasm32-unknown-unknown"
];
};
commonDeps = [ commonDeps = [rustToolchain pkgs.wasm-pack pkgs.lld];
rustToolchain appImageToolsArchive = pkgs.fetchurl {
pkgs.wasm-pack url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage@1.0.3/appimage-tools-runtime-20251108.tar.gz";
pkgs.lld hash = "sha256-hAIaeO4hSub9M6LWKpK6JVQt0QvIa/EXqbLQu6ROdmU=";
]; };
appImageToolsArchive = pkgs.fetchurl { appImageTools = pkgs.runCommand "electron-builder-appimage-tools-nix" {nativeBuildInputs = [pkgs.gnutar pkgs.gzip];} ''
url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage@1.0.3/appimage-tools-runtime-20251108.tar.gz"; mkdir -p "$out"
hash = "sha256-hAIaeO4hSub9M6LWKpK6JVQt0QvIa/EXqbLQu6ROdmU="; tar -xzf ${appImageToolsArchive} --strip-components=1 -C "$out"
}; rm -f "$out/mksquashfs" "$out/desktop-file-validate"
appImageTools = ln -s ${pkgs.squashfsTools}/bin/mksquashfs "$out/mksquashfs"
pkgs.runCommand "electron-builder-appimage-tools-nix" ln -s ${pkgs.desktop-file-utils}/bin/desktop-file-validate "$out/desktop-file-validate"
{ '';
nativeBuildInputs = [ in rec {
pkgs.gnutar default = electron;
pkgs.gzip
];
}
''
mkdir -p "$out"
tar -xzf ${appImageToolsArchive} --strip-components=1 -C "$out"
rm -f "$out/mksquashfs" "$out/desktop-file-validate"
ln -s ${pkgs.squashfsTools}/bin/mksquashfs "$out/mksquashfs"
ln -s ${pkgs.desktop-file-utils}/bin/desktop-file-validate "$out/desktop-file-validate"
'';
in
rec {
default = electron;
electron = pkgs.mkShell { electron = pkgs.mkShell {
packages = packages = with pkgs;
with pkgs; [
[ nodejs_22
nodejs_22 corepack_22
corepack_22 coreutils
coreutils pnpm
pnpm pkgs.electron
pkgs.electron pkg-config
pkg-config python3
python3 gcc
gcc gnumake
gnumake git
git jq
jq patchelf
patchelf dpkg
dpkg rpm
rpm fpm
fpm curl
curl fakeroot
fakeroot rsync
rsync xz
xz p7zip
p7zip ]
] ++ electronRuntimeLibs ++ commonDeps;
++ electronRuntimeLibs
++ commonDeps;
shellHook = '' shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH" export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
export ELECTRON_ENABLE_LOGGING=1 export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}" export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}" export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
export npm_config_build_from_source=true export npm_config_build_from_source=true
export USE_SYSTEM_FPM=true export USE_SYSTEM_FPM=true
export ELECTRON_BUILDER_7ZIP_PATH="${pkgs.p7zip}/bin/7za" export ELECTRON_BUILDER_7ZIP_PATH="${pkgs.p7zip}/bin/7za"
export APPIMAGE_TOOLS_PATH="${appImageTools}" export APPIMAGE_TOOLS_PATH="${appImageTools}"
alias electron-install='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm install' alias electron-install='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm install'
alias electron-build-web='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm run build:web' alias electron-build-web='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm run build:web'
alias electron-dev='pnpm run dev' alias electron-dev='pnpm run dev'
alias electron-package='pnpm run package:linux' alias electron-package='pnpm run package:linux'
alias electron-validate='pnpm run validate' alias electron-validate='pnpm run validate'
''; '';
}; };
tauri = pkgs.mkShell { tauri = pkgs.mkShell {
buildInputs = buildInputs = with pkgs;
with pkgs; [
[ jdk17
jdk17 gradle
gradle pnpm
pnpm bun
bun nodejs
nodejs corepack_22
corepack_22 coreutils
coreutils pkg-config
pkg-config git
git jq
jq curl
curl ]
] ++ [
++ [ android.androidsdk
android.androidsdk android-studio-tools
android-studio-tools ]
] ++ commonDeps;
++ commonDeps;
shellHook = '' shellHook = ''
sdkSource="${android.androidsdk}/libexec/android-sdk" sdkSource="${android.androidsdk}/libexec/android-sdk"
if [ -d apps/tauri/src-tauri ]; then if [ -d apps/tauri/src-tauri ]; then
projectRoot="$PWD/apps/tauri" projectRoot="$PWD/apps/tauri"
else else
projectRoot="$PWD" projectRoot="$PWD"
fi fi
androidHome="$projectRoot/.android" androidHome="$projectRoot/.android"
sdkRoot="$androidHome/sdk" sdkRoot="$androidHome/sdk"
mkdir -p "$androidHome" mkdir -p "$androidHome"
if [ -L "$sdkRoot" ]; then if [ -L "$sdkRoot" ]; then
rm -f "$sdkRoot" rm -f "$sdkRoot"
fi fi
mkdir -p "$sdkRoot" mkdir -p "$sdkRoot"
ln -sfn "$sdkSource/build-tools" "$sdkRoot/build-tools" ln -sfn "$sdkSource/build-tools" "$sdkRoot/build-tools"
ln -sfn "$sdkSource/cmake" "$sdkRoot/cmake" ln -sfn "$sdkSource/cmake" "$sdkRoot/cmake"
ln -sfn "$sdkSource/licenses" "$sdkRoot/licenses" ln -sfn "$sdkSource/licenses" "$sdkRoot/licenses"
ln -sfn "$sdkSource/ndk" "$sdkRoot/ndk" ln -sfn "$sdkSource/ndk" "$sdkRoot/ndk"
ln -sfn "$sdkSource/ndk-bundle" "$sdkRoot/ndk-bundle" ln -sfn "$sdkSource/ndk-bundle" "$sdkRoot/ndk-bundle"
ln -sfn "$sdkSource/platforms" "$sdkRoot/platforms" ln -sfn "$sdkSource/platforms" "$sdkRoot/platforms"
ln -sfn "$sdkSource/platform-tools" "$sdkRoot/platform-tools" ln -sfn "$sdkSource/platform-tools" "$sdkRoot/platform-tools"
ln -sfn "$sdkSource/tools" "$sdkRoot/tools" ln -sfn "$sdkSource/tools" "$sdkRoot/tools"
mkdir -p "$sdkRoot/cmdline-tools" mkdir -p "$sdkRoot/cmdline-tools"
ln -sfn "$sdkSource/cmdline-tools/8.0" "$sdkRoot/cmdline-tools/8.0" ln -sfn "$sdkSource/cmdline-tools/8.0" "$sdkRoot/cmdline-tools/8.0"
ln -sfn "8.0" "$sdkRoot/cmdline-tools/latest" ln -sfn "8.0" "$sdkRoot/cmdline-tools/latest"
sdkRootAbs="$(realpath "$sdkRoot")" sdkRootAbs="$(realpath "$sdkRoot")"
ndkRootAbs="''${sdkRootAbs}/ndk/${ndkVersion}" ndkRootAbs="''${sdkRootAbs}/ndk/${ndkVersion}"
export PATH="''${sdkRootAbs}/cmdline-tools/latest/bin:''${sdkRootAbs}/platform-tools:''${ndkRootAbs}:${pkgs.android-studio-tools}/bin:$PATH" export PATH="''${sdkRootAbs}/cmdline-tools/latest/bin:''${sdkRootAbs}/platform-tools:''${ndkRootAbs}:${pkgs.android-studio-tools}/bin:$PATH"
export ANDROID_HOME="''${sdkRootAbs}" export ANDROID_HOME="''${sdkRootAbs}"
export ANDROID_SDK_ROOT="''${sdkRootAbs}" export ANDROID_SDK_ROOT="''${sdkRootAbs}"
export ANDROID_NDK_ROOT="''${ndkRootAbs}" export ANDROID_NDK_ROOT="''${ndkRootAbs}"
export ANDROID_NDK_HOME="$ANDROID_NDK_ROOT" export ANDROID_NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_HOME="$ANDROID_NDK_ROOT" export NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_PATH="$ANDROID_NDK_ROOT" export NDK_PATH="$ANDROID_NDK_ROOT"
export JAVA_HOME="${pkgs.jdk17}" export JAVA_HOME="${pkgs.jdk17}"
aapt2Path="''${sdkRootAbs}/build-tools/${buildToolsVersion}/aapt2" aapt2Path="''${sdkRootAbs}/build-tools/${buildToolsVersion}/aapt2"
if [ -x "$aapt2Path" ]; then if [ -x "$aapt2Path" ]; then
mkdir -p "$HOME/.gradle" mkdir -p "$HOME/.gradle"
gradleProperties="$HOME/.gradle/gradle.properties" gradleProperties="$HOME/.gradle/gradle.properties"
touch "$gradleProperties" touch "$gradleProperties"
if grep -q '^android\.aapt2FromMavenOverride=' "$gradleProperties"; then if grep -q '^android\.aapt2FromMavenOverride=' "$gradleProperties"; then
sed -i "s|^android\.aapt2FromMavenOverride=.*|android.aapt2FromMavenOverride=$aapt2Path|" "$gradleProperties" sed -i "s|^android\.aapt2FromMavenOverride=.*|android.aapt2FromMavenOverride=$aapt2Path|" "$gradleProperties"
else else
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties" printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
fi fi
fi fi
'';
adb devices };
''; });
}; };
}
);
};
} }

View file

@ -0,0 +1,22 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,21 @@
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.

View file

@ -0,0 +1,20 @@
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.

View file

@ -0,0 +1,20 @@
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.

View file

@ -0,0 +1,20 @@
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.

Some files were not shown because too many files have changed in this diff Show more