Compare commits

..
Author SHA1 Message Date
37a9f97361 Merge pull request '(feat): add theming for testers' (#20) from dev into main
All checks were successful
/ build-web (push) Successful in 1m19s
/ build-desktop (linux) (push) Successful in 6m37s
/ build-mobile (push) Successful in 15m49s
/ release (push) Successful in 32s
Reviewed-on: #20
2026-06-16 11:50:47 +02:00
forgejo-actions
d545b91844 (qol): update release flake hash 2026-06-09 14:39:00 +00:00
b57bc0683c Merge pull request '(fix): typescript error' (#19) from dev into main
All checks were successful
/ build-web (push) Successful in 1m49s
/ build-desktop (linux) (push) Successful in 9m0s
/ build-mobile (push) Successful in 20m25s
/ release (push) Successful in 32s
Reviewed-on: #19
2026-06-09 16:18:04 +02:00
73ff854f0f Merge pull request '(feat): call improvements' (#18) from dev into main
Some checks failed
/ build-desktop (linux) (push) Failing after 1m29s
/ build-web (push) Failing after 1m42s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
Reviewed-on: #18
2026-06-09 16:15:01 +02:00
forgejo-actions
abc0cceb56 (qol): update release flake hash 2026-06-03 07:26:40 +00:00
67756255db Merge pull request '(fix): a few mobile related bugs' (#17) from dev into main
All checks were successful
/ build-web (push) Successful in 1m22s
/ build-desktop (linux) (push) Successful in 5m12s
/ build-mobile (push) Successful in 12m0s
/ release (push) Successful in 29s
Reviewed-on: #17
2026-06-03 09:14:14 +02:00
257ef320d8 Merge pull request '(feat): remove cleanup workflow' (#16) from dev into main
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
Reviewed-on: #16
2026-06-02 22:47:28 +02:00
4a53d5c6dc Merge pull request '(fix): cleanup workflow' (#15) from dev into main
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
Reviewed-on: #15
2026-06-02 22:33:05 +02:00
forgejo-actions
089e6c5b28 (qol): update release flake hash 2026-05-29 13:21:12 +00:00
00598348ab Merge pull request '(feat): some workflow updates' (#14) from dev into main
All checks were successful
/ build-web (push) Successful in 2m53s
/ build-desktop (linux) (push) Successful in 7m34s
/ build-mobile (push) Successful in 21m41s
/ release (push) Successful in 54s
Reviewed-on: #14
2026-05-29 14:58:44 +02:00
forgejo-actions
0c06516984 (qol): update release flake hash 2026-05-28 14:09:23 +00:00
8f4f356746 Merge pull request '(feat): now using the .deb instead of the .AppImage for nixos' (#13) from dev into main
All checks were successful
/ build-web (push) Successful in 3m38s
/ build-desktop (linux) (push) Successful in 9m5s
/ build-mobile (push) Successful in 26m16s
/ release (push) Successful in 55s
Reviewed-on: #13
2026-05-28 15:42:18 +02:00
316 changed files with 8267 additions and 25221 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -5,110 +5,74 @@ on:
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
build-web: build-web:
runs-on: nixos runs-on: docker
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#electron --command pnpm run build:packages run: bun run build:packages
- name: Build web - name: Build web
run: nix develop .#electron --command pnpm run build:web run: bun run build:web
- name: Install rsync
run: apt-get update && apt-get install -y rsync
- name: Deploy - name: Deploy
run: nix develop .#electron --command 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: build-mobile:
runs-on: nixos runs-on: docker
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#tauri --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#tauri --command pnpm run build:packages run: bun run build:packages
- name: Setup Android Keystore - name: Setup Android Keystore
env: env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: | run: |
nix profile add nixpkgs#gnused 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)"
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
echo "ANDROID_KEYSTORE_BASE64 secret is missing or empty"
exit 1
fi
if [ -z "$KEYSTORE_PROPERTIES" ]; then
echo "ANDROID_KEYSTORE_PROPERTIES secret is missing or empty"
exit 1
fi
printf '%s' "$KEYSTORE_BASE64" \
| tr -d '[:space:]' \
| base64 -d > keystore.jks
printf '%s' "$KEYSTORE_PROPERTIES" \
| sed 's/\\n/\n/g' \
| tr -d '\r' \
| sed 's|^[[:space:]]*storeFile[[:space:]]*=.*|storeFile=keystore.jks|' \
> keystore.properties
grep -q '^[[:space:]]*storeFile[[:space:]]*=' keystore.properties || printf '\nstoreFile=keystore.jks\n' >> keystore.properties
if [ ! -s keystore.jks ]; then
echo "Decoded keystore.jks is missing or empty"
exit 1
fi
if [ ! -s keystore.properties ]; then
echo "Generated keystore.properties is missing or empty"
exit 1
fi
if ! grep -q '^[[:space:]]*keyAlias[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing keyAlias"
exit 1
fi
if ! grep -Eq '^[[:space:]]*(keyPassword|password)[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing keyPassword or password"
exit 1
fi
if ! grep -Eq '^[[:space:]]*(storePassword|password)[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing storePassword or password"
exit 1
fi
- name: Build mobile - name: Build mobile
run: nix develop .#tauri --command pnpm run build:mobile run: bun run build:mobile
- name: Upload mobile artifact - name: Upload mobile artifact
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -117,30 +81,31 @@ jobs:
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
build-desktop: build-desktop:
runs-on: nixos runs-on: docker
strategy: strategy:
matrix: matrix:
target: [linux] target: [linux]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#electron --command pnpm run build:packages run: bun run build:packages
- name: Set Electron dev version - name: Set Electron dev version
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")" VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)" SHORT_SHA="$(git rev-parse --short HEAD)"
DEV_VERSION="$VERSION-dev-$SHORT_SHA" DEV_VERSION="$VERSION-dev-$SHORT_SHA"
@ -152,15 +117,9 @@ jobs:
pkg.version = process.env.DEV_VERSION; pkg.version = process.env.DEV_VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n"); fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
' '
EOF
- name: Build Electron desktop - name: Build Electron desktop
run: | run: bun run build:desktop
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
cd apps/electron
pnpm run package:raw
EOF
- name: Upload desktop artifacts - name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -169,19 +128,22 @@ jobs:
path: apps/electron/release/ path: apps/electron/release/
release: release:
runs-on: nixos runs-on: docker
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Download mobile artifact - name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3 uses: https://data.forgejo.org/actions/download-artifact@v3
@ -198,27 +160,21 @@ jobs:
- name: Read version and hash - name: Read version and hash
id: version id: version
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")" VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)" SHORT_SHA="$(git rev-parse --short HEAD)"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT" echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT" echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT" echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
EOF
- name: Copy releases - name: Copy releases
env: env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }} TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }} TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
ASSET_BASE_URL="${{ forgejo.api_url }}" ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}" ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" pnpm run copy-releases FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
EOF
- name: Create pre-release and upload files - name: Create pre-release and upload files
env: env:
@ -229,7 +185,6 @@ jobs:
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }} TITLE: ${{ steps.version.outputs.title }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -eu set -eu
test -d releases test -d releases
@ -318,4 +273,3 @@ jobs:
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$file" -F "attachment=@$file"
done done
EOF

View file

@ -6,110 +6,74 @@ on:
paths-ignore: paths-ignore:
- flake.nix - flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
build-web: build-web:
runs-on: nixos runs-on: docker
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#electron --command pnpm run build:packages run: bun run build:packages
- name: Build web - name: Build web
run: nix develop .#electron --command pnpm run build:web run: bun run build:web
- name: Install rsync
run: apt-get update && apt-get install -y rsync
- name: Deploy - name: Deploy
run: nix develop .#electron --command 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: build-mobile:
runs-on: nixos runs-on: docker
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#tauri --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#tauri --command pnpm run build:packages run: bun run build:packages
- name: Setup Android Keystore - name: Setup Android Keystore
env: env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }} KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: | run: |
nix profile add nixpkgs#gnused 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)"
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
echo "ANDROID_KEYSTORE_BASE64 secret is missing or empty"
exit 1
fi
if [ -z "$KEYSTORE_PROPERTIES" ]; then
echo "ANDROID_KEYSTORE_PROPERTIES secret is missing or empty"
exit 1
fi
printf '%s' "$KEYSTORE_BASE64" \
| tr -d '[:space:]' \
| base64 -d > keystore.jks
printf '%s' "$KEYSTORE_PROPERTIES" \
| sed 's/\\n/\n/g' \
| tr -d '\r' \
| sed 's|^[[:space:]]*storeFile[[:space:]]*=.*|storeFile=keystore.jks|' \
> keystore.properties
grep -q '^[[:space:]]*storeFile[[:space:]]*=' keystore.properties || printf '\nstoreFile=keystore.jks\n' >> keystore.properties
if [ ! -s keystore.jks ]; then
echo "Decoded keystore.jks is missing or empty"
exit 1
fi
if [ ! -s keystore.properties ]; then
echo "Generated keystore.properties is missing or empty"
exit 1
fi
if ! grep -q '^[[:space:]]*keyAlias[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing keyAlias"
exit 1
fi
if ! grep -Eq '^[[:space:]]*(keyPassword|password)[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing keyPassword or password"
exit 1
fi
if ! grep -Eq '^[[:space:]]*(storePassword|password)[[:space:]]*=' keystore.properties; then
echo "keystore.properties is missing storePassword or password"
exit 1
fi
- name: Build mobile - name: Build mobile
run: nix develop .#tauri --command pnpm run build:mobile run: bun run build:mobile
- name: Upload mobile artifact - name: Upload mobile artifact
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -118,30 +82,31 @@ jobs:
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
build-desktop: build-desktop:
runs-on: nixos runs-on: docker
strategy: strategy:
matrix: matrix:
target: [linux] target: [linux]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Copy licenses - name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses run: bun run copy-licenses
- name: Build packages - name: Build packages
run: nix develop .#electron --command pnpm run build:packages run: bun run build:packages
- name: Set Electron prod version - name: Set Electron prod version
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")" VERSION="$(node -p "require('./package.json').version")"
export VERSION export VERSION
node -e ' node -e '
@ -151,15 +116,9 @@ jobs:
pkg.version = process.env.VERSION; pkg.version = process.env.VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n"); fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
' '
EOF
- name: Build Electron desktop - name: Build Electron desktop
run: | run: bun run build:desktop
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
cd apps/electron
pnpm run package:raw
EOF
- name: Upload desktop artifacts - name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3 uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -168,17 +127,20 @@ jobs:
path: apps/electron/release/ path: apps/electron/release/
release: release:
runs-on: nixos runs-on: docker
needs: [build-web, build-mobile, build-desktop] needs: [build-web, build-mobile, build-desktop]
steps: steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: 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 - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: bun install --frozen-lockfile
- name: Download mobile artifact - name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3 uses: https://data.forgejo.org/actions/download-artifact@v3
@ -195,24 +157,18 @@ jobs:
- name: Read version - name: Read version
id: version id: version
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
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=$VERSION" >> "$FORGEJO_OUTPUT" echo "tag=$VERSION" >> "$FORGEJO_OUTPUT"
EOF
- name: Copy releases - name: Copy releases
env: env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }} TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }} TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
ASSET_BASE_URL="${{ forgejo.api_url }}" ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}" ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" pnpm run copy-releases FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
EOF
- name: Create release and upload files - name: Create release and upload files
env: env:
@ -222,7 +178,6 @@ jobs:
SHA: ${{ forgejo.sha }} SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -eu set -eu
test -d releases test -d releases
@ -276,18 +231,18 @@ jobs:
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$file" -F "attachment=@$file"
done done
EOF
- name: Delete dev releases - name: Delete dev releases for prod version
env: env:
TOKEN: ${{ forgejo.token }} TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }} API: ${{ forgejo.api_url }}
REPO: ${{ forgejo.repository }} REPO: ${{ forgejo.repository }}
TAG: ${{ steps.version.outputs.tag }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -eu set -eu
PAGE=1 PAGE=1
PREFIX="$TAG-dev-"
DELETE_RELEASES=delete-dev-releases.tsv DELETE_RELEASES=delete-dev-releases.tsv
: > "$DELETE_RELEASES" : > "$DELETE_RELEASES"
@ -302,7 +257,8 @@ jobs:
test "$COUNT" -gt 0 || break test "$COUNT" -gt 0 || break
jq -r \ jq -r \
'.[] | select(.prerelease == true) | select(.tag_name | contains("-dev-")) | [.id, .tag_name] | @tsv' releases.json \ --arg prefix "$PREFIX" \
'.[] | select(.prerelease == true) | select(.tag_name | startswith($prefix)) | [.id, .tag_name] | @tsv' releases.json \
>> "$DELETE_RELEASES" >> "$DELETE_RELEASES"
PAGE="$((PAGE + 1))" PAGE="$((PAGE + 1))"
@ -315,13 +271,11 @@ jobs:
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/$release_id" "$API/repos/$REPO/releases/$release_id"
done < "$DELETE_RELEASES" done < "$DELETE_RELEASES"
EOF
- name: Update root flake release hash - name: Update root flake release hash
env: env:
TAG: ${{ steps.version.outputs.tag }} TAG: ${{ steps.version.outputs.tag }}
run: | run: |
nix develop .#electron --command bash <<'EOF'
set -eu set -eu
DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)" DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)"
@ -370,4 +324,3 @@ jobs:
git add flake.nix git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash" git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push origin HEAD:dev git push origin HEAD:dev
EOF

