Merge branch 'dev'

This commit is contained in:
Alois 2026-05-27 20:15:48 +02:00
commit c5f0be7af2
146 changed files with 4319 additions and 825 deletions

View file

@ -2,22 +2,35 @@ on:
push: push:
branches: branches:
- dev - dev
paths-ignore:
- flake.nix
jobs: jobs:
deploy: build-web:
runs-on: docker runs-on: docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Setup Bun - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Build - name: Copy licenses
run: bun run copy-licenses
- name: Build packages
run: bun run build:packages
- name: Build web
run: bun run build:web run: bun run build:web
- name: Install rsync - name: Install rsync
@ -25,3 +38,238 @@ jobs:
- name: Deploy - name: Deploy
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/ run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/
build-mobile:
runs-on: docker
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Copy licenses
run: bun run copy-licenses
- name: Build packages
run: bun run build:packages
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
- name: Build mobile
run: bun run build:mobile
- name: Upload mobile artifact
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: mobile-apk
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
build-desktop:
runs-on: docker
strategy:
matrix:
target: [linux]
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Copy licenses
run: bun run copy-licenses
- name: Build packages
run: bun run build:packages
- name: Set Electron dev version
run: |
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)"
DEV_VERSION="$VERSION-dev-$SHORT_SHA"
export DEV_VERSION
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.DEV_VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
- name: Build Electron desktop
run: bun run build:desktop
- name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: electron-desktop-${{ matrix.target }}
path: apps/electron/release/
release:
runs-on: docker
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3
with:
name: mobile-apk
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/
- name: Download desktop artifacts
uses: https://data.forgejo.org/actions/download-artifact@v3
with:
name: electron-desktop-linux
path: apps/electron/release/
- name: Read version and hash
id: version
run: |
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create pre-release and upload files
env:
TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }}
REPO: ${{ forgejo.repository }}
SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }}
run: |
set -eu
test -d releases
find releases -type f | grep -q .
LATEST_PROD_TAG="$(git for-each-ref refs/tags --sort=-creatordate --format='%(refname:short)' | awk '!/-/' | head -n 1 || true)"
if [ -n "$LATEST_PROD_TAG" ]; then
RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')"
else
RAW_LOG="$(git log --pretty=format:'- %s')"
fi
export RAW_LOG LATEST_PROD_TAG API REPO TAG
BODY="$(node -e '
const raw = process.env.RAW_LOG;
const lines = raw.split("\n");
const commits = [];
for (const line of lines) {
const msg = line.replace(/^- /, "");
const parts = msg.split(/(?=\([^)]+\):)/).filter(Boolean).map(s => s.trim()).filter(s => s);
for (const part of parts) {
commits.push(part);
}
}
const priority = { "(feat):": 1, "(fix):": 2, "(qol):": 3 };
commits.sort((a, b) => {
const tagA = a.match(/^(\([^)]+\):)/)?.[1] || "";
const tagB = b.match(/^(\([^)]+\):)/)?.[1] || "";
return (priority[tagA] || 99) - (priority[tagB] || 99);
});
const log = commits.map(c => "- " + c).join("\n");
const latestTag = process.env.LATEST_PROD_TAG;
if (latestTag) {
const serverUrl = process.env.API.replace(/\/api\/v1.*/, "");
const compareUrl = serverUrl + "/" + process.env.REPO + "/compare/" + latestTag + "..." + process.env.TAG;
console.log(log + "\n\n[View changes](" + compareUrl + ")");
} else {
console.log(log);
}
')"
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Release $TAG already exists."
RELEASE_ID="$(jq -r .id release_out.json)"
else
echo "Creating new pre-release for $TAG"
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$TITLE" \
--arg body "$BODY" \
--arg target "$SHA" \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: $target,
draft: false,
prerelease: true
}')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
export ASSET_BASE_URL
node -e '
const fs = require("fs");
const path = "releases/electron-release-metadata.json";
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
metadata.version = process.env.TAG;
metadata.tag = process.env.TAG;
for (const artifact of metadata.artifacts || []) {
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
}
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
'
find releases -type f -print0 | while IFS= read -r -d '' file; do
name="$(basename "$file")"
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$file"
done

View file

@ -2,17 +2,18 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore:
- flake.nix
jobs: jobs:
deploy: build-web:
runs-on: docker runs-on: docker
steps: steps:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages - name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix - name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30 uses: https://github.com/cachix/install-nix-action@v30
@ -23,16 +24,14 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Setup Android Keystore - name: Copy licenses
env: run: bun run copy-licenses
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\\\n/g, '\n').replace(/\\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
- name: Build - name: Build packages
run: bun run build:apps run: bun run build:packages
- name: Build web
run: bun run build:web
- name: Install rsync - name: Install rsync
run: apt-get update && apt-get install -y rsync run: apt-get update && apt-get install -y rsync
@ -40,12 +39,135 @@ jobs:
- name: Deploy - name: Deploy
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/ run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/
build-mobile:
runs-on: docker
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Copy licenses
run: bun run copy-licenses
- name: Build packages
run: bun run build:packages
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
- name: Build mobile
run: bun run build:mobile
- name: Upload mobile artifact
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: mobile-apk
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
build-desktop:
runs-on: docker
strategy:
matrix:
target: [linux]
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Copy licenses
run: bun run copy-licenses
- name: Build packages
run: bun run build:packages
- name: Set Electron prod version
run: |
VERSION="$(node -p "require('./package.json').version")"
export VERSION
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
- name: Build Electron desktop
run: bun run build:desktop
- name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: electron-desktop-${{ matrix.target }}
path: apps/electron/release/
release:
runs-on: docker
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3
with:
name: mobile-apk
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/
- name: Download desktop artifacts
uses: https://data.forgejo.org/actions/download-artifact@v3
with:
name: electron-desktop-linux
path: apps/electron/release/
- name: Read version - name: Read version
id: version id: version
run: | run: |
VERSION="$(node -p "require('./package.json').version")" VERSION="$(node -p "require('./package.json').version")"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT" echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "tag=v$VERSION" >> "$FORGEJO_OUTPUT" echo "tag=$VERSION" >> "$FORGEJO_OUTPUT"
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create release and upload files - name: Create release and upload files
env: env:
@ -54,42 +176,88 @@ jobs:
REPO: ${{ forgejo.repository }} REPO: ${{ forgejo.repository }}
SHA: ${{ forgejo.sha }} SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: | run: |
set -eu set -eu
test -d releases test -d releases
find releases -type f | grep -q . find releases -type f | grep -q .
COMMIT_MSG="$(git log -1 --pretty=%B | sed 's/$/ /')"
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG") HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
if [ "$HTTP_STATUS" = "200" ]; then if [ "$HTTP_STATUS" = "200" ]; then
echo "Release $TAG already exists." echo "Release $TAG already exists."
RELEASE_ID="$(jq -r .id release_out.json)" exit 0
else
echo "Creating new release for $TAG"
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
--arg body "Release $VERSION" \
--arg target "$SHA" \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: $target,
draft: false,
prerelease: false
}')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi fi
echo "Creating new release for $TAG"
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
--arg body "$COMMIT_MSG" \
--arg target "$SHA" \
'{
tag_name: $tag,
name: $name,
body: $body,
target_commitish: $target,
draft: false,
prerelease: false
}')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
export ASSET_BASE_URL
node -e '
const fs = require("fs");
const path = "releases/electron-release-metadata.json";
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
metadata.version = process.env.TAG;
metadata.tag = process.env.TAG;
for (const artifact of metadata.artifacts || []) {
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
}
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
'
find releases -type f -print0 | while IFS= read -r -d '' file; do find releases -type f -print0 | while IFS= read -r -d '' file; do
name="$(basename "$file")" name="$(basename "$file")"
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \ curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$file" -F "attachment=@$file"
done done
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
APPIMAGE="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-x86_64.AppImage' -print -quit)"
test -n "$APPIMAGE"
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"));' "$APPIMAGE")"
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(/hash = "sha256-[^"]+";/, `hash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash."
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

