Compare commits

..
333 changed files with 14670 additions and 20435 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,15 +1,20 @@
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
@ -34,6 +39,9 @@ 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
@ -54,6 +62,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,6 +128,9 @@ 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
@ -168,6 +181,9 @@ jobs:
runs-on: nixos runs-on: nixos
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
with: with:

View file

@ -6,10 +6,16 @@ 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
@ -34,6 +40,9 @@ 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
@ -54,6 +63,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,6 +129,9 @@ 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
@ -166,6 +180,9 @@ 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
@ -311,3 +328,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

2
TODO
View file

@ -1,6 +1,6 @@
- Add a bunch of tests - Add a bunch of tests
- Add packages/hotkeys/
- Full accessability - Full accessability
- Add settings saving & update onboarding to use it - Add settings saving & update onboarding to use it
- Add onboarding page to load one profile during onboarding
-> Most folders in packages/ have specific todos -> 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,8 +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.setName("tensamin");
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron")); app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
@ -50,13 +46,6 @@ if (
app.commandLine.appendSwitch("password-store", "gnome-libsecret"); 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 +180,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 +219,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 +259,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 +344,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 +360,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

@ -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

@ -7,13 +7,16 @@
"./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 +25,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", "@methanium/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

@ -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)

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

@ -9,11 +9,11 @@ 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 "@methanium/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 "@methanium/ui";
import { toast } from "@tensamin/shared/log";
export default function QrCodeScanner({
onData,
}: {
onData: (data: string) => void;
}) {
return (
<Button
onClick={() => {
requestPermissions()
.catch((err) => {
toast("error", err.message);
})
.then(() =>
scan({ windowed: false, formats: [Format.QRCode] })
.catch((err) => {
toast("error", err.message);
})
.then((data) => {
if (data) {
onData(data.content);
}
}),
);
}}
>
Open QR Code Scanner
</Button>
);
}

View file

@ -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,140 @@
"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:*",
"@methanium/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "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,8 +6,8 @@ import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
Button,
} from "@methanium/ui"; } from "@methanium/ui";
import { Card, CardHeader } from "@methanium/ui";
import { Skeleton } from "@methanium/ui"; import { Skeleton } from "@methanium/ui";
import { getStatusColor } from "@tensamin/shared/data"; import { getStatusColor } from "@tensamin/shared/data";
@ -15,57 +15,45 @@ 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 "@methanium/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

@ -5,14 +5,7 @@ import {
PopoverTrigger, PopoverTrigger,
useIsMobile, useIsMobile,
} from "@methanium/ui"; } from "@methanium/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";
@ -47,27 +40,19 @@ 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
@ -86,32 +71,19 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
</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 +92,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} />
@ -194,11 +166,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>
@ -224,7 +191,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 border-0! hover:bg-transparent!"
variant="link" variant="link"
> >
<House className="size-4.5" /> <House className="size-4.5" />
@ -235,7 +202,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 border-0! hover:bg-transparent!"
variant="link" variant="link"
> >
<Settings className="size-4.5" /> <Settings className="size-4.5" />

View file

@ -13,11 +13,8 @@ import {
useState, useState,
} from "react"; } 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"; import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
@ -38,11 +35,59 @@ 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 = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const { load, save } = useStorage(); const { save } = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const loginPendingRef = useRef(false); const loginPendingRef = useRef(false);
@ -51,19 +96,17 @@ export default function Form() {
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,
});
await navigate({ to: "/", replace: true }); await navigate({ to: "/", replace: true });
return true; return true;
} finally { } finally {
loginPendingRef.current = false; loginPendingRef.current = false;
} }
}, },
[load, navigate, save], [navigate, save],
); );
// Process dropped files // Process dropped files
@ -87,11 +130,6 @@ export default function Form() {
[persistLogin], [persistLogin],
); );
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files // Handle .tu files
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
@ -230,10 +268,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

@ -27,6 +27,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@methanium/ui"; } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar"; import { MobileNavbar } from "./navbar";
@ -34,20 +35,24 @@ 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

@ -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

@ -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

@ -22,14 +22,14 @@ 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";
@ -49,7 +49,7 @@ import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui"; import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { HotkeysProvider } from "@tensamin/hotkeys"; import { isTauri } from "@tauri-apps/api/core";
const wrapper = document.getElementById("root"); const wrapper = document.getElementById("root");
@ -254,20 +254,23 @@ function RootShell() {
parentThemeStorageKey={null} parentThemeStorageKey={null}
designStorageKey={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 /> <Outlet />
<LoginWrapper> </LoginWrapper>
<Outlet />
</LoginWrapper>
</HotkeysProvider>
</Storage> </Storage>
</TooltipProvider> </TooltipProvider>
</div> </div>
@ -283,10 +286,10 @@ function AppShell() {
<DesktopMediaProvider> <DesktopMediaProvider>
<MTPProvider> <MTPProvider>
<CacheSync /> <CacheSync />
<DeeplinkNavigator />
<Session> <Session>
<UserProvider> <UserProvider>
<CallInit /> <CallInit />
<CallPopout />
<TAuthWrapper> <TAuthWrapper>
<AppLayout> <AppLayout>
<ChatContext> <ChatContext>
@ -305,36 +308,6 @@ function AppShell() {
); );
} }
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 +332,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 +395,7 @@ function CallInit() {
}); });
}, [inCall, primaryColor, speaking]); }, [inCall, primaryColor, speaking]);
return callInvitePopup; return null;
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({

View file

@ -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);
@ -136,7 +136,7 @@ function AddConversationButton() {
<Button onClick={onClick}>Add Conversation</Button> <Button onClick={onClick}>Add Conversation</Button>
)} )}
/> />
<DialogContent showCloseButton={false}> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
</DialogHeader> </DialogHeader>

View file

@ -3,7 +3,6 @@ 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 "@methanium/ui";
@ -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,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";
@ -8,10 +8,14 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite"; import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
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 +57,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 +82,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 +92,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,7 +134,7 @@ 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/onboarding",
@ -126,7 +154,6 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }), methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),

View file

@ -6,7 +6,6 @@ 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 { import {
inlineSingleUseDeclarations,
noReactNamespaceImport, noReactNamespaceImport,
noWindowLocationReload, noWindowLocationReload,
} from "./utils/eslint-rules/index.js"; } from "./utils/eslint-rules/index.js";
@ -35,7 +34,6 @@ export default [
"react-hooks": reactHooks, "react-hooks": reactHooks,
tensamin: { tensamin: {
rules: { rules: {
"inline-single-use-declarations": inlineSingleUseDeclarations,
"no-react-namespace-import": noReactNamespaceImport, "no-react-namespace-import": noReactNamespaceImport,
"no-window-location-reload": noWindowLocationReload, "no-window-location-reload": noWindowLocationReload,
}, },
@ -44,7 +42,6 @@ export default [
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-react-namespace-import": "error",
"tensamin/no-window-location-reload": "error", "tensamin/no-window-location-reload": "error",
}, },

185
flake.nix
View file

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

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,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1,15 +0,0 @@
Copyright (c) 2025 Methanium
All rights reserved.
No part of this software, source code, documentation, or
associated materials may be copied, reproduced, modified,
distributed, published, sublicensed, sold, or used to create
derivative works without prior written permission from the
copyright holder.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.

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