3
.gitignore vendored
View file

@ -1,4 +1,3 @@
node_modules node_modules
releases releases
.fallow .fallow/
.direnv

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: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

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

@ -5,28 +5,27 @@
"description": "Tensamin desktop client", "description": "Tensamin desktop client",
"author": "methanium", "author": "methanium",
"homepage": "https://git.methanium.net/tensamin/client", "homepage": "https://git.methanium.net/tensamin/client",
"desktopName": "Tensamin",
"type": "module", "type": "module",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {
"clean": "rm -rf dist release", "clean": "rm -rf dist release",
"lint": "eslint src", "lint": "eslint src",
"build:web": "cd ../.. && pnpm run build:web", "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", "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 ../.. && pnpm run build:web && cd apps/electron && pnpm run build && electron . --verbose", "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 ../..#electron --command pnpm run dev:raw; else pnpm run dev:raw; fi", "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": "pnpm run build && electron .", "start:raw": "bun run build && electron .",
"start": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#electron --command pnpm run start:raw; else pnpm run start:raw; fi", "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 ../.. && pnpm run build:web && cd apps/electron && pnpm run build && electron-builder --publish never", "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 ../..#electron --command pnpm run package:raw; else pnpm run package:raw; fi", "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 ../.. && pnpm run build:web && cd apps/electron && pnpm run build && electron-builder --linux --publish never", "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 ../..#electron --command pnpm run package:linux:raw; else pnpm run package:linux:raw; fi", "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 ../.. && pnpm run build:web && cd apps/electron && pnpm run build && electron-builder --win --publish never", "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 ../..#electron --command pnpm run package:windows:raw; else pnpm run package:windows:raw; fi", "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": "node scripts/generate-release-metadata.ts", "checksum": "bun scripts/generate-release-metadata.ts",
"generate-signing-key": "node scripts/generate-signing-key.ts", "generate-signing-key": "bun scripts/generate-signing-key.ts",
"validate:raw": "pnpm run build && pnpm run package:linux:raw", "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 ../..#electron --command pnpm run validate:raw; else pnpm run validate:raw; fi" "validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run validate:raw; else bun run validate:raw; fi"
}, },
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
@ -45,9 +44,6 @@
"directories": { "directories": {
"output": "release" "output": "release"
}, },
"toolsets": {
"appimage": "1.0.3"
},
"files": [ "files": [
"dist/**/*", "dist/**/*",
"package.json" "package.json"
@ -58,12 +54,8 @@
"to": "web" "to": "web"
}, },
{ {
"from": "build/icons", "from": "build/icons/icon.png",
"to": "icons", "to": "icons/icon.png"
"filter": [
"32x32.png",
"icon.png"
]
} }
], ],
"linux": { "linux": {
@ -76,7 +68,6 @@
"executableName": "tensamin", "executableName": "tensamin",
"category": "Network", "category": "Network",
"maintainer": "Methanium", "maintainer": "Methanium",
"syncDesktopName": true,
"desktop": { "desktop": {
"entry": { "entry": {
"Name": "Tensamin", "Name": "Tensamin",

View file

@ -12,18 +12,9 @@ import {
import { checkForUpdates } from "./updates.js"; import { checkForUpdates } from "./updates.js";
import { import {
ipcChannels, ipcChannels,
type DesktopCallStatus,
type DesktopScreenShareAudioOutput, type DesktopScreenShareAudioOutput,
type DesktopScreenShareCapabilities, type DesktopScreenShareCapabilities,
} from "../shared/ipc.js"; } from "../shared/ipc.js";
import { initTray, setTrayCallStatus } from "./tray.js";
import {
clearSecureStorage,
deleteSecureStorage,
getSecureStorageStatus,
loadSecureStorage,
saveSecureStorage,
} from "./secureStorage.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const verbose = process.argv.includes("--verbose"); const verbose = process.argv.includes("--verbose");
@ -197,37 +188,6 @@ function registerIpc() {
); );
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion()); ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates); ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
ipcMain.handle(ipcChannels.getSecureStorageStatus, getSecureStorageStatus);
ipcMain.handle(ipcChannels.loadSecureStorage, (_event, key: unknown) =>
loadSecureStorage(key),
);
ipcMain.handle(
ipcChannels.saveSecureStorage,
(_event, key: unknown, value: unknown) => saveSecureStorage(key, value),
);
ipcMain.handle(ipcChannels.deleteSecureStorage, (_event, key: unknown) =>
deleteSecureStorage(key),
);
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
if (
typeof status !== "object" ||
status === null ||
typeof (status as DesktopCallStatus).inCall !== "boolean" ||
typeof (status as DesktopCallStatus).speaking !== "boolean" ||
((status as DesktopCallStatus).iconDataUrl !== undefined &&
(typeof (status as DesktopCallStatus).iconDataUrl !== "string" ||
!(status as DesktopCallStatus).iconDataUrl?.startsWith(
"data:image/png;base64,",
) ||
(status as DesktopCallStatus).iconDataUrl!.length > 16_384))
) {
throw new Error("Invalid call status.");
}
const { inCall, iconDataUrl } = status as DesktopCallStatus;
setTrayCallStatus(inCall, iconDataUrl);
});
ipcMain.handle(ipcChannels.minimizeWindow, () => { ipcMain.handle(ipcChannels.minimizeWindow, () => {
verboseLog("window:minimize"); verboseLog("window:minimize");
mainWindow?.minimize(); mainWindow?.minimize();
@ -350,7 +310,6 @@ async function start() {
verboseLog("app ready"); verboseLog("app ready");
registerIpc(); registerIpc();
registerDisplayMediaHandler(); registerDisplayMediaHandler();
initTray(() => mainWindow);
await createWindow(); await createWindow();
} }

View file

@ -1,130 +0,0 @@
import { app, safeStorage } from "electron";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import {
secureStorageLimits,
type DesktopSecureStorageStatus,
} from "../shared/ipc.js";
type StoredValues = Record<string, string>;
let pendingWrite = Promise.resolve();
function storagePath() {
return join(app.getPath("userData"), "secure-storage.json");
}
function validateKey(key: unknown): asserts key is string {
if (
typeof key !== "string" ||
key.length === 0 ||
Buffer.byteLength(key, "utf8") > secureStorageLimits.maxKeyBytes
) {
throw new Error("Invalid secure storage key.");
}
}
function validateValue(value: unknown): asserts value is string {
if (
typeof value !== "string" ||
Buffer.byteLength(value, "utf8") > secureStorageLimits.maxValueBytes
) {
throw new Error("Invalid secure storage value.");
}
}
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
if (!safeStorage.isEncryptionAvailable()) {
return { available: false, backend: null };
}
const backend =
process.platform === "linux"
? safeStorage.getSelectedStorageBackend()
: process.platform === "darwin"
? "keychain"
: process.platform === "win32"
? "dpapi"
: null;
return {
available: process.platform !== "linux" || backend !== "basic_text",
backend,
};
}
function requireAvailable() {
if (!getSecureStorageStatus().available) {
throw new Error("Secure storage is unavailable.");
}
}
async function readValues(): Promise<StoredValues> {
try {
const parsed: unknown = JSON.parse(await readFile(storagePath(), "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Invalid secure storage data.");
}
const values = parsed as Record<string, unknown>;
if (Object.values(values).some((value) => typeof value !== "string")) {
throw new Error("Invalid secure storage data.");
}
return values as StoredValues;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
}
async function writeValues(values: StoredValues) {
const path = storagePath();
const temporaryPath = `${path}.tmp`;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(temporaryPath, JSON.stringify(values), { mode: 0o600 });
await rename(temporaryPath, path);
}
function mutateValues(mutation: (values: StoredValues) => void) {
const operation = pendingWrite.then(async () => {
const values = await readValues();
mutation(values);
await writeValues(values);
});
pendingWrite = operation.catch(() => undefined);
return operation;
}
export async function loadSecureStorage(key: unknown): Promise<string | null> {
requireAvailable();
validateKey(key);
await pendingWrite;
const encrypted = (await readValues())[key];
if (encrypted === undefined) return null;
return safeStorage.decryptString(Buffer.from(encrypted, "base64"));
}
export function saveSecureStorage(key: unknown, value: unknown) {
requireAvailable();
validateKey(key);
validateValue(value);
const encrypted = safeStorage.encryptString(value).toString("base64");
return mutateValues((values) => {
values[key] = encrypted;
});
}
export function deleteSecureStorage(key: unknown) {
requireAvailable();
validateKey(key);
return mutateValues((values) => {
delete values[key];
});
}
export function clearSecureStorage() {
requireAvailable();
return mutateValues((values) => {
for (const key of Object.keys(values)) delete values[key];
});
}

View file

@ -1,62 +0,0 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron";
import path from "node:path";
import { fileURLToPath } from "node:url";
let tray: Tray | null = null;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getTrayIconPath(filename: string) {
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
return path.resolve(__dirname, "../../build/icons", filename);
}
export function setTrayCallStatus(
inCall: boolean,
iconDataUrl?: string,
) {
if (!tray) return;
if (inCall && iconDataUrl) {
const image = nativeImage.createFromDataURL(iconDataUrl);
if (!image.isEmpty()) {
tray.setImage(image);
return;
}
}
tray.setImage(getTrayIconPath("32x32.png"));
}
export function initTray(getMainWindow: () => BrowserWindow | null) {
tray = new Tray(getTrayIconPath("32x32.png")); // keep reference alive
const contextMenu = Menu.buildFromTemplate([
{
label: "Restart",
type: "normal",
click: () => {
app.relaunch();
app.quit();
},
},
{ label: "Quit", type: "normal", click: () => app.quit() },
]);
tray.setContextMenu(contextMenu);
tray.on("click", () => {
const mainWindow = getMainWindow();
if (!mainWindow) return;
if (mainWindow.isVisible()) {
mainWindow.hide();
return;
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
}

View file

@ -1,23 +1,10 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
ipcChannels,
type DesktopCallStatus,
type DesktopScreenShareSource,
secureStorageLimits,
} from "../shared/ipc.js";
function windowAction(channel: string) { function windowAction(channel: string) {
return () => ipcRenderer.invoke(channel); return () => ipcRenderer.invoke(channel);
} }
function validKey(key: string) {
return (
typeof key === "string" &&
key.length > 0 &&
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
);
}
const desktopApi = { const desktopApi = {
media: { media: {
listScreenShareSources: () => listScreenShareSources: () =>
@ -40,39 +27,6 @@ const desktopApi = {
updates: { updates: {
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
}, },
call: {
setStatus: (status: DesktopCallStatus) => {
if (
typeof status?.inCall !== "boolean" ||
typeof status?.speaking !== "boolean" ||
(status.iconDataUrl !== undefined &&
(typeof status.iconDataUrl !== "string" ||
!status.iconDataUrl.startsWith("data:image/png;base64,")))
) {
return Promise.reject(new Error("Invalid call status."));
}
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
},
},
secureStorage: {
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
load: (key: string) =>
validKey(key)
? ipcRenderer.invoke(ipcChannels.loadSecureStorage, key)
: Promise.reject(new Error("Invalid secure storage key.")),
save: (key: string, value: string) =>
validKey(key) &&
typeof value === "string" &&
Buffer.byteLength(value, "utf8") <= secureStorageLimits.maxValueBytes
? ipcRenderer.invoke(ipcChannels.saveSecureStorage, key, value)
: Promise.reject(new Error("Invalid secure storage key or value.")),
delete: (key: string) =>
validKey(key)
? ipcRenderer.invoke(ipcChannels.deleteSecureStorage, key)
: Promise.reject(new Error("Invalid secure storage key.")),
clear: () => ipcRenderer.invoke(ipcChannels.clearSecureStorage),
},
window: { window: {
minimize: () => windowAction(ipcChannels.minimizeWindow), minimize: () => windowAction(ipcChannels.minimizeWindow),
maximize: () => windowAction(ipcChannels.maximizeWindow), maximize: () => windowAction(ipcChannels.maximizeWindow),

View file

@ -20,22 +20,6 @@ export type DesktopScreenShareCapabilities = {
hasReliableSystemAudio: boolean; hasReliableSystemAudio: boolean;
}; };
export type DesktopCallStatus = {
inCall: boolean;
speaking: boolean;
iconDataUrl?: string;
};
export type DesktopSecureStorageStatus = {
available: boolean;
backend: string | null;
};
export const secureStorageLimits = {
maxKeyBytes: 256,
maxValueBytes: 1024 * 1024,
} as const;
export type ReleaseArtifact = { export type ReleaseArtifact = {
name: string; name: string;
platform: string; platform: string;
@ -71,10 +55,4 @@ export const ipcChannels = {
closeWindow: "window:close", closeWindow: "window:close",
getVersion: "app:getVersion", getVersion: "app:getVersion",
checkForUpdates: "updates:checkForUpdates", checkForUpdates: "updates:checkForUpdates",
setCallStatus: "call:setStatus",
getSecureStorageStatus: "secureStorage:getStatus",
loadSecureStorage: "secureStorage:load",
saveSecureStorage: "secureStorage:save",
deleteSecureStorage: "secureStorage:delete",
clearSecureStorage: "secureStorage:clear",
} as const; } as const;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Before After
Before After

96
apps/tauri/flake.lock generated Normal file
View file

@ -0,0 +1,96 @@
{
"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": 1775036866,
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1775272153,
"narHash": "sha256-FwYb64ysv8J2TxaqsYYcDyHAHBUEaQlriPMWPMi1K7M=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "740fb0203b2852917b909a72b948d34d0b171ec0",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"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
}

108
apps/tauri/flake.nix Normal file
View file

@ -0,0 +1,108 @@
{
description = "Tauri mobile development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
rust-overlay,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system: let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {
inherit system overlays;
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
projectRoot = ".";
androidHome = "${projectRoot}/.android";
sdkRoot = "${androidHome}/sdk";
ndkVersion = "29.0.14206865";
android = pkgs.androidenv.composeAndroidPackages {
cmdLineToolsVersion = "8.0";
toolsVersion = "26.1.1";
platformToolsVersion = "35.0.2";
buildToolsVersions = ["35.0.0"];
platformVersions = ["35" "36"];
includeSources = false;
includeSystemImages = false;
includeNDK = true;
ndkVersions = [ndkVersion];
useGoogleAPIs = false;
};
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "rust-analyzer"];
targets = [
"aarch64-linux-android"
"armv7-linux-androideabi"
"i686-linux-android"
"x86_64-linux-android"
"wasm32-unknown-unknown"
];
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs;
[
jdk17
rustToolchain
gradle
nodejs
pkg-config
]
++ [
android.androidsdk
pkgs.android-studio-tools
];
shellHook = ''
sdkSource="${android.androidsdk}/libexec/android-sdk"
mkdir -p "${androidHome}"
if [ -L "${sdkRoot}" ]; then
rm -f "${sdkRoot}"
fi
mkdir -p "${sdkRoot}"
ln -sfn "$sdkSource/build-tools" "${sdkRoot}/build-tools"
ln -sfn "$sdkSource/cmake" "${sdkRoot}/cmake"
ln -sfn "$sdkSource/licenses" "${sdkRoot}/licenses"
ln -sfn "$sdkSource/ndk" "${sdkRoot}/ndk"
ln -sfn "$sdkSource/ndk-bundle" "${sdkRoot}/ndk-bundle"
ln -sfn "$sdkSource/platforms" "${sdkRoot}/platforms"
ln -sfn "$sdkSource/platform-tools" "${sdkRoot}/platform-tools"
ln -sfn "$sdkSource/tools" "${sdkRoot}/tools"
mkdir -p "${sdkRoot}/cmdline-tools"
ln -sfn "$sdkSource/cmdline-tools/8.0" "${sdkRoot}/cmdline-tools/8.0"
ln -sfn "8.0" "${sdkRoot}/cmdline-tools/latest"
sdkRootAbs="$(realpath "${sdkRoot}")"
ndkRootAbs="''${sdkRootAbs}/ndk/${ndkVersion}"
export PATH="''${sdkRootAbs}/cmdline-tools/latest/bin:''${sdkRootAbs}/platform-tools:''${ndkRootAbs}:${pkgs.android-studio-tools}/bin:$PATH"
export ANDROID_HOME="''${sdkRootAbs}"
export ANDROID_SDK_ROOT="''${sdkRootAbs}"
export ANDROID_NDK_ROOT="''${ndkRootAbs}"
export ANDROID_NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_HOME="$ANDROID_NDK_ROOT"
export NDK_PATH="$ANDROID_NDK_ROOT"
export JAVA_HOME="${pkgs.jdk17}"
'';
};
}
);
}

View file

@ -2,14 +2,14 @@
<!-- Created with Inkscape (http://www.inkscape.org/) --> <!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="90.476906mm" width="90mm"
height="90.476906mm" height="90mm"
viewBox="0 0 90.476906 90.476906" viewBox="0 0 90 90"
version="1.1" version="1.1"
id="svg1" id="svg1"
xml:space="preserve" xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_square_outline_gen.svg" sodipodi:docname="logo_raw.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xlink="http://www.w3.org/1999/xlink"
@ -24,32 +24,16 @@
inkscape:pagecheckerboard="1" inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050" inkscape:deskcolor="#505050"
inkscape:document-units="mm" inkscape:document-units="mm"
inkscape:zoom="2" inkscape:zoom="0.99999999"
inkscape:cx="191.5" inkscape:cx="450"
inkscape:cy="117.25" inkscape:cy="291.5"
inkscape:window-width="2500" inkscape:window-width="1223"
inkscape:window-height="1403" inkscape:window-height="1369"
inkscape:window-x="0" inkscape:window-x="26"
inkscape:window-y="0" inkscape:window-y="23"
inkscape:window-maximized="1" inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:path-effect id="defs1"><linearGradient
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1 @ F,0,0,1,0,0.26458333,0,1"
radius="1"
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" /><linearGradient
id="swatch15" id="swatch15"
inkscape:swatch="solid"><stop inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;" style="stop-color:#000000;stop-opacity:1;"
@ -67,7 +51,7 @@
id="stop3" /></linearGradient><linearGradient id="stop3" /></linearGradient><linearGradient
id="swatch2" id="swatch2"
inkscape:swatch="solid"><stop inkscape:swatch="solid"><stop
style="stop-color:#b8f8ff;stop-opacity:1;" style="stop-color:#000000;stop-opacity:1;"
offset="0" offset="0"
id="stop2" /></linearGradient><linearGradient id="stop2" /></linearGradient><linearGradient
id="swatch1" id="swatch1"
@ -200,23 +184,13 @@
x1="63.191292" x1="63.191292"
y1="148.5" y1="148.5"
x2="146.82355" x2="146.82355"
y2="148.5" /><linearGradient y2="148.5" /></defs><g
inkscape:collect="always"
xlink:href="#swatch2"
id="linearGradient1"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientTransform="matrix(1.0144629,0,0,1.0151924,217.46226,13.830072)" /></defs><g
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1" id="layer1"
transform="translate(-281.06837,-119.33314)" transform="translate(-62.941767,-104.13688)"><g
><g
id="g2" id="g2"
transform="matrix(0.77770172,0,0,0.82714179,104.17421,-33.291714)" transform="matrix(0.78671048,0,0,0.83608014,-116.36038,-50.640745)"
inkscape:label="background" inkscape:label="background"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none" style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path clip-path="url(#clipPath1)"><path
@ -239,19 +213,11 @@
id="rect1-1-3-6-2" id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none" style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)" transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><path d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
id="path1-5"
style="fill:none;stroke:url(#linearGradient1);stroke-width:2;stroke-dasharray:none"
inkscape:label="glow_outline"
d="m 323.73413,119.91784 -40.80246,15.9513 c -0.13609,0.0532 -0.26542,0.21326 -0.28843,0.35757 -2.90631,18.22859 -0.19736,34.27444 8.28042,47.36302 0.0794,0.12264 0.10916,0.33484 0.0657,0.47436 l -7.08112,22.75509 a 0.11130973,0.11130973 40.101955 0 0 0.15695,0.13218 l 18.7652,-9.59506 c 0.13011,-0.0665 0.32608,-0.044 0.43816,0.0497 5.75202,4.81168 12.45464,8.67546 20.46583,11.84762 0.13586,0.0538 0.3564,0.0538 0.49225,-3e-5 32.16862,-12.74646 46.41741,-39.59704 41.09168,-73.02692 -0.023,-0.14431 -0.15232,-0.30436 -0.28841,-0.35757 l -40.80297,-15.9513 a 0.67679193,0.67679193 179.99988 0 0 -0.49284,0 z"
sodipodi:nodetypes="cccccccc"
inkscape:original-d="m 323.98055,119.8215 -41.2953,16.14398 c -2.99079,18.43973 -0.24934,34.65473 8.38277,47.84598 l -7.23836,23.26035 19.23635,-9.83596 c 5.85568,4.9403 12.69983,8.88741 20.91454,12.11489 32.42602,-12.73985 46.75142,-39.74883 41.29581,-73.38526 z"
inkscape:path-effect="#path-effect1"
transform="matrix(0.98854883,0,0,0.98930922,6.03231,1.7325084)" /><g
id="g3" id="g3"
inkscape:label="foreground" inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none" style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
transform="matrix(0.98854883,0,0,0.98930922,222.52896,17.64618)"><g transform="translate(3.3653788,0.84760028)"><g
id="g1" id="g1"
inkscape:label="outline" inkscape:label="outline"
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

@ -21,11 +21,11 @@
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}", "dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
"start-adb:mobile:raw": "adb devices", "start-adb:mobile:raw": "adb devices",
"build:mobile:raw": "tauri android build", "build:mobile:raw": "tauri android build",
"dev:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run dev:mobile:raw; else pnpm run dev:mobile:raw; fi", "dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi",
"start-adb:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run start-adb:mobile:raw; else pnpm run start-adb:mobile:raw; fi", "start-adb:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun start-adb:mobile:raw; else bun start-adb:mobile:raw; fi",
"build:mobile": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run build:mobile:raw; else pnpm run build:mobile:raw; fi", "build:mobile": "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",
"gen-icons": "tauri icon ./logo.json && node scripts/sync-electron-icons.ts", "gen-icons": "tauri icon ./logo.json && bun scripts/sync-electron-icons.ts",
"format": "pnpm exec prettier --write .", "format": "bunx prettier --write .",
"lint": "eslint src" "lint": "eslint src"
}, },
"dependencies": { "dependencies": {

View file

@ -1,12 +1,9 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url";
// Config // Config
const PLACEHOLDER_VERSION = "0.0.0"; const PLACEHOLDER_VERSION = "0.0.0";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootPackageJsonPath = path.resolve(__dirname, "../../package.json"); const rootPackageJsonPath = path.resolve(__dirname, "../../package.json");
const cargoTomlPath = path.resolve(__dirname, "./src-tauri/Cargo.toml"); const cargoTomlPath = path.resolve(__dirname, "./src-tauri/Cargo.toml");
const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json"); const tauriConfigPath = path.resolve(__dirname, "./src-tauri/tauri.conf.json");

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tauri-plugin-deep-link = "2" tauri-plugin-deep-link = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
ttp-core = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-core" }
ttp-tauri = { git = "https://git.methanium.net/tensamin/ttp.git", package = "ttp-tauri" }
tauri-plugin-log = "2" tauri-plugin-log = "2"
[target.'cfg(target_os = "android")'.dependencies.tauri] [target.'cfg(target_os = "android")'.dependencies.tauri]

View file

@ -9,9 +9,17 @@
"core:default", "core:default",
"opener:default", "opener:default",
"core:window:default", "core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-toggle-maximize",
"core:window:allow-minimize",
"core:event:default", "core:event:default",
"deep-link:default", "deep-link:default",
"notification:default", "notification:default",
"log:default" "log:default",
"ttp-tauri:allow-connect",
"ttp-tauri:allow-send",
"ttp-tauri:allow-close",
"ttp-tauri:allow-ready-state"
] ]
} }

View file

@ -14,6 +14,10 @@
"barcode-scanner:allow-scan", "barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel", "barcode-scanner:allow-cancel",
"notification:default", "notification:default",
"log:default" "log:default",
"ttp-tauri:allow-connect",
"ttp-tauri:allow-send",
"ttp-tauri:allow-close",
"ttp-tauri:allow-ready-state"
] ]
} }

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.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 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: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

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: 22 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 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: 15 KiB

After

Width:  |  Height:  |  Size: 14 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: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 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.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 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: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 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

@ -2,7 +2,8 @@
pub fn run() { pub fn run() {
let builder = tauri::Builder::default() let builder = tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build()) .plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
.plugin(tauri_plugin_notification::init()); .plugin(tauri_plugin_notification::init())
.plugin(ttp_tauri::init());
let builder = builder let builder = builder
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())

View file

@ -5,9 +5,9 @@
"mainBinaryName": "tensamin", "mainBinaryName": "tensamin",
"identifier": "net.tensamin.client", "identifier": "net.tensamin.client",
"build": { "build": {
"beforeDevCommand": "cd ../web && pnpm run dev && cd ../tauri", "beforeDevCommand": "cd ../web && bun run dev && cd ../tauri",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeBuildCommand": "cd ../.. && pnpm run build:web && cd apps/tauri", "beforeBuildCommand": "cd ../.. && bun run build:web && cd apps/tauri",
"frontendDist": "../../web/dist" "frontendDist": "../../web/dist"
}, },
"app": { "app": {

2
apps/web/.gitignore vendored
View file

@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.android

View file

@ -4,128 +4,38 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "bunx prettier --write .",
"lint": "eslint src", "lint": "eslint src",
"dev": "vite --port 3000 --host 0.0.0.0", "dev": "vite --port 3000 --host 0.0.0.0",
"test": "vitest run --passWithNoTests", "test": "bun test --pass-with-no-tests",
"build": "pnpm run test && tsc -b && vite build", "build": "bun run test && tsc -b && vite build",
"preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .." "preview": "cd dist && nix-shell -p python3 --run 'python3 -m http.server 3000' && cd .."
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.0.0",
"@base-ui/utils": "0.3.1",
"@babel/runtime": "^7.29.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7", "@fontsource-variable/public-sans": "^5.2.7",
"@floating-ui/core": "^1.7.0",
"@floating-ui/dom": "^1.7.0",
"@floating-ui/react-dom": "^2.1.8",
"@floating-ui/utils": "^0.2.11",
"@radix-ui/primitive": "^1.1.0",
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-context": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dismissable-layer": "^1.1.11",
"@radix-ui/react-focus-guards": "^1.1.4",
"@radix-ui/react-focus-scope": "^1.1.11",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-portal": "^1.1.13",
"@radix-ui/react-presence": "^1.1.6",
"@radix-ui/react-primitive": "^2.0.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-use-callback-ref": "^1.1.1",
"@radix-ui/react-use-controllable-state": "^1.2.3",
"@radix-ui/react-use-effect-event": "^0.0.2",
"@radix-ui/react-use-layout-effect": "^1.1.0",
"@reduxjs/toolkit": "^2.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@tanstack/devtools-event-client": "^0.3.0",
"@tanstack/query-core": "^5.0.0",
"@tanstack/react-router": "^1.169.1", "@tanstack/react-router": "^1.169.1",
"@tanstack/history": "1.162.0",
"@tanstack/react-store": "^0.9.3",
"@tanstack/router-core": "^1.169.1",
"@tanstack/store": "^0.9.3",
"@tanstack/virtual-core": "^3.13.24",
"@tanstack/react-virtual": "^3.13.24", "@tanstack/react-virtual": "^3.13.24",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tensamin/call": "workspace:*", "@tensamin/call": "workspace:*",
"@tensamin/cache": "workspace:*",
"@tensamin/chat": "workspace:*", "@tensamin/chat": "workspace:*",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/settings": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/tauri": "workspace:*", "@tensamin/tauri": "workspace:*",
"@tensamin/mtp": "workspace:*", "@tensamin/ttp": "workspace:*",
"@tensamin/tauth": "workspace:*", "@tensamin/tauth": "workspace:*",
"@tensamin/markdown": "workspace:*", "@tensamin/markdown": "workspace:*",
"@tensamin/ui": "*", "@tensamin/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"@tensamin/notifications": "workspace:*", "@tensamin/notifications": "workspace:*",
"aria-hidden": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie-es": "^3.0.0",
"d3-array": "^3.1.6",
"d3-color": "^3.1.0",
"d3-ease": "^3.0.1",
"d3-format": "^3.1.0",
"d3-interpolate": "^3.0.1",
"d3-path": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-time-format": "^4.1.0",
"d3-timer": "^3.0.1",
"date-fns": "^4.4.0",
"decimal.js-light": "^2.5.1",
"detect-node-es": "^1.1.0",
"dijkstrajs": "^1.0.1",
"embla-carousel": "8.6.0",
"embla-carousel-react": "^8.6.0",
"embla-carousel-reactive-utils": "8.6.0",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"get-nonce": "^1.0.1",
"immer": "^10.1.1",
"input-otp": "^1.4.2",
"internmap": "^2.0.3",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"pngjs": "^5.0.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-is": "^19.0.0",
"react-redux": "^9.0.0",
"react-remove-scroll": "^2.7.2",
"react-remove-scroll-bar": "^2.3.7",
"react-resizable-panels": "^4.11.2",
"react-style-singleton": "^2.2.3",
"recharts": "3.8.1",
"redux": "^5.0.0",
"redux-thunk": "^3.1.0",
"reselect": "5.1.1",
"scheduler": "^0.27.0",
"seroval": "^1.5.4",
"seroval-plugins": "^1.5.4",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"tailwind-merge": "^3.6.0",
"tailwind-scrollbar-hide": "^4.0.0", "tailwind-scrollbar-hide": "^4.0.0",
"tiny-invariant": "^1.3.3",
"tslib": "^2.8.1",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3",
"use-sync-external-store": "^1.2.2",
"vaul": "^1.1.2",
"victory-vendor": "^37.0.2",
"yargs": "^15.3.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {

View file

@ -19,13 +19,13 @@ export function Basic({
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
return ( return (
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!"> <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">
<div className="relative shrink-0 overflow-visible"> <div className="relative shrink-0 overflow-visible">
<Avatar> <Avatar>
<AvatarImage src={user.Avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback> <AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()} {user.display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<Tooltip> <Tooltip>
@ -34,7 +34,7 @@ export function Basic({
<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 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 <div
style={{ style={{
backgroundColor: getStatusColor(user.OnlineStatus), backgroundColor: getStatusColor(user.online_status),
}} }}
className="h-2.25 w-2.25 rounded-full" className="h-2.25 w-2.25 rounded-full"
/> />
@ -42,14 +42,15 @@ export function Basic({
} }
/> />
<TooltipContent> <TooltipContent>
{user.OnlineStatus.split("_") {user.online_status
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")} .join(" ")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]"> <div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
<p>{user.Display}</p> <p>{user.display}</p>
</div> </div>
<div className="pr-1">{extra}</div> <div className="pr-1">{extra}</div>
</CardHeader> </CardHeader>
@ -58,5 +59,5 @@ export function Basic({
} }
export function Loading() { export function Loading() {
return <Skeleton className="h-12.5! rounded-2xl" />; return <Skeleton className="h-12.5 rounded-2xl" />;
} }

View file

@ -1,27 +1,77 @@
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui"; import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@tensamin/ui";
import { toLossySixDigitCode } from "@tensamin/shared/code";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp, Info } from "lucide-react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useCrypto } from "@tensamin/crypto/context";
import { useStorage } from "@tensamin/storage/context";
import { useUser } from "@tensamin/user/context";
export default function Profile({ user }: { user: User }) { export default function Profile({ user }: { user: User }) {
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false); const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
const [sharedSecret, setSharedSecret] = useState("");
const { getSharedSecret } = useCrypto();
const { load } = useStorage();
const { get } = useUser();
useEffect(() => {
let active = true;
void (async () => {
try {
const ownId = await load("user_id");
const privateKey = await load("private_key");
const ownData = await get(ownId);
const secret = await getSharedSecret(
privateKey,
ownData.public_key,
user.public_key,
);
if (active) {
setSharedSecret(secret);
}
} catch {
if (active) {
setSharedSecret("");
}
}
})();
return () => {
active = false;
};
}, [get, getSharedSecret, load, user.public_key]);
const sharedSecretCode = sharedSecret
? toLossySixDigitCode(sharedSecret)
: "------";
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Avatar className="size-10"> <Avatar className="size-10">
<AvatarImage src={user.Avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback className="text-lg"> <AvatarFallback className="text-lg">
{user.Display.slice(0, 2).toUpperCase()} {user.display.slice(0, 2).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-lg font-semibold">{user.Display}</p> <p className="text-lg font-semibold">{user.display}</p>
<p className="text-muted-foreground">{user.Username}</p> <p className="text-muted-foreground">{user.username}</p>
</div> </div>
</div> </div>
<Text value={user.About || ""} /> <Text value={user.about || ""} />
<Button <Button
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline" className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
variant="link" variant="link"
@ -37,14 +87,24 @@ export default function Profile({ user }: { user: User }) {
</Button> </Button>
{showAdvancedInformation && ( {showAdvancedInformation && (
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap"> <p className="text-white flex gap-1 overflow-hidden text-ellipsis whitespace-nowrap">
Iota ID: <code>{user.IotaId}</code> Shared Code: <code>{sharedSecretCode}</code>{" "}
<Tooltip>
<TooltipTrigger render={<Info className="size-3.5" />} />
<TooltipContent>
You can compare this code with the conversation partner to
validate that this chat is E2EE
</TooltipContent>
</Tooltip>
</p> </p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap"> <p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
User ID: <code>{user.UserId}</code> Iota ID: <code>{user.iota_id}</code>
</p> </p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap"> <p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
Public Key: <code>{user.PublicKey}</code> User ID: <code>{user.user_id}</code>
</p>
<p className="text-muted-foreground text-xs overflow-hidden text-ellipsis whitespace-nowrap">
Public Key: <code>{user.public_key}</code>
</p> </p>
</div> </div>
)} )}

View file

@ -28,7 +28,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const { id } = useSearch({ strict: false }); const { id } = useSearch({ strict: false });
const currentCalls = calls.filter((call) => const currentCalls = calls.filter((call) =>
call.CallMembers.some((member) => member === id), call.call_members.some((member) => member === id),
); );
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -58,6 +58,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<House className="size-4.5" /> <House className="size-4.5" />
</Button> </Button>
<Button <Button
// @ts-expect-error TanStack router doesn't properly detect the settings route
onClick={() => navigate({ to: "/settings" })} onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
variant="outline" variant="outline"
@ -71,7 +72,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
userId={id} userId={id}
component={(user) => component={(user) =>
isMobile ? ( isMobile ? (
<p className="font-medium text-[1.07rem]">{user?.Display}</p> <p className="font-medium text-[1.07rem]">{user?.display}</p>
) : ( ) : (
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}> <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger <PopoverTrigger
@ -84,7 +85,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
}} }}
> >
<p className="font-medium text-[1.07rem]"> <p className="font-medium text-[1.07rem]">
{user?.Display} {user?.display}
</p> </p>
</Button> </Button>
} }
@ -95,7 +96,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
</Popover> </Popover>
) )
} }
loading={<Skeleton className="w-30 h-5" />} loading={<Skeleton className="ml-3 w-40 h-5" />}
/> />
)} )}
</div> </div>
@ -120,8 +121,8 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => { onClick={() => {
void joinCall( void joinCall(
id, id,
currentCalls[0].CallSecret, currentCalls[0].call_secret,
currentCalls[0].CallId, currentCalls[0].call_id,
); );
}} }}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9 aspect-square rounded-lg"
@ -146,13 +147,13 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
<SelectContent className="p-1"> <SelectContent className="p-1">
{currentCalls.map((call) => ( {currentCalls.map((call) => (
<SelectItem <SelectItem
value={call.CallId} value={call.call_id}
key={call.CallId} key={call.call_id}
onSelect={() => { onSelect={() => {
void joinCall(id, call.CallSecret, call.CallId); void joinCall(id, call.call_secret, call.call_id);
}} }}
> >
{displayCallId(call.CallId)} {displayCallId(call.call_id)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -194,6 +195,7 @@ export function MobileNavbar() {
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
// @ts-expect-error TanStack router doesn't properly detect the settings route
navigate({ to: "/settings" }); navigate({ to: "/settings" });
setOpenMobile(false); setOpenMobile(false);
}} }}

View file

@ -24,7 +24,7 @@ const fetchedUser = z.object({
const formSchema = z.object({ const formSchema = z.object({
username: z.string().min(1).max(255), username: z.string().min(1).max(255),
mtp_keyring: z.string().min(1).max(92), private_key: z.string().min(1).max(92),
}); });
/** /**
@ -61,7 +61,14 @@ function parseTuFileContent(rawFileContent: string): {
? Number(userIdString.split("@")[0]) ? Number(userIdString.split("@")[0])
: Number(userIdString); : Number(userIdString);
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null; const rawDomain = userIdString.includes("@")
? userIdString.split("@")[1]
: null;
const domain = rawDomain
? rawDomain.includes(":")
? rawDomain
: rawDomain + ":1984"
: null;
if (!userId || !privateKey) { if (!userId || !privateKey) {
throw new Error("Invalid file"); throw new Error("Invalid file");
@ -69,6 +76,7 @@ function parseTuFileContent(rawFileContent: string): {
console.log({ console.log({
domain, domain,
rawDomain,
userId, userId,
}); });
@ -80,30 +88,6 @@ export default function Form() {
const uploadRef = React.useRef<HTMLInputElement | null>(null); const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage(); const { save } = useStorage();
const loginPendingRef = React.useRef(false);
const persistLogin = React.useCallback(
async (userId: number, privateKey: string, domain?: string | null) => {
if (loginPendingRef.current) return false;
loginPendingRef.current = true;
try {
if (domain) await save("omega_url", `https://${domain}/`);
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
window.history.replaceState(
null,
"",
window.location.protocol === "file:" ? "#/" : "/",
);
window.location.reload();
return true;
} finally {
loginPendingRef.current = false;
}
},
[save],
);
// Process dropped files // Process dropped files
const processDroppedFile = React.useCallback( const processDroppedFile = React.useCallback(
@ -117,13 +101,20 @@ export default function Form() {
const raw = await file.text(); const raw = await file.text();
const parsed = parseTuFileContent(raw); const parsed = parseTuFileContent(raw);
await persistLogin(parsed.userId, parsed.privateKey, parsed.domain); await save("session_id", Date.now());
await save("user_id", parsed.userId);
await save("private_key", parsed.privateKey);
if (parsed.domain) {
await save("ttp_url", `https://${parsed.domain}/`);
}
location.href = "/";
} catch (error) { } catch (error) {
log(0, "login", "red", error); log(0, "login", "red", error);
toast("error", "Failed to load file"); toast("error", "Failed to load file");
} }
}, },
[persistLogin], [save],
); );
// Handle .tu files // Handle .tu files
@ -229,9 +220,14 @@ export default function Form() {
const actualUsername = inputUsername.includes("@") const actualUsername = inputUsername.includes("@")
? inputUsername.split("@")[0] ? inputUsername.split("@")[0]
: inputUsername; : inputUsername;
const domain = inputUsername.includes("@") const rawDomain = inputUsername.includes("@")
? inputUsername.split("@")[1] ? inputUsername.split("@")[1]
: null; : null;
const domain = rawDomain
? rawDomain.includes(":")
? rawDomain
: rawDomain + ":1984"
: null;
try { try {
const response = await fetch( const response = await fetch(
@ -253,13 +249,20 @@ export default function Form() {
const user = parse.data; const user = parse.data;
await persistLogin(user.user_id, inputParse.data.mtp_keyring, domain); await save("session_id", Date.now());
await save("user_id", user.user_id);
await save("private_key", inputParse.data.private_key);
if (domain) {
await save("ttp_url", `https://${domain}/`);
}
location.href = "/";
} catch (error) { } catch (error) {
log(0, "login", "red", error); log(0, "login", "red", error);
toast("error", "Failed to fetch user data"); toast("error", "Failed to fetch user data");
} }
}, },
[persistLogin], [save],
); );
return ( return (
@ -279,7 +282,15 @@ export default function Form() {
const { userId, privateKey, domain } = const { userId, privateKey, domain } =
parseTuFileContent(decoded); parseTuFileContent(decoded);
await persistLogin(userId, privateKey, domain); await save("session_id", Date.now());
await save("user_id", userId);
await save("private_key", privateKey);
if (domain) {
await save("ttp_url", `https://${domain}/`);
}
location.href = "/";
} catch (error) { } catch (error) {
log(0, "login", "red", error); log(0, "login", "red", error);
toast("error", "Failed to parse QR code data"); toast("error", "Failed to parse QR code data");
@ -335,8 +346,8 @@ export default function Form() {
<Input required type="text" id="username" name="username" /> <Input required type="text" id="username" name="username" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="mtp_keyring">MTP Keyring</Label> <Label htmlFor="private_key">Private Key</Label>
<Input required type="password" id="mtp_keyring" name="mtp_keyring" /> <Input required type="password" id="private_key" name="private_key" />
</div> </div>
<Button className="mt-auto" type="submit"> <Button className="mt-auto" type="submit">
Login Login

View file

@ -12,6 +12,7 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel,
Dialog, Dialog,
DialogContent, DialogContent,
DialogFooter, DialogFooter,
@ -37,10 +38,12 @@ import { Ellipsis, Check } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import type z from "zod"; import type z from "zod";
import { mtp } from "@tensamin/shared/data"; import { ttp } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp"; import { useTTP } from "@tensamin/ttp";
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>; type OnlineStatus = z.infer<
typeof ttp.get_user_data.response.shape.online_status
>;
const onlineStatusLabels: Record<OnlineStatus, string> = { const onlineStatusLabels: Record<OnlineStatus, string> = {
user_online: "Online", user_online: "Online",
@ -71,7 +74,7 @@ function StatusDialog({
user: User; user: User;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useMTP>["send"]; send: ReturnType<typeof useTTP>["send"];
draftStatus: string; draftStatus: string;
setDraftStatus: (value: string) => void; setDraftStatus: (value: string) => void;
draftOnlineStatus: OnlineStatus; draftOnlineStatus: OnlineStatus;
@ -86,8 +89,8 @@ function StatusDialog({
open={open} open={open}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.online_status);
setErrorMessage(""); setErrorMessage("");
setSaveSucceeded(false); setSaveSucceeded(false);
} }
@ -142,10 +145,11 @@ function StatusDialog({
onClick={async () => { onClick={async () => {
const payload = { const payload = {
...(draftStatus && { status: draftStatus }), ...(draftStatus && { status: draftStatus }),
OnlineStatus: draftOnlineStatus, online_status: draftOnlineStatus,
}; };
const validation = mtp.ChangeUserData.request.safeParse(payload); const validation =
ttp.change_user_data.request.safeParse(payload);
if (!validation.success) { if (!validation.success) {
setSaveSucceeded(false); setSaveSucceeded(false);
@ -156,7 +160,7 @@ function StatusDialog({
} }
try { try {
await send("ChangeUserData", validation.data); await send("change_user_data", validation.data);
setSaveSucceeded(true); setSaveSucceeded(true);
setErrorMessage(""); setErrorMessage("");
} catch (err) { } catch (err) {
@ -191,7 +195,7 @@ export default function Sidebar() {
const [statusErrorMessage, setStatusErrorMessage] = useState(""); const [statusErrorMessage, setStatusErrorMessage] = useState("");
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false); const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
const { send } = useMTP(); const { send } = useTTP();
const content = ( const content = (
<> <>
@ -224,10 +228,11 @@ export default function Sidebar() {
/> />
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel>Profile</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.online_status);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
setDialogOpen(true); setDialogOpen(true);
@ -245,8 +250,8 @@ export default function Sidebar() {
open={dialogOpen} open={dialogOpen}
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (!nextOpen) { if (!nextOpen) {
setDraftStatus(user.Status ?? ""); setDraftStatus(user.status ?? "");
setDraftOnlineStatus(user.OnlineStatus); setDraftOnlineStatus(user.online_status);
setStatusErrorMessage(""); setStatusErrorMessage("");
setStatusSaveSucceeded(false); setStatusSaveSucceeded(false);
} }

View file

@ -65,7 +65,7 @@ export default function List() {
{category === "conversations" ? ( {category === "conversations" ? (
contacts?.[virtualItem.index] ? ( contacts?.[virtualItem.index] ? (
<ConversationModal <ConversationModal
userId={contacts[virtualItem.index].UserId} userId={contacts[virtualItem.index].user_id}
/> />
) : null ) : null
) : communities?.[virtualItem.index] ? ( ) : communities?.[virtualItem.index] ? (

View file

@ -23,10 +23,10 @@ export default function Switch(props: {
return ( return (
<div <div
role="tablist" role="tablist"
className="border relative grid grid-cols-2 rounded-4xl bg-card p-1 select-none" className="border relative grid grid-cols-2 rounded-full bg-card p-1 select-none"
> >
<div <div
className="absolute top-1 bottom-1 rounded-4xl bg-input shadow-sm transition-all duration-300 ease-in-out" className="absolute top-1 bottom-1 rounded-full bg-input shadow-sm transition-all duration-300 ease-in-out"
style={{ style={{
left: props.category === "conversations" ? "0.25rem" : "50%", left: props.category === "conversations" ? "0.25rem" : "50%",
width: "calc(50% - 0.25rem)", width: "calc(50% - 0.25rem)",
@ -35,7 +35,7 @@ export default function Switch(props: {
<button <button
role="tab" role="tab"
aria-selected={props.category === "conversations"} aria-selected={props.category === "conversations"}
className={`relative z-10 w-full cursor-pointer rounded-4xl px-2.5 py-1.5 text-center text-sm font-medium transition-colors duration-300 ${ className={`relative z-10 w-full cursor-pointer rounded-full px-2.5 py-1.5 text-center text-sm font-medium transition-colors duration-300 ${
props.category === "conversations" props.category === "conversations"
? "text-foreground" ? "text-foreground"
: "text-ring/50 hover:text-ring" : "text-ring/50 hover:text-ring"
@ -47,7 +47,7 @@ export default function Switch(props: {
<button <button
role="tab" role="tab"
aria-selected={props.category === "communities"} aria-selected={props.category === "communities"}
className={`relative z-10 w-full cursor-pointer rounded-4xl px-2.5 py-1.5 text-center text-sm font-medium transition-colors duration-300 ${ className={`relative z-10 w-full cursor-pointer rounded-full px-2.5 py-1.5 text-center text-sm font-medium transition-colors duration-300 ${
props.category === "communities" props.category === "communities"
? "text-foreground" ? "text-foreground"
: "text-ring/50 hover:text-ring" : "text-ring/50 hover:text-ring"

View file

@ -26,7 +26,10 @@ type ListStorageKey = {
type ListStorageItem<K extends ListStorageKey> = type ListStorageItem<K extends ListStorageKey> =
Storage[K] extends Array<infer Item> ? Item : never; Storage[K] extends Array<infer Item> ? Item : never;
export function Switch({ label, id }: { export function Switch({
label,
id,
}: {
label: React.ReactNode; label: React.ReactNode;
id: keyof typeof settingsStorageDefaults & BooleanStorageKey; id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
}) { }) {
@ -39,16 +42,23 @@ export function Switch({ label, id }: {
return ( return (
<div className="flex gap-1"> <div className="flex gap-1">
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => { <UISwitch
setValue(nextValue); id={id}
save(id, nextValue); checked={value}
}} /> onCheckedChange={(value) => {
setValue(value);
save(id, value);
}}
/>
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
</div> </div>
); );
} }
export function List<K extends ListStorageKey>({ label, id }: { export function List<K extends ListStorageKey>({
label,
id,
}: {
label: React.ReactNode; label: React.ReactNode;
id: K; id: K;
}) { }) {
@ -68,20 +78,30 @@ export function List<K extends ListStorageKey>({ label, id }: {
const toStorageItem = (value: string): ListStorageItem<K> => { const toStorageItem = (value: string): ListStorageItem<K> => {
const referenceItem = items[0] ?? storageDefaults[id][0]; const referenceItem = items[0] ?? storageDefaults[id][0];
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
if (typeof referenceItem === "number") {
return Number(value) as ListStorageItem<K>;
}
return value as ListStorageItem<K>;
}; };
const addItem = () => { const addItem = () => {
const trimmedValue = inputValue.trim(); const trimmedValue = inputValue.trim();
if (!trimmedValue) return; if (!trimmedValue) return;
const nextItem = toStorageItem(trimmedValue); const nextItem = toStorageItem(trimmedValue);
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return; if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
persistItems([...items, nextItem] as Storage[K]); persistItems([...items, nextItem] as Storage[K]);
setInputValue(""); setInputValue("");
}; };
const deleteItems = (indexes: Set<number>) => { const deleteItems = (indexes: Set<number>) => {
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K]; const nextItems = items.filter(
(_, index) => !indexes.has(index),
) as Storage[K];
setItems(nextItems); setItems(nextItems);
setSelectedItems(new Set()); setSelectedItems(new Set());
save(id, nextItems); save(id, nextItems);
@ -92,9 +112,13 @@ export function List<K extends ListStorageKey>({ label, id }: {
<Label>{label}</Label> <Label>{label}</Label>
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl"> <div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
<div className="flex gap-1"> <div className="flex gap-1">
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => { <Input
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addItem(); if (event.key === "Enter") addItem();
}} /> }}
/>
<Button onClick={addItem}>Add item</Button> <Button onClick={addItem}>Add item</Button>
</div> </div>
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
@ -102,19 +126,45 @@ export function List<K extends ListStorageKey>({ label, id }: {
const labelId = `${String(id)}-${index}`; const labelId = `${String(id)}-${index}`;
const selected = selectedItems.has(index); const selected = selectedItems.has(index);
const deletingSelectedItems = selectedItems.size > 1; const deletingSelectedItems = selectedItems.size > 1;
return ( return (
<ContextMenu key={`${String(item)}-${index}`}> <ContextMenu key={`${String(item)}-${index}`}>
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0"> <ContextMenuTrigger
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => { render={
<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
<Checkbox
id={labelId}
checked={selected}
onCheckedChange={(checked) => {
setSelectedItems((previous) => {
const nextSelected = new Set(previous); const nextSelected = new Set(previous);
if (checked) nextSelected.add(index);
else nextSelected.delete(index); if (checked) {
nextSelected.add(index);
} else {
nextSelected.delete(index);
}
return nextSelected; return nextSelected;
})} /> });
<Label htmlFor={labelId}>{String(item)}</Label><div /> }}
</div>} /> />
<Label htmlFor={labelId}>{String(item)}</Label>
<div />
</div>
}
/>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}> <ContextMenuItem
variant="destructive"
onClick={() =>
deleteItems(
deletingSelectedItems
? selectedItems
: new Set([index]),
)
}
>
{deletingSelectedItems ? "Delete Selected" : "Delete"} {deletingSelectedItems ? "Delete Selected" : "Delete"}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>

View file

@ -1,22 +1,25 @@
import { Button, ClearStorageButton } from "@tensamin/ui";
import options from "@tensamin/shared/settings";
import { cn, useIsMobile } from "@tensamin/ui";
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router"; import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
import { Button, ClearStorageButton, cn, useIsMobile } from "@tensamin/ui";
import { settingsNavigation } from "./manifest"; export default function Screen() {
export default function SettingsLayout() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const location = useLocation(); const location = useLocation();
return ( return (
<div className="flex h-full w-full"> <div className="flex h-full w-full">
{!isMobile && <SettingsSidebar />} {!isMobile && <SettingsSidebar />}
{/* Page */}
<div className="bg-background w-full h-full p-3 flex flex-col gap-3"> <div className="bg-background w-full h-full p-3 flex flex-col gap-3">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">
{location.pathname {location.pathname
.split("/") .split("/")
.pop() .pop()
?.replace(/-/g, " ") ?.replace(/-/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase())} .replace(/\b\w/g, (l) => l.toUpperCase())}
</h1> </h1>
<Outlet /> <Outlet />
</div> </div>
@ -25,31 +28,44 @@ export default function SettingsLayout() {
} }
export function SettingsSidebar() { export function SettingsSidebar() {
const settingsOptions = options as Record<
string,
Record<string, Record<string, unknown>>
>;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
const categories = [...new Set(settingsNavigation.map((page) => page.category))];
return ( return (
<div <div
className={cn( className={cn(
isMobile ? "w-full p-1" : "p-3 rounded-tl-2xl border-r bg-input/15 w-50", isMobile
? "w-full p-1"
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
"flex flex-col gap-6", "flex flex-col gap-6",
)} )}
> >
{categories.map((category) => ( {/* Settings */}
{Object.keys(settingsOptions).map((category) => (
// Category
<div key={category} className="flex flex-col gap-2"> <div key={category} className="flex flex-col gap-2">
<h2 className="font-bold text-xs uppercase">{category}</h2> <h2 className="font-bold text-xs uppercase">{category}</h2>
{settingsNavigation {Object.keys(settingsOptions[category]).map((page) => (
.filter((page) => page.category === category) // Page
.map((page) => ( <div key={page}>
<Button <Button
key={page.path}
className="w-full" className="w-full"
variant="outline" variant="outline"
onClick={() => navigate({ to: `/settings/${page.path}` })} onClick={() =>
navigate({
to: "/settings/" + page.toLowerCase(),
})
}
> >
{page.label} {(page as string).charAt(0).toUpperCase() +
(page as string).slice(1)}
</Button> </Button>
</div>
))} ))}
</div> </div>
))} ))}

View file

@ -22,17 +22,6 @@
} }
} }
@theme inline {
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--radius-full: calc(var(--radius) * 9999);
}
html, html,
body, body,
#root { #root {

View file

@ -14,7 +14,7 @@ import "@tensamin/ui/index.css";
import NotFound from "@/routes/404"; import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout"; import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings"; import SettingsLayout from "@/features/settings/layout";
import Home from "@/routes/app/home"; import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen"; import ChatScreen from "@tensamin/chat/screen";
@ -23,16 +23,15 @@ import Login from "@/routes/screens/login";
import CallPopout from "@tensamin/call/popout"; import CallPopout from "@tensamin/call/popout";
import ChatContext from "@tensamin/chat/context"; import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState"; import { Provider as TTPProvider } from "@tensamin/ttp";
import { Provider as MTPProvider } from "@tensamin/mtp"; import UserContext from "@tensamin/user/context";
import UserProvider from "@tensamin/user/context";
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context"; import NotificationsProvider from "@tensamin/notifications/context";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui"; import { ThemeProvider, useTheme } from "@tensamin/ui";
import z from "zod"; import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
@ -41,10 +40,8 @@ import Storage from "@tensamin/storage/context";
import Session from "@tensamin/storage/session"; import Session from "@tensamin/storage/session";
import Crypto from "@tensamin/crypto/context"; import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia"; import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log";
import LegalWrapper from "@/features/legal/screen"; import LegalWrapper from "@/features/legal/screen";
import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
@ -73,39 +70,34 @@ window.setLogLevelToMax = () => {
function LoginWrapper({ children }: { children: ReactNode }) { function LoginWrapper({ children }: { children: ReactNode }) {
const [loggedIn, setLoggedIn] = useState<boolean | null>(null); const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
const { load, secureStorage } = useStorage(); const { load } = useStorage();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
if (secureStorage === null) return;
let active = true; let active = true;
Promise.all([load("user_id"), load("mtp_keyring")]) load("user_id").then((userId) => {
.then(([userId, keyring]) => { if (!active) {
if (!active) return;
if (userId !== 0 && keyring !== "") {
setLoggedIn(true);
if (location.pathname === "/login") {
void navigate({ to: "/", replace: true });
}
return; return;
} }
if (userId !== 0) {
setLoggedIn(true);
return;
}
setLoggedIn(false); setLoggedIn(false);
void navigate({ navigate({
to: "/login", to: "/login",
replace: true,
}); });
})
.catch((error) => {
log(0, "login", "red", "Failed to load login state", error);
}); });
return () => { return () => {
active = false; active = false;
}; };
}, [load, location.pathname, navigate, secureStorage]); }, [load, navigate]);
if (loggedIn !== true && location.pathname !== "/login") { if (loggedIn !== true && location.pathname !== "/login") {
return null; return null;
@ -127,10 +119,6 @@ function ThemeStorageBridge() {
setThemePolarity, setThemePolarity,
themeTint, themeTint,
setThemeTint, setThemeTint,
themeBorderRadius,
setThemeBorderRadius,
themeCustomCss,
setThemeCustomCss,
} = useTheme(); } = useTheme();
const loadedRef = useRef(false); const loadedRef = useRef(false);
@ -143,18 +131,7 @@ function ThemeStorageBridge() {
load("theme_primary_color"), load("theme_primary_color"),
load("theme_polarity"), load("theme_polarity"),
load("theme_tint"), load("theme_tint"),
load("theme_border_radius"), ]).then(([color, palette, primaryColor, polarity, tint]) => {
load("theme_custom_css"),
]).then(
([
color,
palette,
primaryColor,
polarity,
tint,
borderRadius,
customCss,
]) => {
if (!active) { if (!active) {
return; return;
} }
@ -164,20 +141,15 @@ function ThemeStorageBridge() {
setThemePrimaryColor(primaryColor); setThemePrimaryColor(primaryColor);
setThemePolarity(polarity); setThemePolarity(polarity);
setThemeTint(tint); setThemeTint(tint);
setThemeBorderRadius(borderRadius);
setThemeCustomCss(customCss);
loadedRef.current = true; loadedRef.current = true;
}, });
);
return () => { return () => {
active = false; active = false;
}; };
}, [ }, [
load, load,
setThemeBorderRadius,
setThemeColor, setThemeColor,
setThemeCustomCss,
setThemePalette, setThemePalette,
setThemePolarity, setThemePolarity,
setThemePrimaryColor, setThemePrimaryColor,
@ -185,33 +157,45 @@ function ThemeStorageBridge() {
]); ]);
useEffect(() => { useEffect(() => {
if (loadedRef.current) save("theme_color", themeColor); if (!loadedRef.current) {
return;
}
save("theme_color", themeColor);
}, [save, themeColor]); }, [save, themeColor]);
useEffect(() => { useEffect(() => {
if (loadedRef.current) save("theme_palette", themePalette); if (!loadedRef.current) {
return;
}
save("theme_palette", themePalette);
}, [save, themePalette]); }, [save, themePalette]);
useEffect(() => { useEffect(() => {
if (loadedRef.current) save("theme_primary_color", themePrimaryColor); if (!loadedRef.current) {
return;
}
save("theme_primary_color", themePrimaryColor);
}, [save, themePrimaryColor]); }, [save, themePrimaryColor]);
useEffect(() => { useEffect(() => {
if (loadedRef.current) save("theme_polarity", themePolarity); if (!loadedRef.current) {
return;
}
save("theme_polarity", themePolarity);
}, [save, themePolarity]); }, [save, themePolarity]);
useEffect(() => { useEffect(() => {
if (loadedRef.current) save("theme_tint", themeTint); if (!loadedRef.current) {
return;
}
save("theme_tint", themeTint);
}, [save, themeTint]); }, [save, themeTint]);
useEffect(() => {
if (loadedRef.current) save("theme_border_radius", themeBorderRadius);
}, [save, themeBorderRadius]);
useEffect(() => {
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
}, [save, themeCustomCss]);
return null; return null;
} }
@ -226,8 +210,6 @@ function RootShell() {
paletteStorageKey={null} paletteStorageKey={null}
primaryColorStorageKey={null} primaryColorStorageKey={null}
tintStorageKey={null} tintStorageKey={null}
borderRadiusStorageKey={null}
customCssStorageKey={null}
> >
<div className="w-screen h-dvh overflow-hidden"> <div className="w-screen h-dvh overflow-hidden">
<Toaster <Toaster
@ -262,10 +244,9 @@ function RootShell() {
function AppShell() { function AppShell() {
return ( return (
<MTPProvider> <TTPProvider>
<CacheSync />
<Session> <Session>
<UserProvider> <UserContext>
<CallInit /> <CallInit />
<CallPopout /> <CallPopout />
<TAuthWrapper> <TAuthWrapper>
@ -277,110 +258,18 @@ function AppShell() {
</ChatContext> </ChatContext>
</AppLayout> </AppLayout>
</TAuthWrapper> </TAuthWrapper>
</UserProvider> </UserContext>
</Session> </Session>
</MTPProvider> </TTPProvider>
); );
} }
function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas");
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext("2d");
if (!context) return undefined;
context.globalAlpha = speaking ? 1 : 0.55;
context.fillStyle = color;
context.beginPath();
context.arc(16, 16, 13, 0, Math.PI * 2);
context.fill();
if (speaking) {
context.globalAlpha = 0.3;
context.fillStyle = "#ffffff";
context.fill();
}
return canvas.toDataURL("image/png");
}
function CallInit() { function CallInit() {
useInitializeCall(); return useInitializeCall();
const { load } = useStorage();
const {
themeColor,
themePalette,
themePrimaryColor,
themePolarity,
themeTint,
themeCustomCss,
} = useTheme();
const [localUserId, setLocalUserId] = useState(-1);
const [primaryColor, setPrimaryColor] = useState("");
const inCall = useCall((state) => state.state === "open");
const speaking = useIsSpeaking(localUserId);
useEffect(() => {
let active = true;
load("user_id").then((userId) => {
if (active) setLocalUserId(userId);
});
return () => {
active = false;
};
}, [load]);
useEffect(() => {
const frame = requestAnimationFrame(() => {
setPrimaryColor(
getComputedStyle(document.documentElement)
.getPropertyValue("--primary")
.trim(),
);
});
return () => cancelAnimationFrame(frame);
}, [
themeColor,
themeCustomCss,
themePalette,
themePolarity,
themePrimaryColor,
themeTint,
]);
useEffect(() => {
const iconDataUrl = primaryColor
? createCallTrayIcon(primaryColor, speaking)
: undefined;
void window.tensaminDesktop?.call
?.setStatus?.({
inCall,
speaking: inCall && speaking,
iconDataUrl: inCall ? iconDataUrl : undefined,
})
.catch((error: unknown) => {
console.error("Failed to update desktop call status", error);
});
}, [inCall, primaryColor, speaking]);
return null;
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: RootShell, component: RootShell,
errorComponent: ({ error }: { error: Error }) => (
<ErrorScreen
description={error.message}
error={"Unknown Error: " + error.name}
/>
),
}); });
const appRoute = createRoute({ const appRoute = createRoute({
@ -390,7 +279,47 @@ const appRoute = createRoute({
notFoundComponent: NotFound, notFoundComponent: NotFound,
}); });
const settingsRoute = createSettingsRoute(appRoute); const settingsRoute = createRoute({
getParentRoute: () => appRoute,
path: "settings",
component: SettingsLayout,
staticData: {
showMobileNavbar: true,
},
});
type SettingsRouteModule = {
default?: () => React.JSX.Element;
component?: () => React.JSX.Element;
};
const settingsRouteModules = import.meta.glob<SettingsRouteModule>(
"./routes/settings/*.tsx",
{ eager: true },
);
const settingsChildren = Object.entries(settingsRouteModules).map(
([filePath, module]) => {
const fileName = filePath.split("/").pop()?.replace(".tsx", "") ?? "";
const path = fileName === "index" ? "/" : fileName;
const component = module.component ?? module.default;
if (!component) {
throw new Error(
`Settings route module "${filePath}" must export a default component`,
);
}
return createRoute({
getParentRoute: () => settingsRoute,
path,
component,
staticData: {
showMobileNavbar: true,
},
});
},
);
const homeRoute = createRoute({ const homeRoute = createRoute({
getParentRoute: () => appRoute, getParentRoute: () => appRoute,
@ -432,7 +361,12 @@ const loginRoute = createRoute({
}); });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
appRoute.addChildren([homeRoute, chatRoute, callRoute, settingsRoute]), appRoute.addChildren([
homeRoute,
chatRoute,
callRoute,
settingsRoute.addChildren(settingsChildren),
]),
loginRoute, loginRoute,
]); ]);

View file

@ -11,47 +11,27 @@ import {
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@tensamin/ui";
import z from "zod"; import z from "zod";
import { useMTP } from "@tensamin/mtp"; import { useTTP } from "@tensamin/ttp";
import { useState } from "react"; import { useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import { useStorage } from "@tensamin/storage/context";
import { ShieldAlert } from "lucide-react";
// The page // The page
export default function Page() { export default function Page() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { secureStorage } = useStorage();
return ( return (
<div <div className={`px-3 flex gap-2 ${!(isTauri() && isMobile) && "pt-3"}`}>
className={`px-3 flex flex-col gap-3 ${!(isTauri() && isMobile) && "pt-3"}`}
>
<div className="flex gap-2">
<AddConversationButton /> <AddConversationButton />
<Button disabled>Add Community</Button> <Button disabled>Add Community</Button>
</div> </div>
{secureStorage && !secureStorage.secure && (
<div className="flex max-w-2xl gap-3 rounded-lg border border-(--destructive)/60 bg-(--destructive)/10 p-3 text-sm">
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
<div>
<p className="font-medium">Secure storage is unavailable</p>
<p>{secureStorage.reason}</p>
<p className="text-muted-foreground">
Your keyring and cached messages will get saved in regular
storage.
</p>
</div>
</div>
)}
</div>
); );
} }
// Add Conversation Button Component // Add Conversation Button Component
function AddConversationButton() { function AddConversationButton() {
const { send } = useMTP(); const { send } = useTTP();
const { contacts, insertContact } = useSession(); const { contacts, insertContact } = useSession();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -75,11 +55,11 @@ function AddConversationButton() {
} }
// user existence check // user existence check
const user = await send("GetUserData", { const user = await send("get_user_data", {
Username: result.data, username: result.data,
}) })
.then((data) => { .then((data) => {
if (data.data.UserId === 0) { if (data.data.user_id === 0) {
throw new Error(); throw new Error();
} }
@ -92,7 +72,7 @@ function AddConversationButton() {
if (!user) return; if (!user) return;
// alrady added check // alrady added check
if (contacts.some((contact) => contact.UserId === user.data.UserId)) { if (contacts.some((contact) => contact.user_id === user.data.user_id)) {
setError("Conversation already exists"); setError("Conversation already exists");
return; return;
} }
@ -100,11 +80,11 @@ function AddConversationButton() {
// add the conv // add the conv
const timeout = setTimeout(() => setLoading(true), 500); const timeout = setTimeout(() => setLoading(true), 500);
send("AddConversation", { send("add_conversation", {
ChatPartnerName: result.data, chat_partner_name: result.data,
}) })
.then(() => { .then(() => {
insertContact(user.data.UserId); insertContact(user.data.user_id);
setOpen(false); setOpen(false);
}) })
.catch((error) => { .catch((error) => {

View file

@ -1,10 +1,7 @@
import { storageDefaults } from "@tensamin/shared/data"; import { List, Switch } from "@/features/settings/components";
import { useStorage } from "@tensamin/storage/context"; import { Kbd } from "@tensamin/ui";
import { Button, Kbd } from "@tensamin/ui";
import { List, Switch } from "../components";
export default function Page() { export default function Page() {
const { save } = useStorage();
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Switch <Switch
@ -32,14 +29,6 @@ export default function Page() {
Trusted embed domains can get your IP-Address! Only add domains if you Trusted embed domains can get your IP-Address! Only add domains if you
really trust them! really trust them!
</p> </p>
<div>
<Button
variant="outline"
onClick={() => void save("reactions", storageDefaults.reactions)}
>
Reset Emoji Ranks
</Button>
</div>
<List label="Trusted embed domains" id="chat_trusted_domains" /> <List label="Trusted embed domains" id="chat_trusted_domains" />
</div> </div>
); );

View file

@ -1,5 +1,5 @@
import { SettingsSidebar } from "@/features/settings/layout";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@tensamin/ui";
import { SettingsSidebar } from "../layout";
export default function Page() { export default function Page() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();

View file

@ -0,0 +1,144 @@
import {
Card,
CardContent,
CardTitle,
CardHeader,
CardDescription,
CardFooter,
Button,
Badge,
Dialog,
DialogContent,
DialogTrigger,
} from "@tensamin/ui";
import {
packages,
packageCount,
generatedAt,
} from "../../../../../licenses/third-party-credits.json";
const licenseTexts = import.meta.glob("../../../../../licenses/**/*", {
eager: true,
import: "default",
query: "?raw",
}) as Record<string, string>;
function getLicenseFiles(licensePackage: (typeof packages)[number]) {
return licensePackage.files.map((fileName) => {
const path =
"../../../../../" + licensePackage.licenseFolder + "/" + fileName;
return {
fileName,
text: licenseTexts[path],
};
});
}
export default function Page() {
return (
<div className="flex h-full min-h-0 flex-col gap-7">
<div className="flex flex-col">
<p>Last generated: {generatedAt}</p>
<p>Package Count: {packageCount}</p>
</div>
<div className="min-h-0 flex-1 max-h-[calc(100vh-180px)] overflow-auto pr-2">
<div className="flex flex-col gap-5">
{packages.map((licensePackage) => (
<Card
key={licensePackage.name + licensePackage.version}
id={licensePackage.name + licensePackage.version}
>
<CardHeader>
<CardTitle className="flex gap-2 items-center">
<Badge>{licensePackage.license}</Badge> {licensePackage.name}{" "}
{licensePackage.version}
</CardTitle>
</CardHeader>
{licensePackage.description && (
<CardContent>
<CardDescription>
{licensePackage.description}
</CardDescription>
</CardContent>
)}
<CardFooter className="gap-2">
<LicenseDialog licensePackage={licensePackage} />
{licensePackage.repository ? (
<a
target="_blank"
rel="noreferrer"
href={licensePackage.repository
?.replace("git+", "")
.replace(".git", "")}
>
<Button variant="outline" className="cursor-pointer">
Open Repository
</Button>
</a>
) : (
<Button disabled variant="outline" className="cursor-pointer">
Open Repository
</Button>
)}
{licensePackage.homepage ? (
<a
target="_blank"
rel="noreferrer"
href={licensePackage.homepage}
>
<Button variant="outline" className="cursor-pointer">
Open Homepage
</Button>
</a>
) : (
<Button disabled variant="outline" className="cursor-pointer">
Open Homepage
</Button>
)}
</CardFooter>
</Card>
))}
</div>
</div>
</div>
);
}
function LicenseDialog({
licensePackage,
}: {
licensePackage: (typeof packages)[number];
}) {
const licenseFiles = getLicenseFiles(licensePackage);
const hasLicenseText = licenseFiles.some(({ text }) => text);
return (
<Dialog>
<DialogTrigger
render={
<Button disabled={!hasLicenseText} className="cursor-pointer">
Open License
</Button>
}
/>
<DialogContent className="flex max-h-[85vh] min-h-0 flex-col overflow-hidden sm:max-w-3xl">
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2">
{licenseFiles.map(({ fileName, text }, index) => (
<section key={fileName} className="border-b last:border-b-0">
<h3
className={`border-b pb-2 text-sm font-medium ${index >= 1 && "pt-2"}`}
>
{fileName}
</h3>
<pre className="pt-2 whitespace-pre-wrap wrap-break-word text-xs leading-relaxed">
{text ||
"License text could not be loaded. Please contact support@tensamin.net"}
</pre>
</section>
))}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,218 @@
import { useStorage } from "@tensamin/storage/context";
import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
cn,
Input,
useIsMobile,
} from "@tensamin/ui";
import { useUser, type User } from "@tensamin/user/context";
import { useEffect, useRef, useState } from "react";
import MDInput from "@tensamin/markdown/input";
import { ttp } from "@tensamin/shared/data";
import { useTTP } from "@tensamin/ttp";
import { Check } from "lucide-react";
async function prepImage(
file: File,
size = 300,
quality = 0.8,
): Promise<string> {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Could not get canvas context");
const scale = Math.max(size / bitmap.width, size / bitmap.height);
const width = bitmap.width * scale;
const height = bitmap.height * scale;
const x = (size - width) / 2;
const y = (size - height) / 2;
ctx.drawImage(bitmap, x, y, width, height);
return canvas.toDataURL("image/webp", quality);
}
export default function Page() {
const { get } = useUser();
const { load } = useStorage();
const { send } = useTTP();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [draftUser, setDraftUser] = useState<Partial<User>>({});
const [errorMessage, setErrorMessage] = useState("");
const [saveSucceeded, setSaveSucceeded] = useState(false);
const avatarUploadRef = useRef<HTMLInputElement>(null);
const draftInitializedRef = useRef(false);
const effectiveAvatar =
draftUser.avatar === "none" ? undefined : draftUser.avatar;
const updateDraftUser = (
updater: (previous: Partial<User>) => Partial<User>,
) => {
setSaveSucceeded(false);
setErrorMessage("");
setDraftUser(updater);
};
useEffect(() => {
const fetchUser = async () => {
const user = await get(await load("user_id"));
setCurrentUser(user);
};
fetchUser();
}, [load, get]);
useEffect(() => {
if (!currentUser || draftInitializedRef.current) return;
setDraftUser(currentUser);
draftInitializedRef.current = true;
}, [currentUser]);
const handleAvatarUpload = async (file: File) => {
const final = await prepImage(file);
updateDraftUser((prev) => ({ ...prev, avatar: final }));
if (avatarUploadRef.current) {
avatarUploadRef.current.value = "";
}
};
const isMobile = useIsMobile();
return currentUser ? (
<>
<input
ref={avatarUploadRef}
hidden
onChange={(e) =>
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
}
type="file"
/>
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "w-80")}>
<div className="flex items-center gap-3">
<Avatar className="size-14">
<AvatarImage src={effectiveAvatar} />
<AvatarFallback className="text-2xl">
{draftUser.display?.slice(0, 2).toUpperCase() ||
currentUser.display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<p>Avatar</p>
<div className="flex gap-1">
<Button onClick={() => avatarUploadRef.current?.click()}>
Upload avatar
</Button>
<Button
onClick={() => {
updateDraftUser((prev) => {
return { ...prev, avatar: "none" };
});
}}
variant="destructive"
disabled={effectiveAvatar === undefined}
>
Remove
</Button>
</div>
<p className="text-sm text-muted-foreground">
GIFs are supported in decentralised mode or with Tensamin Premium.
<br />
Maximum file size is 16mb.
</p>
</div>
</div>
<Input
className="w-full"
onChange={(event) =>
updateDraftUser((prev) => ({
...prev,
display: event.target.value,
}))
}
placeholder="Display Name"
value={draftUser.display || ""}
/>
<Input
className="w-full"
onChange={(event) =>
updateDraftUser((prev) => ({
...prev,
username: event.target.value,
}))
}
placeholder="Username"
value={draftUser.username || ""}
/>
<MDInput
styled
paddingY="4px"
paddingX="10px"
fontSize=".875rem"
placeholder="About Me"
setValue={(value) =>
updateDraftUser((prev) => ({ ...prev, about: value }))
}
value={draftUser.about || ""}
/>
<Button
onClick={async () => {
const { avatar, ...draftUsersWithoutAvatar } = draftUser;
const payload = {
...draftUsersWithoutAvatar,
...(typeof avatar === "string"
? {
avatar: avatar.startsWith("data:")
? (avatar.split(",", 2)[1] ?? "")
: avatar,
}
: {}),
};
const validation = ttp.change_user_data.request.safeParse(payload);
if (!validation.success) {
setSaveSucceeded(false);
setErrorMessage(
validation.error.issues[0]?.message ?? "Invalid profile data",
);
return;
}
try {
await send("change_user_data", validation.data);
setSaveSucceeded(true);
setErrorMessage("");
} catch (err) {
setSaveSucceeded(false);
setErrorMessage("Failed to update profile: " + err);
}
}}
>
{saveSucceeded ? (
<span className="inline-flex items-center gap-1.5">
<Check className="size-4" />
Saved
</span>
) : (
"Save"
)}
</Button>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</div>
</>
) : (
<p>Loading...</p>
);
}

View file

@ -0,0 +1,125 @@
import { useStorage } from "@tensamin/storage/context";
import { useEffect, useState } from "react";
import QRCode from "qrcode";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
} from "@tensamin/ui";
export default function Page() {
return (
<div>
<QrCodeLogin />
</div>
);
}
// QR Code Login
const generateQR = async (text: string): Promise<string> => {
try {
const url = await QRCode.toDataURL(text, {
errorCorrectionLevel: "H",
margin: 1,
color: {
dark: "#000000FF",
light: "#FFFFFFFF",
},
});
return url;
} catch (err) {
console.error(err);
throw err;
}
};
function QrCodeLogin() {
const { load } = useStorage();
const [userId, setUserId] = useState<number>(0);
const [privateKey, setPrivateKey] = useState<string>("");
const [qrCodeBase64, setQrCodeBase64] = useState<string | undefined>(
undefined,
);
const [connectionString, setConnectionString] = useState<string | null>(null);
useEffect(() => {
load("private_key").then((value) => {
if (value) {
setPrivateKey(value);
}
});
load("user_id").then((value) => {
if (value) {
setUserId(value);
}
});
load("ttp_url")
.then((value) => {
if (value) {
const url = new URL(value);
setConnectionString(`@${url.host}`);
} else {
setConnectionString("");
}
})
.catch(() => setConnectionString(""));
}, [load]);
useEffect(() => {
if (userId && privateKey && connectionString !== null) {
generateQR(
`tensamin://tu::${userId}${connectionString}::${privateKey}`,
).then(setQrCodeBase64);
}
}, [userId, privateKey, connectionString]);
const [qrCodeVisible, setQrCodeVisible] = useState(false);
return (
<div className="flex flex-col gap-1 pl-2">
<p>Login QR Code</p>
<div className="relative rounded-lg w-50 border-3 aspect-square overflow-hidden">
{qrCodeBase64 && (
<img className="w-full h-full" src={qrCodeBase64} alt="QR Code" />
)}
{!qrCodeVisible && (
<>
<div className="absolute top-0 left-0 w-full h-full backdrop-blur-sm bg-background/50" />
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<AlertDialog>
<AlertDialogTrigger render={<Button>Show QR Code</Button>} />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Exposing this QR code is the same as sharing your .tu
file. Since changing your private key is quite tedious,
only do this when you're confident the key won't be
compromised.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
render={
<Button onClick={() => setQrCodeVisible(true)}>
Show QR Code
</Button>
}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)}
</div>
</div>
);
}

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