View file

@ -2,7 +2,6 @@
"recommendations": [ "recommendations": [
"tauri-apps.tauri-vscode", "tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer", "rust-lang.rust-analyzer",
"bradlc.vscode-tailwindcss", "bradlc.vscode-tailwindcss"
"antfu.vite"
] ]
} }

2
apps/electron/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist
release

61
apps/electron/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

88
apps/electron/flake.nix Normal file
View file

@ -0,0 +1,88 @@
{
description = "Electron Development Environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {nixpkgs, flake-utils, ...}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system; config.allowUnfree = true;};
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
# xorg
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
in {
devShells.default = pkgs.mkShell {
packages = with pkgs; [
nodejs_22
corepack_22
bun
electron
pkg-config
python3
gcc
gnumake
git
jq
patchelf
dpkg
rpm
fpm
] ++ electronRuntimeLibs;
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
export npm_config_build_from_source=true
export USE_SYSTEM_FPM=true
alias electron-install='cd ../.. && bun install'
alias electron-build-web='cd ../.. && bun run build:web'
alias electron-dev='bun run dev'
alias electron-package='bun run package:linux'
alias electron-validate='bun run validate'
'';
};
});
}

View file

@ -0,0 +1,77 @@
{
"name": "@tensamin/electron",
"private": true,
"version": "0.0.3",
"description": "Tensamin desktop client",
"author": "methanium",
"homepage": "https://git.methanium.net/tensamin/client",
"type": "module",
"main": "dist/main/main.js",
"scripts": {
"clean": "rm -rf dist release",
"build:web": "cd ../.. && bun run build:web",
"build": "tsc -p tsconfig.json && esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/preload.cjs",
"dev:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron . --verbose",
"dev": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run dev:raw; else bun run dev:raw; fi",
"start:raw": "bun run build && electron .",
"start": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run start:raw; else bun run start:raw; fi",
"package:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --publish never",
"package": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:raw; else bun run package:raw; fi",
"package:linux:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --linux --publish never",
"package:linux": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:linux:raw; else bun run package:linux:raw; fi",
"package:windows:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --win --publish never",
"package:windows": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:windows:raw; else bun run package:windows:raw; fi",
"checksum": "bun scripts/generate-release-metadata.ts",
"generate-signing-key": "bun scripts/generate-signing-key.ts",
"validate:raw": "bun run build && bun run package:linux:raw",
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run validate:raw; else bun run validate:raw; fi"
},
"dependencies": {
"electron-updater": "^6.6.2"
},
"devDependencies": {
"@types/node": "^25.9.1",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.25.11",
"typescript": "~6.0.3"
},
"build": {
"appId": "net.tensamin.client",
"productName": "Tensamin",
"executableName": "tensamin",
"artifactName": "Tensamin-${version}-${os}-${arch}.${ext}",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "../web/dist",
"to": "web"
}
],
"linux": {
"target": ["AppImage", "deb", "rpm"],
"executableName": "tensamin",
"category": "Network",
"maintainer": "Methanium",
"desktop": {
"entry": {
"Name": "Tensamin",
"StartupWMClass": "Tensamin"
}
}
},
"win": {
"target": ["nsis", "portable"]
},
"mac": {
"target": ["dmg"]
},
"publish": null
}
}

View file

@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { createReadStream, existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import rootPackage from "../../../package.json" with { type: "json" };
const releaseDir = join(import.meta.dir, "..", "release");
const outDir = join(import.meta.dir, "..", "..", "..", "releases");
function sha256(filePath: string) {
const hash = createHash("sha256");
const stream = createReadStream(filePath);
return new Promise<string>((resolve, reject) => {
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex")));
});
}
function platformFor(file: string) {
if (/win|nsis|portable|\.exe$/i.test(file)) return "windows";
if (/mac|darwin|\.dmg$/i.test(file)) return "macos";
return "linux";
}
function archFor(file: string) {
if (/arm64|aarch64/i.test(file)) return "arm64";
return "x64";
}
if (!existsSync(releaseDir)) {
throw new Error(`Missing Electron release directory: ${releaseDir}`);
}
const files = readdirSync(releaseDir)
.filter((file) => !file.endsWith(".blockmap") && !file.endsWith(".yml"))
.map((file) => join(releaseDir, file))
.filter((file) => statSync(file).isFile());
const artifacts = await Promise.all(
files.map(async (filePath) => ({
name: basename(filePath),
platform: platformFor(filePath),
arch: archFor(filePath),
url: `__FORGEJO_RELEASE_ASSET_URL__/${encodeURIComponent(basename(filePath))}`,
sha256: await sha256(filePath),
size: statSync(filePath).size,
})),
);
const metadata = {
version: rootPackage.version,
tag: rootPackage.version,
publishedAt: new Date().toISOString(),
artifacts,
};
writeFileSync(join(outDir, "electron-release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
writeFileSync(
join(outDir, "SHA256SUMS"),
`${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`,
);

View file

@ -0,0 +1,11 @@
import { generateKeyPairSync } from "node:crypto";
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
console.log("TENSAMIN_UPDATE_PRIVATE_KEY_PEM=");
console.log(privateKey.trim());
console.log("\nTENSAMIN_UPDATE_PUBLIC_KEY_PEM=");
console.log(publicKey.trim());

View file

@ -0,0 +1,274 @@
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { app, BrowserWindow, desktopCapturer, ipcMain, session, shell } from "electron";
import { checkForUpdates } from "./updates.js";
import { ipcChannels, type DesktopScreenShareCapabilities } from "../shared/ipc.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | null = null;
if (verbose) {
app.commandLine.appendSwitch("enable-logging", "stderr");
app.commandLine.appendSwitch("v", "1");
app.commandLine.appendSwitch("log-level", "0");
}
if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland" && !process.env.TENSAMIN_ENABLE_VULKAN) {
app.commandLine.appendSwitch("disable-features", "Vulkan");
}
function verboseLog(...args: unknown[]) {
if (verbose) {
console.log("[tensamin:electron]", ...args);
}
}
function getRendererIndex() {
if (!app.isPackaged) {
return resolve(__dirname, "../../../web/dist/index.html");
}
return join(process.resourcesPath, "web", "index.html");
}
function getPlatform(): DesktopScreenShareCapabilities["platform"] {
if (process.platform === "linux") return "linux";
if (process.platform === "darwin") return "macos";
if (process.platform === "win32") return "windows";
return "other";
}
function getScreenShareCapabilities(): DesktopScreenShareCapabilities {
const platform = getPlatform();
return {
runtime: "electron",
platform,
showAudioOutputSelector: platform === "linux",
showAudioSwitch: platform === "windows" || platform === "macos",
hasReliableSystemAudio: platform === "windows",
};
}
function execJson(command: string, args: string[]) {
verboseLog("exec", command, args.join(" "));
return new Promise<unknown>((resolvePromise, reject) => {
execFile(command, args, { timeout: 3000 }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr.trim() || error.message));
return;
}
resolvePromise(JSON.parse(stdout));
});
});
}
async function listAudioOutputs() {
verboseLog("listAudioOutputs", { platform: process.platform });
if (process.platform !== "linux") return [];
const sinks = await execJson("pactl", ["--format=json", "list", "sinks"]);
if (!Array.isArray(sinks)) return [];
return sinks
.map((sink) => {
if (!sink || typeof sink !== "object") return null;
const record = sink as Record<string, unknown>;
const id = record.index == null ? undefined : String(record.index);
const name = typeof record.description === "string" ? record.description : id;
if (!id || !name) return null;
return { id, name, isDefault: false };
})
.filter(Boolean);
}
async function listScreenShareSources() {
verboseLog("listScreenShareSources");
const sources = await desktopCapturer.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 320, height: 180 },
fetchWindowIcons: true,
});
return sources.map((source) => ({
id: source.id,
kind: source.id.startsWith("screen:") ? "screen" : "window",
name: source.name,
subtitle: source.id,
thumbnail: source.thumbnail.isEmpty() ? null : source.thumbnail.toDataURL(),
}));
}
function registerDisplayMediaHandler() {
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
verboseLog("display media request", { selectedScreenShareSourceId });
const sources = await desktopCapturer.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 0, height: 0 },
});
const selected = sources.find((source) => source.id === selectedScreenShareSourceId);
selectedScreenShareSourceId = null;
const video = selected ?? sources[0];
if (!video) {
callback({});
return;
}
if (process.platform === "win32") {
callback({ video, audio: "loopback" });
return;
}
callback({ video });
});
}
function registerIpc() {
verboseLog("registering ipc handlers");
ipcMain.handle(ipcChannels.listScreenShareSources, listScreenShareSources);
ipcMain.handle(ipcChannels.listScreenShareAudioOutputs, listAudioOutputs);
ipcMain.handle(ipcChannels.getScreenShareCapabilities, getScreenShareCapabilities);
ipcMain.handle(ipcChannels.selectScreenShareSource, (_event, sourceId: unknown) => {
if (typeof sourceId !== "string" || sourceId.length === 0 || sourceId.length > 256) {
throw new Error("Invalid screen share source id.");
}
selectedScreenShareSourceId = sourceId;
verboseLog("selected screen share source", sourceId);
return true;
});
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
ipcMain.handle(ipcChannels.minimizeWindow, () => {
verboseLog("window:minimize");
mainWindow?.minimize();
});
ipcMain.handle(ipcChannels.maximizeWindow, () => {
verboseLog("window:maximize");
if (!mainWindow) return;
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
return;
}
mainWindow.maximize();
});
ipcMain.handle(ipcChannels.closeWindow, () => {
verboseLog("window:close");
mainWindow?.close();
});
}
async function createWindow() {
const rendererIndex = getRendererIndex();
verboseLog("creating main window", {
appVersion: app.getVersion(),
electronVersion: process.versions.electron,
chromeVersion: process.versions.chrome,
nodeVersion: process.versions.node,
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
rendererIndex,
argv: process.argv,
});
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
title: "Tensamin",
frame: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
mainWindow.setMenuBarVisibility(false);
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
verboseLog("blocked window open", url);
void shell.openExternal(url);
return { action: "deny" };
});
if (verbose) {
mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => {
const target = level >= 2 ? console.error : console.log;
target("[tensamin:renderer]", message, { level, line, sourceId });
});
mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
console.error("[tensamin:electron] renderer failed to load", {
errorCode,
errorDescription,
validatedURL,
});
});
mainWindow.webContents.on("did-finish-load", () => {
verboseLog("renderer finished loading", mainWindow?.webContents.getURL());
});
mainWindow.webContents.on("render-process-gone", (_event, details) => {
console.error("[tensamin:electron] renderer process gone", details);
});
mainWindow.on("unresponsive", () => {
console.error("[tensamin:electron] main window became unresponsive");
});
}
await mainWindow.loadFile(rendererIndex);
}
app.on("window-all-closed", () => {
verboseLog("window-all-closed");
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
verboseLog("activate");
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
});
if (verbose) {
process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error);
});
process.on("unhandledRejection", (reason) => {
console.error("[tensamin:electron] unhandled rejection", reason);
});
}
async function start() {
verboseLog("waiting for app readiness");
await app.whenReady();
verboseLog("app ready");
registerIpc();
registerDisplayMediaHandler();
await createWindow();
}
void start().catch((error) => {
console.error("[tensamin:electron] failed to start", error);
app.exit(1);
});

View file

@ -0,0 +1,152 @@
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { app, net } from "electron";
import type { ReleaseArtifact, ReleaseMetadata, UpdateCheckResult } from "../shared/ipc.js";
const metadataUrl = process.env.TENSAMIN_UPDATE_METADATA_URL;
function compareSemver(left: string, right: string) {
const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function isDevVersion(version: string) {
return /-dev[.-]/.test(version);
}
function platformName() {
if (process.platform === "win32") return "windows";
if (process.platform === "darwin") return "macos";
if (process.platform === "linux") return "linux";
return process.platform;
}
function archName() {
if (process.arch === "x64") return "x64";
if (process.arch === "arm64") return "arm64";
return process.arch;
}
function requestText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const request = net.request(url);
request.on("response", (response) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(`Update request failed with HTTP ${response.statusCode}.`));
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
});
request.on("error", reject);
request.end();
});
}
function requestBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const request = net.request(url);
request.on("response", (response) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
reject(new Error(`Download failed with HTTP ${response.statusCode}.`));
return;
}
const chunks: Buffer[] = [];
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
response.on("end", () => resolve(Buffer.concat(chunks)));
});
request.on("error", reject);
request.end();
});
}
async function sha256File(filePath: string) {
const hash = createHash("sha256");
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on("data", (chunk) => hash.update(chunk));
stream.on("error", reject);
stream.on("end", resolve);
});
return hash.digest("hex");
}
function selectArtifact(metadata: ReleaseMetadata): ReleaseArtifact | undefined {
const platform = platformName();
const arch = archName();
return metadata.artifacts.find(
(artifact) => artifact.platform === platform && artifact.arch === arch,
);
}
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const currentVersion = app.getVersion();
if (!metadataUrl) {
return { available: false, currentVersion, latestVersion: currentVersion };
}
const metadata = JSON.parse(await requestText(metadataUrl)) as ReleaseMetadata;
const artifact = selectArtifact(metadata);
if (isDevVersion(metadata.version) !== isDevVersion(currentVersion)) {
return { available: false, currentVersion, latestVersion: metadata.version };
}
if (isDevVersion(currentVersion)) {
if (!artifact || metadata.version === currentVersion) {
return { available: false, currentVersion, latestVersion: metadata.version };
}
return {
available: true,
currentVersion,
latestVersion: metadata.version,
artifact,
};
}
if (!artifact || compareSemver(metadata.version, currentVersion) <= 0) {
return { available: false, currentVersion, latestVersion: metadata.version };
}
return {
available: true,
currentVersion,
latestVersion: metadata.version,
artifact,
};
}
export async function downloadVerifiedArtifact(artifact: ReleaseArtifact) {
const updatesDir = join(app.getPath("userData"), "updates");
await rm(updatesDir, { recursive: true, force: true });
await mkdir(updatesDir, { recursive: true });
const destination = join(updatesDir, basename(artifact.name));
await writeFile(destination, await requestBuffer(artifact.url), { mode: 0o600 });
const actualHash = await sha256File(destination);
if (actualHash !== artifact.sha256) {
await rm(destination, { force: true });
throw new Error("Downloaded update failed checksum verification.");
}
return destination;
}

View file

@ -0,0 +1,43 @@
import { contextBridge, ipcRenderer } from "electron";
import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
function windowAction(channel: string) {
return () => ipcRenderer.invoke(channel);
}
const desktopApi = {
media: {
listScreenShareSources: () =>
ipcRenderer.invoke(ipcChannels.listScreenShareSources),
listScreenShareAudioOutputs: () =>
ipcRenderer.invoke(ipcChannels.listScreenShareAudioOutputs),
getScreenShareCapabilities: () =>
ipcRenderer.invoke(ipcChannels.getScreenShareCapabilities),
selectScreenShareSource: (sourceId: DesktopScreenShareSource["id"]) => {
if (typeof sourceId !== "string" || sourceId.length === 0) {
return Promise.reject(new Error("Invalid screen share source id."));
}
return ipcRenderer.invoke(ipcChannels.selectScreenShareSource, sourceId);
},
},
app: {
getVersion: () => ipcRenderer.invoke(ipcChannels.getVersion),
},
updates: {
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
},
window: {
minimize: () => windowAction(ipcChannels.minimizeWindow),
maximize: () => windowAction(ipcChannels.maximizeWindow),
close: () => windowAction(ipcChannels.closeWindow),
},
};
contextBridge.exposeInMainWorld("tensaminDesktop", desktopApi);
contextBridge.exposeInMainWorld(
"tensaminShowWindowControls",
process.env.TENSAMIN_HIDE_CONTROLS == null,
);
export type TensaminDesktopApi = typeof desktopApi;

View file

@ -0,0 +1,58 @@
export type DesktopScreenShareSource = {
id: string;
kind: "screen" | "window";
name: string;
subtitle?: string | null;
thumbnail?: string | null;
};
export type DesktopScreenShareAudioOutput = {
id: string;
name: string;
isDefault: boolean;
};
export type DesktopScreenShareCapabilities = {
runtime: "electron";
platform: "linux" | "macos" | "windows" | "other";
showAudioOutputSelector: boolean;
showAudioSwitch: boolean;
hasReliableSystemAudio: boolean;
};
export type ReleaseArtifact = {
name: string;
platform: string;
arch: string;
url: string;
sha256: string;
size: number;
};
export type ReleaseMetadata = {
version: string;
tag: string;
publishedAt: string;
artifacts: ReleaseArtifact[];
};
export type UpdateCheckResult =
| { available: false; currentVersion: string; latestVersion: string }
| {
available: true;
currentVersion: string;
latestVersion: string;
artifact: ReleaseArtifact;
};
export const ipcChannels = {
listScreenShareSources: "desktopMedia:listScreenShareSources",
listScreenShareAudioOutputs: "desktopMedia:listScreenShareAudioOutputs",
getScreenShareCapabilities: "desktopMedia:getScreenShareCapabilities",
selectScreenShareSource: "desktopMedia:selectScreenShareSource",
minimizeWindow: "window:minimize",
maximizeWindow: "window:maximize",
closeWindow: "window:close",
getVersion: "app:getVersion",
checkForUpdates: "updates:checkForUpdates",
} as const;

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node", "electron"]
},
"include": ["src/**/*.ts"]
}

BIN
apps/tauri/android.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
apps/tauri/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 MiB

View file

@ -1,5 +1,5 @@
{ {
description = "Tauri development environment"; description = "Tauri mobile development environment";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@ -53,34 +53,6 @@
]; ];
}; };
desktopRuntimeLibs = with pkgs; [
glib
gtk3
gdk-pixbuf
pango
cairo
atk
at-spi2-atk
alsa-lib
dbus
cups
expat
libdrm
libgbm
libglvnd
mesa
libxkbcommon
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
systemd
nspr
nss
];
in { in {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; buildInputs = with pkgs;
@ -91,7 +63,6 @@
nodejs nodejs
pkg-config pkg-config
] ]
++ desktopRuntimeLibs
++ [ ++ [
android.androidsdk android.androidsdk
pkgs.android-studio-tools pkgs.android-studio-tools
@ -130,7 +101,6 @@
export NDK_HOME="$ANDROID_NDK_ROOT" export NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_PATH="$ANDROID_NDK_ROOT" export NDK_PATH="$ANDROID_NDK_ROOT"
export JAVA_HOME="${pkgs.jdk17}" export JAVA_HOME="${pkgs.jdk17}"
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath desktopRuntimeLibs}:$LD_LIBRARY_PATH"
''; '';
}; };
} }

8
apps/tauri/logo.json Normal file
View file

@ -0,0 +1,8 @@
{
"default": "./logo.svg",
"android_fg": "./android.png",
"android_bg": "./background.png",
"android_fg_scale": 100,
"android_monochrome": "./monochrome.png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 997 KiB

237
apps/tauri/logo.svg Normal file
View file

@ -0,0 +1,237 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="90mm"
height="90mm"
viewBox="0 0 90 90"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_raw.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="0.99999999"
inkscape:cx="450"
inkscape:cy="291.5"
inkscape:window-width="1223"
inkscape:window-height="1369"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><linearGradient
id="swatch15"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop15" /></linearGradient><linearGradient
id="swatch10"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop10" /></linearGradient><linearGradient
id="swatch3"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop3" /></linearGradient><linearGradient
id="swatch2"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop2" /></linearGradient><linearGradient
id="swatch1"
inkscape:swatch="solid"><stop
style="stop-color:#031616;stop-opacity:1;"
offset="0"
id="stop1" /></linearGradient><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
radius="3"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="bspline"
id="path-effect6"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="spiro"
id="path-effect5"
is_visible="true"
lpeversion="1" /><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1"><path
id="path3"
style="fill:#043b3c;stroke-width:0.320821"
inkscape:label="arrow"
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient15"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient16"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient17"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient18"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient19"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient20"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient21"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient22"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient23"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient25"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-62.941767,-104.13688)"><g
id="g2"
transform="matrix(0.78671048,0,0,0.83608014,-116.36038,-50.640745)"
inkscape:label="background"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path
id="rect1"
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1"
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3"
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6"
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
id="g3"
inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
transform="translate(3.3653788,0.84760028)"><g
id="g1"
inkscape:label="outline"
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
id="path15-3-5"
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="filler"
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
transform="translate(159.54375,40.87617)" /></g><path
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
id="path2"
sodipodi:nodetypes="cccccccc"
inkscape:label="bolt" /><path
id="path1"
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="dark_outline"
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

BIN
apps/tauri/monochrome.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -4,10 +4,6 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./context": {
"types": "./src/context.tsx",
"default": "./src/context.tsx"
},
"./controls": { "./controls": {
"types": "./src/windowControls.tsx", "types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx" "default": "./src/windowControls.tsx"
@ -25,12 +21,8 @@
"dev:mobile:raw": "tauri android dev", "dev:mobile:raw": "tauri android dev",
"build:mobile:raw": "tauri android build", "build:mobile:raw": "tauri android build",
"dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi", "dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi",
"build:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi", "build:mobile": "bun run render-version.ts && if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi && bun run render-version.ts --unrender",
"dev:desktop:raw": "tauri dev", "gen-icons": "tauri icon ./logo.json",
"build:desktop:raw": "tauri build",
"dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi",
"build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi",
"gen-icons": "tauri icon ./logo.png",
"format": "bunx prettier --write .", "format": "bunx prettier --write .",
"lint": "eslint src" "lint": "eslint src"
}, },
@ -46,6 +38,7 @@
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2",
"@types/node": "^25.9.1"
} }
} }

View file

@ -1,5 +0,0 @@
# Outputs
- ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
- ./src-tauri/target/release/bundle/deb/Tensamin*{version}*{arch}.deb
- ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm

View file

@ -0,0 +1,65 @@
import fs from "fs";
import path from "path";
// Config
const PLACEHOLDER_VERSION = "0.0.0";
const rootPackageJsonPath = path.resolve(__dirname, "../../package.json");
const cargoTomlPath = path.resolve(__dirname, "./src-tauri/Cargo.toml");
const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json");
// Args
const isUnrender = process.argv.includes("--unrender");
// Version Source
const packageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8"));
const packageVersion: string = packageJson.version;
if (!packageVersion && !isUnrender) {
throw new Error("No version found in package.json");
}
const targetVersion = isUnrender ? PLACEHOLDER_VERSION : packageVersion;
// Helpers
function updateCargoToml(content: string): string {
const regex = /^version\s*=\s*".*"$/m;
if (!regex.test(content)) {
throw new Error("Could not find version field in Cargo.toml");
}
return content.replace(regex, `version = "${targetVersion}"`);
}
function updateTauriConfig(content: string): string {
const regex = /"version"\s*:\s*".*"/;
if (!regex.test(content)) {
throw new Error("Could not find version field in tauri.conf.json");
}
return content.replace(regex, `"version": "${targetVersion}"`);
}
// Update Cargo.toml
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
const updatedCargoToml = updateCargoToml(cargoToml);
fs.writeFileSync(cargoTomlPath, updatedCargoToml, "utf8");
// Update tauri.conf.json
const tauriConfig = fs.readFileSync(tauriConfigPath, "utf8");
const updatedTauriConfig = updateTauriConfig(tauriConfig);
fs.writeFileSync(tauriConfigPath, updatedTauriConfig, "utf8");
// Finished
if (isUnrender) {
console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`);
} else {
console.log(`Rendered version ${targetVersion}`);
}

View file

@ -4838,7 +4838,7 @@ dependencies = [
[[package]] [[package]]
name = "tensamin" name = "tensamin"
version = "0.1.0" version = "0.0.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"image 0.25.10", "image 0.25.10",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "tensamin" name = "tensamin"
version = "0.1.0" version = "0.0.0"
description = "Privacy focused messanger" description = "Privacy focused messanger"
authors = ["methanium"] authors = ["methanium"]
edition = "2021" edition = "2021"
@ -18,36 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies]
xcap = "0.4.1"
[target.'cfg(target_os = "android")'.dependencies.tauri] [target.'cfg(target_os = "android")'.dependencies.tauri]
version = "2" version = "2"
features = [] features = []
default-features = true default-features = true
[target.'cfg(target_os = "windows")'.dependencies.tauri]
version = "2"
features = ["compression", "common-controls-v6", "dynamic-acl"]
default-features = true
[target.'cfg(target_os = "linux")'.dependencies.tauri]
version = "2"
features = ["common-controls-v6", "cef", "compression", "dynamic-acl", "x11"]
default-features = false
[target.'cfg(target_os = "macos")'.dependencies.tauri]
version = "2"
features = ["x11", "common-controls-v6", "cef", "compression", "dynamic-acl"]
default-features = false
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2" tauri-plugin-barcode-scanner = "2"
tauri-plugin-app-events = "0.2" tauri-plugin-app-events = "0.2"

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">tensamin</string> <string name="app_name">Tensamin</string>
<string name="main_activity_title">tensamin</string> <string name="main_activity_title">Tensamin</string>
</resources> </resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,001 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -1,204 +1,7 @@
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareSource {
id: String,
kind: String,
name: String,
subtitle: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareAudioOutput {
id: String,
name: String,
is_default: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ScreenShareCapabilities {
platform: String,
show_audio_output_selector: bool,
show_audio_switch: bool,
has_reliable_system_audio: bool,
}
#[tauri::command]
fn list_screen_share_sources() -> Result<Vec<ScreenShareSource>, String> {
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
{
use xcap::{Monitor, Window};
let mut sources = Vec::new();
let mut errors = Vec::new();
match Monitor::all() {
Ok(monitors) => {
for (index, monitor) in monitors.into_iter().enumerate() {
let mut subtitle = None;
if monitor.is_primary().unwrap_or(false) {
subtitle = Some("Primary display".to_string());
}
sources.push(ScreenShareSource {
id: format!("screen:{index}"),
kind: "screen".to_string(),
name: monitor
.name()
.unwrap_or_else(|_| format!("Display {}", index + 1)),
subtitle,
});
}
}
Err(error) => errors.push(format!("display listing failed: {error}")),
}
match Window::all() {
Ok(windows) => {
for (index, window) in windows.into_iter().enumerate() {
if window.is_minimized().unwrap_or(false) {
continue;
}
let title = window.title().unwrap_or_default();
if title.trim().is_empty() {
continue;
}
sources.push(ScreenShareSource {
id: format!("window:{index}"),
kind: "window".to_string(),
name: title,
subtitle: None,
});
}
}
Err(error) => errors.push(format!("window listing failed: {error}")),
}
if sources.is_empty() {
if errors.is_empty() {
return Ok(sources);
}
return Err(errors.join("; "));
}
return Ok(sources);
}
#[allow(unreachable_code)]
Ok(Vec::new())
}
#[tauri::command]
fn list_audio_outputs() -> Result<Vec<ScreenShareAudioOutput>, String> {
#[cfg(target_os = "linux")]
{
use serde_json::Value;
use std::process::Command;
let output = Command::new("pactl")
.args(["--format=json", "list", "sinks"])
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let default_sink = Command::new("pactl")
.arg("get-default-sink")
.output()
.ok()
.filter(|result| result.status.success())
.map(|result| String::from_utf8_lossy(&result.stdout).trim().to_string());
let sinks: Value = serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
let sink_entries = sinks
.as_array()
.ok_or_else(|| "Unexpected pactl sink response".to_string())?;
let mut outputs = Vec::new();
for sink in sink_entries {
let Some(index) = sink.get("index").and_then(Value::as_i64) else {
continue;
};
let Some(name) = sink.get("name").and_then(Value::as_str) else {
continue;
};
let description = sink
.get("description")
.and_then(Value::as_str)
.or_else(|| {
sink.get("properties")
.and_then(|properties| properties.get("device.description"))
.and_then(Value::as_str)
})
.unwrap_or(name)
.to_string();
let is_default = default_sink.as_deref() == Some(name);
outputs.push(ScreenShareAudioOutput {
id: index.to_string(),
name: description,
is_default,
});
}
outputs.sort_by_key(|output| !output.is_default);
return Ok(outputs);
}
#[allow(unreachable_code)]
Ok(Vec::new())
}
#[tauri::command]
fn get_screen_share_capabilities() -> ScreenShareCapabilities {
ScreenShareCapabilities {
platform: if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"other"
}
.to_string(),
show_audio_output_selector: cfg!(target_os = "linux"),
show_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")),
has_reliable_system_audio: cfg!(target_os = "windows"),
}
}
#[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();
#[cfg(any(target_os = "linux", target_os = "macos"))]
let builder = builder.command_line_args([
("enable-media-stream", None::<String>),
("enable-usermedia-screen-capturing", None::<String>),
("allow-http-screen-capture", None::<String>),
#[cfg(target_os = "linux")]
(
"enable-features",
Some("WebRTCPipeWireCapturer".to_string()),
),
]);
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());
@ -225,11 +28,6 @@ pub fn run() {
} }
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![
list_screen_share_sources,
list_audio_outputs,
get_screen_share_capabilities
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
{ {
eprintln!("error while running tauri application: {error}"); eprintln!("error while running tauri application: {error}");

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Tensamin", "productName": "Tensamin",
"version": "0.1.0", "version": "0.0.0",
"mainBinaryName": "tensamin", "mainBinaryName": "tensamin",
"identifier": "net.tensamin.client", "identifier": "net.tensamin.client",
"build": { "build": {
@ -26,7 +26,7 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app", "deb", "rpm", "nsis", "msi", "dmg"], "targets": [],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
@ -37,9 +37,6 @@
}, },
"plugins": { "plugins": {
"deep-link": { "deep-link": {
"desktop": {
"schemes": ["tensamin"]
},
"mobile": [ "mobile": [
{ {
"scheme": ["tensamin"], "scheme": ["tensamin"],

View file

@ -7,6 +7,7 @@ import {
} from "react"; } from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui";
type DeeplinkContextValue = { type DeeplinkContextValue = {
deeplinks: readonly string[]; deeplinks: readonly string[];
@ -31,10 +32,10 @@ export default function DeeplinkProvider({
children: ReactNode; children: ReactNode;
}) { }) {
const [deeplinks, setDeeplinks] = useState<string[]>([]); const [deeplinks, setDeeplinks] = useState<string[]>([]);
const isTauriEnv = isTauri(); const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
if (!isTauriEnv) return; if (!isTauri() || !isMobile) return;
let mounted = true; let mounted = true;
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@ -57,7 +58,7 @@ export default function DeeplinkProvider({
mounted = false; mounted = false;
unlisten?.(); unlisten?.();
}; };
}, [isTauriEnv]); }, [isMobile]);
return ( return (
<deeplinkContext.Provider <deeplinkContext.Provider

View file

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["node"],
"target": "ES2022", "target": "ES2022",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
@ -8,5 +9,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["src"] "include": ["src", "./render-version.ts"]
} }

View file

@ -26,6 +26,7 @@
"@tensamin/tauri": "workspace:*", "@tensamin/tauri": "workspace:*",
"@tensamin/ttp": "workspace:*", "@tensamin/ttp": "workspace:*",
"@tensamin/tauth": "workspace:*", "@tensamin/tauth": "workspace:*",
"@tensamin/markdown": "workspace:*",
"@tensamin/ui": "*", "@tensamin/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"@tensamin/notifications": "workspace:*", "@tensamin/notifications": "workspace:*",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before After
Before After

View file

@ -1,20 +1,58 @@
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui"; import {
import { reduceDisplay } from "./utils"; Avatar,
AvatarImage,
AvatarFallback,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@tensamin/ui";
import { Card, CardHeader } from "@tensamin/ui"; import { Card, CardHeader } from "@tensamin/ui";
import { Skeleton } from "@tensamin/ui"; import { Skeleton } from "@tensamin/ui";
import { getStatusColor } from "@tensamin/shared/data";
export function Basic(props: { user: User }) { export function Basic({
user,
extra,
}: {
user: User;
extra?: React.ReactNode;
}) {
return ( return (
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px"> <Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2"> <CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
<Avatar> <div className="relative shrink-0 overflow-visible">
<AvatarImage src={props.user.avatar} /> <Avatar>
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback> <AvatarImage src={user.avatar} />
</Avatar> <AvatarFallback>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]"> {user.display.slice(0, 2).toUpperCase()}
<p>{props.user.display}</p> </AvatarFallback>
</Avatar>
<Tooltip>
<TooltipTrigger
render={
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
<div
style={{
backgroundColor: getStatusColor(user.online_status),
}}
className="h-2.25 w-2.25 rounded-full"
/>
</div>
}
/>
<TooltipContent>
{user.online_status
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</TooltipContent>
</Tooltip>
</div> </div>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
<p>{user.display}</p>
</div>
<div className="pr-1">{extra}</div>
</CardHeader> </CardHeader>
</Card> </Card>
); );

View file

@ -0,0 +1,34 @@
import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import Text from "@tensamin/markdown/text";
export default function Profile({ user }: { user: User }) {
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Avatar className="size-10">
<AvatarImage src={user.avatar} />
<AvatarFallback className="text-lg">
{user.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-lg font-semibold">{user.display}</p>
<p className="text-muted-foreground">{user.username}</p>
</div>
</div>
<Text value={user.about || ""} />
<div className="flex flex-col">
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
iota: {user.iota_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
user: {user.user_id}
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
pub key: {user.public_key}
</p>
</div>
</div>
);
}

View file

@ -1,13 +0,0 @@
/**
* Executes reduceDisplay.
* @param display Parameter display.
* @returns unknown.
*/
export function reduceDisplay(display: string) {
const words = display.split(" ");
if (words.length === 1) {
return display.slice(0, 2).toUpperCase();
} else {
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
}
}

View file

@ -1,5 +1,13 @@
import { Button } from "@tensamin/ui"; import { Button, Popover, PopoverContent, PopoverTrigger } from "@tensamin/ui";
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react"; import {
ArrowLeft,
ChevronDown,
ChevronUp,
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";
@ -10,6 +18,7 @@ import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui"; import { SidebarTrigger, useSidebar } from "@tensamin/ui";
import { WindowControls as Controls } from "@tensamin/ui"; import { WindowControls as Controls } from "@tensamin/ui";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile";
export default function Navbar({ forMobile }: { forMobile: boolean }) { export default function Navbar({ forMobile }: { forMobile: boolean }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -23,10 +32,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const currentCalls = calls.filter((call) => const currentCalls = calls.filter((call) =>
call.call_members.some((member) => member === id), call.call_members.some((member) => member === id),
); );
//const currentCalls = ["leck", "schleck", "und", "eck"];
const [selectOpen, setSelectOpen] = useState(false); const [selectOpen, setSelectOpen] = useState(false);
const [userInfoOpen, setUserInfoOpen] = useState(false);
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
@ -63,7 +73,27 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<Wrapper <Wrapper
userId={id} userId={id}
component={(user) => ( component={(user) => (
<p className="font-medium text-md">{user?.display}</p> <>
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger
render={
<Button
variant="link"
className="px-0! text-foreground"
style={{
textDecorationLine: "none",
}}
>
<p className="font-medium text-md">{user?.display}</p>
{userInfoOpen ? <ChevronUp /> : <ChevronDown />}
</Button>
}
/>
<PopoverContent side="bottom">
<Profile user={user} />
</PopoverContent>
</Popover>
</>
)} )}
loading={<Skeleton className="ml-3 w-40 h-5" />} loading={<Skeleton className="ml-3 w-40 h-5" />}
/> />
@ -126,7 +156,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)} )}
</> </>
)} )}
<Controls className="mr-2 ring-border" /> <Controls className="mr-2" />
</div> </div>
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { Button } from "@tensamin/ui"; import { Button, cn, useIsMobile } from "@tensamin/ui";
import { Input } from "@tensamin/ui"; import { Input } from "@tensamin/ui";
import { Label } from "@tensamin/ui"; import { Label } from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
@ -83,25 +83,19 @@ function parseTuFileContent(rawFileContent: string): {
return { userId, privateKey, domain }; return { userId, privateKey, domain };
} }
/**
* Renders the login form for file upload and manual credential login.
* @returns Login form JSX.
*/
export default function Form() { export default function Form() {
const isMobile = useIsMobile();
const uploadRef = React.useRef<HTMLInputElement | null>(null); const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage(); const { save } = useStorage();
/** // Process dropped files
* Handles uploaded .tu files and stores resolved credentials. const processDroppedFile = React.useCallback(
* @param event Change event from the hidden file input. async (file: globalThis.File): Promise<void> => {
* @returns Promise that resolves when processing has finished.
*/
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
try { try {
const file = event.currentTarget.files?.[0]; if (!file.name.endsWith(".tu")) {
if (!file) { toast("error", "Please upload a .tu file");
throw new Error("No file selected"); return;
} }
const raw = await file.text(); const raw = await file.text();
@ -123,13 +117,94 @@ export default function Form() {
[save], [save],
); );
// Handle .tu files
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.currentTarget.files?.[0];
if (!file) {
toast("error", "No file selected");
return;
}
await processDroppedFile(file);
},
[processDroppedFile],
);
// Drag and drop listener
React.useEffect(() => {
let dragCounter = 0;
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter++;
if (event.dataTransfer?.items?.length) {
setIsDragging(true);
}
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter--;
if (dragCounter <= 0) {
setIsDragging(false);
}
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
};
const handleDrop = async (event: DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragCounter = 0;
setIsDragging(false);
const file = event.dataTransfer?.files?.[0];
if (!file) {
toast("error", "No file dropped");
return;
}
await processDroppedFile(file);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}, [processDroppedFile]);
/** /**
* Handles username and private key login submission. * Handles username and private key login submission.
* @param event Form submit event. * @param event Form submit event.
* @returns Promise that resolves after login processing. * @returns Promise that resolves after login processing.
*/ */
const handleCredentialsSubmit = React.useCallback( const handleCredentialsSubmit = React.useCallback(
async (event: React.SubmitEvent<HTMLFormElement>): Promise<void> => { async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@ -190,34 +265,35 @@ export default function Form() {
[save], [save],
); );
const isTauriEnv = isTauri();
return ( return (
<div className="flex md:flex-row flex-col gap-15"> <div className="relative flex md:flex-row flex-col gap-15">
{isTauriEnv ? ( {isTauri() && isMobile ? (
<> <>
<QrCodeScanner <QrCodeScanner
onData={async (data) => { onData={async (data) => {
if (!data.startsWith("tensamin://tu::")) { if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code"); toast("error", "Invalid QR code");
return; return;
} else { }
const decoded = data.replace("tensamin://tu::", "");
try { const decoded = data.replace("tensamin://tu::", "");
const { userId, privateKey, domain } =
parseTuFileContent(decoded); try {
await save("session_id", Date.now()); const { userId, privateKey, domain } =
await save("user_id", userId); parseTuFileContent(decoded);
await save("private_key", privateKey);
if (domain) { await save("session_id", Date.now());
await save("ttp_url", `https://${domain}/`); await save("user_id", userId);
} await save("private_key", privateKey);
location.href = "/";
} catch (error) { if (domain) {
log(0, "login", "red", error); await save("ttp_url", `https://${domain}/`);
toast("error", "Failed to parse QR code data");
} }
location.href = "/";
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
} }
}} }}
/> />
@ -228,10 +304,30 @@ export default function Form() {
) : ( ) : (
<div <div
onClick={() => uploadRef.current?.click()} onClick={() => uploadRef.current?.click()}
className="flex flex-col gap-3 cursor-pointer w-55 aspect-square bg-input/13 hover:bg-input/30 transition-all duration-300 ease-in-out border-3 items-center justify-center rounded-lg" className={cn(
"flex flex-col gap-3 cursor-pointer w-55 aspect-square",
"border-3 items-center justify-center rounded-lg",
"transition-all duration-300 ease-in-out",
"border-input",
"bg-input/13 hover:bg-input/30",
isDragging ? "animate-wiggle" : "",
)}
> >
<File className="text-foreground" size={27} /> <File
<p className="text-md">Select .tu file</p> className={["transition-all duration-300 text-foreground"].join(
" ",
)}
size={27}
/>
<p
className={[
"text-md transition-all duration-300",
isDragging ? "font-medium" : "",
].join(" ")}
>
Select .tu file
</p>
</div> </div>
)} )}
<input <input

View file

@ -6,6 +6,27 @@ import {
Sidebar as SidebarRoot, Sidebar as SidebarRoot,
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
useSidebar,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuLabel,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
DialogClose,
Button,
Label,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@tensamin/ui"; } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@tensamin/ui";
@ -13,17 +34,171 @@ import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox"; import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/layout"; import { useShowMobileNavbar } from "@/routes/app/layout";
import { Ellipsis, Check } from "lucide-react";
import { useState } from "react";
import type { User } from "@tensamin/user/context";
import type z from "zod";
import { ttp } from "@tensamin/shared/data";
import { useTTP } from "@tensamin/ttp";
type OnlineStatus = z.infer<
typeof ttp.get_user_data.response.shape.online_status
>;
const onlineStatusLabels: Record<OnlineStatus, string> = {
user_online: "Online",
user_offline: "Offline",
user_dnd: "Do not disturb",
user_idle: "Idle",
user_wc: "Away",
user_borked: "Borked",
iota_offline: "Iota offline",
iota_online: "Iota online",
iota_borked: "Iota borked",
};
function StatusDialog({
user,
open,
onOpenChange,
send,
draftStatus,
setDraftStatus,
draftOnlineStatus,
setDraftOnlineStatus,
errorMessage,
setErrorMessage,
saveSucceeded,
setSaveSucceeded,
}: {
user: User;
open: boolean;
onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useTTP>["send"];
draftStatus: string;
setDraftStatus: (value: string) => void;
draftOnlineStatus: OnlineStatus;
setDraftOnlineStatus: (value: OnlineStatus) => void;
errorMessage: string;
setErrorMessage: (value: string) => void;
saveSucceeded: boolean;
setSaveSucceeded: (value: boolean) => void;
}) {
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.online_status);
setErrorMessage("");
setSaveSucceeded(false);
}
onOpenChange(nextOpen);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Update Status</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<Label htmlFor="user-status">Status message</Label>
<Input
id="user-status"
placeholder="Getting snacks..."
value={draftStatus}
onChange={(e) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftStatus(e.target.value);
}}
/>
<Label htmlFor="user-online-status">Online status</Label>
<Select
value={draftOnlineStatus}
onValueChange={(value) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftOnlineStatus(value ?? "user_online");
}}
>
<SelectTrigger className="w-full" id="user-online-status">
<SelectValue placeholder="Online">
{onlineStatusLabels[draftOnlineStatus]}
</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="user_online">Online</SelectItem>
<SelectItem value="user_offline">Offline</SelectItem>
<SelectItem value="user_idle">Idle</SelectItem>
<SelectItem value="user_dnd">Do not disturb</SelectItem>
</SelectContent>
</Select>
</div>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
<DialogFooter>
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button
onClick={async () => {
const payload = {
...(draftStatus && { status: draftStatus }),
online_status: draftOnlineStatus,
};
const validation =
ttp.change_user_data.request.safeParse(payload);
if (!validation.success) {
setSaveSucceeded(false);
setErrorMessage(
validation.error.issues[0]?.message ?? "Invalid status data",
);
return;
}
try {
await send("change_user_data", validation.data);
setSaveSucceeded(true);
setErrorMessage("");
} catch (err) {
setSaveSucceeded(false);
setErrorMessage("Failed to update status: " + err);
}
}}
>
{saveSucceeded ? (
<span className="inline-flex items-center gap-1.5">
<Check className="size-4" />
Saved
</span>
) : (
"Save"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Renders the conversation sidebar with account summary and conversation list.
* @returns Sidebar JSX.
*/
export default function Sidebar() { export default function Sidebar() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const showMobileNavbar = useShowMobileNavbar(); const showMobileNavbar = useShowMobileNavbar();
const { openMobile, setOpenMobile } = useSidebar();
const [draftStatus, setDraftStatus] = useState("");
const [draftOnlineStatus, setDraftOnlineStatus] =
useState<OnlineStatus>("user_online");
const [dialogOpen, setDialogOpen] = useState(false);
const [statusErrorMessage, setStatusErrorMessage] = useState("");
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
return ( const { send } = useTTP();
<SidebarRoot className="border-0!">
const content = (
<>
<SidebarContent <SidebarContent
className={ className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2" isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
@ -34,7 +209,67 @@ export default function Sidebar() {
<Wrapper <Wrapper
loading={<Loading />} loading={<Loading />}
userId={"own"} userId={"own"}
component={(user) => <Basic user={user} />} component={(user) => (
<>
<Basic
user={user}
extra={
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label="Open profile menu"
className="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Ellipsis />
</button>
}
/>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Profile</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.online_status);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
setDialogOpen(true);
}}
>
Set Status
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<StatusDialog
user={user}
open={dialogOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.online_status);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
}
setDialogOpen(nextOpen);
}}
send={send}
draftStatus={draftStatus}
setDraftStatus={setDraftStatus}
draftOnlineStatus={draftOnlineStatus}
setDraftOnlineStatus={setDraftOnlineStatus}
errorMessage={statusErrorMessage}
setErrorMessage={setStatusErrorMessage}
saveSucceeded={statusSaveSucceeded}
setSaveSucceeded={setStatusSaveSucceeded}
/>
</>
)}
/> />
</div> </div>
<div className="h-full"> <div className="h-full">
@ -52,6 +287,34 @@ export default function Sidebar() {
<SidebarBox /> <SidebarBox />
</SidebarFooter> </SidebarFooter>
)} )}
</SidebarRoot> </>
); );
if (isMobile) {
return (
<>
{openMobile && (
<div
className="fixed inset-0 z-40 bg-black/10"
onClick={() => setOpenMobile(false)}
/>
)}
<div
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0,
pointerEvents: openMobile ? "auto" : "none",
}}
>
<div className="flex h-full w-full flex-col">{content}</div>
</div>
</>
);
}
return <SidebarRoot className="border-0!">{content}</SidebarRoot>;
} }

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