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
393 changed files with 9932 additions and 33443 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -5,116 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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
@ -123,33 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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"
@ -161,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
@ -178,22 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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
@ -210,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:
@ -241,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
@ -330,4 +273,3 @@ jobs:
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-F "attachment=@$file" -F "attachment=@$file"
done done
EOF

View file

@ -6,116 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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
@ -124,33 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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 '
@ -160,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
@ -177,20 +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: Pull git submodules - name: Install Packages
run: git submodule update --init --recursive 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
@ -207,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:
@ -234,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
@ -288,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"
@ -314,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))"
@ -327,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)"
@ -382,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

5
.gitignore vendored
View file

@ -1,6 +1,3 @@
node_modules node_modules
releases releases
.fallow .fallow/
.direnv
keystore.jks
keystore.properties

3
.gitmodules vendored
View file

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

25
LICENSE
View file

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

3
README
View file

@ -1,3 +0,0 @@
# Information
All dev releases get deleted upon creation of the latest prod release.

6
TODO
View file

@ -1,6 +0,0 @@
- Add a bunch of tests
- Full accessability
- Add settings saving & update onboarding to use it
- Add onboarding page to load one profile during onboarding
-> Most folders in packages/ have specific todos

Binary file not shown.

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",
@ -95,11 +86,7 @@
"target": [ "target": [
"dmg" "dmg"
], ],
"icon": "build/icons/icon.icns", "icon": "build/icons/icon.icns"
"extendInfo": {
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls."
}
}, },
"publish": null "publish": null
} }

View file

@ -5,7 +5,6 @@ import {
app, app,
BrowserWindow, BrowserWindow,
desktopCapturer, desktopCapturer,
globalShortcut,
ipcMain, ipcMain,
session, session,
shell, shell,
@ -13,29 +12,14 @@ import {
import { checkForUpdates } from "./updates.js"; import { checkForUpdates } from "./updates.js";
import { import {
ipcChannels, ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
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");
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | null = null; let selectedScreenShareSourceId: string | null = null;
let globalHotkeyBindings: DesktopGlobalHotkeyBinding[] = [];
let globalHotkeysSuspended = false;
app.setName("tensamin");
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
if (verbose) { if (verbose) {
app.commandLine.appendSwitch("enable-logging", "stderr"); app.commandLine.appendSwitch("enable-logging", "stderr");
@ -43,20 +27,6 @@ if (verbose) {
app.commandLine.appendSwitch("log-level", "0"); app.commandLine.appendSwitch("log-level", "0");
} }
if (
process.platform === "linux" &&
!app.commandLine.hasSwitch("password-store")
) {
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
}
if (
process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland"
) {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
}
if ( if (
process.platform === "linux" && process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland" && process.env.XDG_SESSION_TYPE === "wayland" &&
@ -191,29 +161,6 @@ function registerDisplayMediaHandler() {
); );
} }
function registerMediaPermissionHandler() {
const isTrustedRenderer = (url: string) => {
try {
const parsed = new URL(url);
return parsed.protocol === "file:";
} catch {
return false;
}
};
session.defaultSession.setPermissionCheckHandler(
(_webContents, permission, requestingOrigin) =>
permission === "media" && isTrustedRenderer(requestingOrigin),
);
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback, details) => {
callback(
permission === "media" && isTrustedRenderer(details.requestingUrl),
);
},
);
}
function registerIpc() { function registerIpc() {
verboseLog("registering ipc handlers"); verboseLog("registering ipc handlers");
@ -241,55 +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.setGlobalHotkeyBindings,
(event, bindings: unknown) => {
assertTrustedRenderer(event);
return setGlobalHotkeyBindings(bindings);
},
);
ipcMain.handle(
ipcChannels.setGlobalHotkeysSuspended,
(event, suspended: unknown) => {
assertTrustedRenderer(event);
if (typeof suspended !== "boolean") {
throw new Error("Invalid hotkey suspension state.");
}
globalHotkeysSuspended = suspended;
return applyGlobalHotkeyBindings();
},
);
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
if (
typeof status !== "object" ||
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();
@ -311,90 +209,6 @@ function registerIpc() {
}); });
} }
function assertTrustedRenderer(event: Electron.IpcMainInvokeEvent) {
const target = mainWindow;
if (
!target ||
target.isDestroyed() ||
event.sender !== target.webContents ||
event.senderFrame !== target.webContents.mainFrame
) {
throw new Error("Untrusted hotkey IPC sender.");
}
try {
if (fileURLToPath(event.senderFrame.url) === getRendererIndex()) return;
} catch {
// Fall through to the rejection below.
}
throw new Error("Untrusted hotkey IPC sender.");
}
function validGlobalHotkeyBindings(
value: unknown,
): value is DesktopGlobalHotkeyBinding[] {
return (
Array.isArray(value) &&
value.length <= 64 &&
value.every(
(binding) =>
binding &&
typeof binding === "object" &&
typeof (binding as DesktopGlobalHotkeyBinding).id === "string" &&
/^[a-z0-9.-]+$/i.test((binding as DesktopGlobalHotkeyBinding).id) &&
(binding as DesktopGlobalHotkeyBinding).id.length > 0 &&
(binding as DesktopGlobalHotkeyBinding).id.length <= 128 &&
typeof (binding as DesktopGlobalHotkeyBinding).accelerator ===
"string" &&
(binding as DesktopGlobalHotkeyBinding).accelerator.length > 0 &&
(binding as DesktopGlobalHotkeyBinding).accelerator.length <= 128,
)
);
}
function applyGlobalHotkeyBindings() {
globalShortcut.unregisterAll();
const statuses = Object.fromEntries(
globalHotkeyBindings.map(({ id }) => [id, false]),
);
if (globalHotkeysSuspended) return statuses;
const grouped = new Map<string, string[]>();
for (const { id, accelerator } of globalHotkeyBindings) {
const ids = grouped.get(accelerator) ?? [];
ids.push(id);
grouped.set(accelerator, ids);
}
for (const [accelerator, ids] of grouped) {
let registered = false;
try {
registered = globalShortcut.register(accelerator, () => {
const target = mainWindow;
if (!target || target.isDestroyed()) return;
ids.forEach((id) =>
target.webContents.send(ipcChannels.globalHotkeyTriggered, id),
);
});
} catch (error) {
console.error("Failed to register global hotkey", accelerator, error);
}
ids.forEach((id) => {
statuses[id] = registered;
});
}
return statuses;
}
function setGlobalHotkeyBindings(bindings: unknown) {
if (!validGlobalHotkeyBindings(bindings)) {
throw new Error("Invalid global hotkey bindings.");
}
globalHotkeyBindings = bindings;
return applyGlobalHotkeyBindings();
}
async function createWindow() { async function createWindow() {
const rendererIndex = getRendererIndex(); const rendererIndex = getRendererIndex();
verboseLog("creating main window", { verboseLog("creating main window", {
@ -480,10 +294,6 @@ app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow(); if (BrowserWindow.getAllWindows().length === 0) void createWindow();
}); });
app.on("will-quit", () => {
globalShortcut.unregisterAll();
});
if (verbose) { if (verbose) {
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error); console.error("[tensamin:electron] uncaught exception", error);
@ -500,8 +310,6 @@ async function start() {
verboseLog("app ready"); verboseLog("app ready");
registerIpc(); registerIpc();
registerDisplayMediaHandler(); registerDisplayMediaHandler();
registerMediaPermissionHandler();
initTray(() => mainWindow);
await createWindow(); await createWindow();
} }

View file

@ -1,128 +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 {
const backend =
process.platform === "linux"
? safeStorage.getSelectedStorageBackend()
: process.platform === "darwin"
? "keychain"
: process.platform === "win32"
? "dpapi"
: null;
return {
available:
safeStorage.isEncryptionAvailable() &&
(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,60 +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,24 +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 DesktopGlobalHotkeyBinding,
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: () =>
@ -41,56 +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);
},
},
hotkeys: {
setBindings: (bindings: DesktopGlobalHotkeyBinding[]) =>
ipcRenderer.invoke(ipcChannels.setGlobalHotkeyBindings, bindings),
setSuspended: (suspended: boolean) =>
typeof suspended === "boolean"
? ipcRenderer.invoke(ipcChannels.setGlobalHotkeysSuspended, suspended)
: Promise.reject(new Error("Invalid hotkey suspension state.")),
onTriggered: (callback: (id: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, id: unknown) => {
if (typeof id === "string") callback(id);
};
ipcRenderer.on(ipcChannels.globalHotkeyTriggered, listener);
return () => {
ipcRenderer.removeListener(ipcChannels.globalHotkeyTriggered, listener);
};
},
},
secureStorage: {
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
load: (key: string) =>
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,27 +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 type DesktopGlobalHotkeyBinding = {
id: string;
accelerator: string;
};
export const secureStorageLimits = {
maxKeyBytes: 256,
maxValueBytes: 1024 * 1024,
} as const;
export type ReleaseArtifact = { export type ReleaseArtifact = {
name: string; name: string;
platform: string; platform: string;
@ -76,13 +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",
setGlobalHotkeyBindings: "hotkeys:setBindings",
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
globalHotkeyTriggered: "hotkeys:triggered",
} as const; } as const;

View file

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

Binary file not shown.

Before

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

@ -4,6 +4,10 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./controls": {
"types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx"
},
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
@ -17,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": {
@ -31,7 +35,7 @@
"@tauri-apps/plugin-log": "~2", "@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@methanium/ui": "*", "@tensamin/ui": "*",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

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"
] ]
} }

View file

@ -1,12 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
@ -45,11 +39,6 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. --> <!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity> </activity>
<service
android:name=".MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View file

@ -1,66 +1,20 @@
package net.tensamin.client package net.tensamin.client
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Rect import android.graphics.Rect
import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowManager import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null private var contentChild: android.view.View? = null
private var previousUsableHeight = 0 private var previousUsableHeight = 0
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var mediaWebView: WebView? = null
private var pendingScreenAudio: Boolean? = null
private val screenCaptureLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val includeAudio = pendingScreenAudio
pendingScreenAudio = null
if (includeAudio == null) return@registerForActivityResult
val data = result.data
if (result.resultCode != Activity.RESULT_OK || data == null) {
MobileMediaEvents.emitError("Screen capture permission was denied")
return@registerForActivityResult
}
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) {
emitCameraPermission()
}
private val screenAudioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
launchScreenCaptureIntent()
}
override fun onWebViewCreate(webView: WebView) {
webView.setInitialScale(300)
mediaWebView = webView
MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
@ -76,84 +30,9 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null attachLayoutListener = null
contentRoot = null contentRoot = null
contentChild = null contentChild = null
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
mediaWebView = null
MobileMediaEvents.detach()
super.onDestroy() super.onDestroy()
} }
private fun startScreenShare(includeAudio: Boolean) {
runOnUiThread {
if (pendingScreenAudio != null) {
MobileMediaEvents.emitError("Screen capture permission is already pending")
return@runOnUiThread
}
pendingScreenAudio = includeAudio
if (
includeAudio &&
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} else {
launchScreenCaptureIntent()
}
}
}
private fun launchScreenCaptureIntent() {
val manager = getSystemService(MediaProjectionManager::class.java)
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
}
private fun stopScreenShare() {
runOnUiThread {
pendingScreenAudio = null
MediaProjectionService.stop(this)
}
}
private fun requestCameraPermission() {
runOnUiThread {
cameraPermissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
)
}
}
private fun emitCameraPermission() {
val detail = JSONObject()
.put(
"camera",
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
.put(
"microphone",
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED,
)
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
}
private inner class MobileMediaJavascriptInterface {
@JavascriptInterface
fun startScreenShare(includeAudio: Boolean) {
this@MainActivity.startScreenShare(includeAudio)
}
@JavascriptInterface
fun stopScreenShare() {
this@MainActivity.stopScreenShare()
}
@JavascriptInterface
fun requestCameraPermission() {
this@MainActivity.requestCameraPermission()
}
}
private fun installKeyboardResizeWorkaround() { private fun installKeyboardResizeWorkaround() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content) val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
contentRoot = content contentRoot = content

View file

@ -1,348 +0,0 @@
package net.tensamin.client
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.media.ImageReader
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.util.Base64
import android.util.DisplayMetrics
import androidx.core.content.ContextCompat
import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicBoolean
import org.json.JSONObject
class MediaProjectionService : Service() {
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var audioRecord: AudioRecord? = null
private var audioThread: Thread? = null
private val captureActive = AtomicBoolean(false)
private var lastFrameAt = 0L
private val projectionCallback = object : MediaProjection.Callback() {
override fun onStop() {
stopCapture(stopProjection = false, emitStopped = true)
stopSelf()
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopCapture(stopProjection = true, emitStopped = true)
stopSelf()
return START_NOT_STICKY
}
val permissionData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA)
}
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE) ?: Int.MIN_VALUE
if (permissionData == null || resultCode == Int.MIN_VALUE) {
MobileMediaEvents.emitError("Screen capture permission data is missing")
stopSelf()
return START_NOT_STICKY
}
val includeAudio = intent?.getBooleanExtra(EXTRA_INCLUDE_AUDIO, false) ?: false
try {
startForegroundNotification()
startCapture(resultCode, permissionData, includeAudio)
} catch (error: Throwable) {
stopCapture(stopProjection = true, emitStopped = false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
MobileMediaEvents.emitError(error.message ?: "Unable to start screen capture")
}
return START_NOT_STICKY
}
override fun onDestroy() {
stopCapture(stopProjection = true, emitStopped = true)
super.onDestroy()
}
private fun startForegroundNotification() {
val notificationManager = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Screen sharing",
NotificationManager.IMPORTANCE_LOW,
),
)
}
val stopIntent = Intent(this, MediaProjectionService::class.java).setAction(ACTION_STOP)
val stopPendingIntent = PendingIntent.getService(
this,
0,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
android.app.Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
} else {
android.app.Notification.Builder(this)
}
.setSmallIcon(android.R.drawable.ic_menu_share)
.setContentTitle("Tensamin is sharing your screen")
.setContentText("Tap Stop to end screen sharing")
.setOngoing(true)
.setCategory(android.app.Notification.CATEGORY_SERVICE)
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("DEPRECATION")
private fun startCapture(resultCode: Int, permissionData: Intent, includeAudio: Boolean) {
stopCapture(stopProjection = true, emitStopped = false)
val manager = getSystemService(MediaProjectionManager::class.java)
val newProjection = manager.getMediaProjection(resultCode, permissionData)
?: error("Android did not provide a screen capture session")
projection = newProjection
val thread = HandlerThread("tensamin-screen-capture").also { it.start() }
captureThread = thread
val handler = Handler(thread.looper)
newProjection.registerCallback(projectionCallback, handler)
val metrics = DisplayMetrics()
getSystemService(android.view.WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
val displayWidth = metrics.widthPixels
val displayHeight = metrics.heightPixels
val width = minOf(displayWidth, MAX_FRAME_WIDTH)
val height = (displayHeight.toLong() * width / displayWidth).toInt()
val density = metrics.densityDpi
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
imageReader = reader
reader.setOnImageAvailableListener({ source -> captureFrame(source, width, height) }, handler)
virtualDisplay = newProjection.createVirtualDisplay(
"Tensamin screen sharing",
width,
height,
density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
reader.surface,
null,
handler,
)
captureActive.set(true)
val audioStarted = includeAudio && startAudioCapture(newProjection)
MobileMediaEvents.emit(
"tensamin-mobile-screen-started",
JSONObject().put("includeAudio", audioStarted),
)
}
private fun captureFrame(source: ImageReader, width: Int, height: Int) {
val image = source.acquireLatestImage() ?: return
try {
val now = System.currentTimeMillis()
if (!captureActive.get() || now - lastFrameAt < FRAME_INTERVAL_MS) return
lastFrameAt = now
val plane = image.planes[0]
val paddedWidth = plane.rowStride / plane.pixelStride
val paddedBitmap = Bitmap.createBitmap(paddedWidth, height, Bitmap.Config.ARGB_8888)
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
val croppedBitmap = if (paddedWidth == width) {
paddedBitmap
} else {
Bitmap.createBitmap(paddedBitmap, 0, 0, width, height).also { paddedBitmap.recycle() }
}
val outputBitmap = croppedBitmap
val bytes = ByteArrayOutputStream()
outputBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, bytes)
val outputWidth = outputBitmap.width
val outputHeight = outputBitmap.height
outputBitmap.recycle()
MobileMediaEvents.emit(
"tensamin-mobile-screen-frame",
JSONObject()
.put("data", Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP))
.put("mimeType", "image/jpeg")
.put("width", outputWidth)
.put("height", outputHeight),
)
} catch (error: Throwable) {
if (captureActive.get()) {
MobileMediaEvents.emitError(error.message ?: "Unable to read a screen frame")
}
} finally {
image.close()
}
}
private fun startAudioCapture(activeProjection: MediaProjection): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
MobileMediaEvents.emitError("System audio capture requires Android 10 or newer")
return false
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
MobileMediaEvents.emitError("Microphone permission is required for system audio capture")
return false
}
return try {
val format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(AUDIO_SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
val configuration = AudioPlaybackCaptureConfiguration.Builder(activeProjection)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.build()
val minimumBuffer = AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
check(minimumBuffer > 0) { "Android could not allocate a system audio buffer" }
val bufferSize = maxOf(minimumBuffer * 2, AUDIO_BATCH_BYTES)
val record = AudioRecord.Builder()
.setAudioFormat(format)
.setAudioPlaybackCaptureConfig(configuration)
.setBufferSizeInBytes(bufferSize)
.build()
check(record.state == AudioRecord.STATE_INITIALIZED) {
"Android could not initialize system audio capture"
}
audioRecord = record
record.startRecording()
audioThread = Thread({ readAudio(record) }, "tensamin-audio-capture").also { it.start() }
true
} catch (error: Throwable) {
audioRecord?.release()
audioRecord = null
MobileMediaEvents.emitError(error.message ?: "Unable to capture system audio")
false
}
}
private fun readAudio(record: AudioRecord) {
val buffer = ByteArray(AUDIO_BATCH_BYTES)
while (captureActive.get() && !Thread.currentThread().isInterrupted) {
val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
if (read > 0 && captureActive.get()) {
MobileMediaEvents.emit(
"tensamin-mobile-screen-audio",
JSONObject()
.put("data", Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP))
.put("sampleRate", AUDIO_SAMPLE_RATE)
.put("channelCount", 1)
.put("encoding", "pcm16le"),
)
} else if (read < 0 && captureActive.get()) {
MobileMediaEvents.emitError("System audio capture stopped with code $read")
return
}
}
}
@Synchronized
private fun stopCapture(stopProjection: Boolean, emitStopped: Boolean) {
val wasActive = captureActive.getAndSet(false)
val record = audioRecord
audioRecord = null
try {
record?.stop()
} catch (_: IllegalStateException) {
}
record?.release()
audioThread?.interrupt()
audioThread = null
imageReader?.setOnImageAvailableListener(null, null)
virtualDisplay?.release()
virtualDisplay = null
imageReader?.close()
imageReader = null
val oldProjection = projection
projection = null
oldProjection?.unregisterCallback(projectionCallback)
if (stopProjection) oldProjection?.stop()
captureThread?.quitSafely()
captureThread = null
lastFrameAt = 0L
if (wasActive && emitStopped) {
MobileMediaEvents.emit("tensamin-mobile-screen-stopped")
}
}
companion object {
private const val ACTION_STOP = "net.tensamin.client.STOP_SCREEN_SHARE"
private const val EXTRA_RESULT_CODE = "resultCode"
private const val EXTRA_PERMISSION_DATA = "permissionData"
private const val EXTRA_INCLUDE_AUDIO = "includeAudio"
private const val NOTIFICATION_CHANNEL_ID = "screen-sharing"
private const val NOTIFICATION_ID = 7314
private const val FRAME_INTERVAL_MS = 75L
private const val MAX_FRAME_WIDTH = 1280
private const val JPEG_QUALITY = 72
private const val AUDIO_SAMPLE_RATE = 48_000
private const val AUDIO_BATCH_BYTES = 9_600
fun start(context: Context, resultCode: Int, data: Intent, includeAudio: Boolean) {
val intent = Intent(context, MediaProjectionService::class.java)
.putExtra(EXTRA_RESULT_CODE, resultCode)
.putExtra(EXTRA_PERMISSION_DATA, data)
.putExtra(EXTRA_INCLUDE_AUDIO, includeAudio)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.startService(
Intent(context, MediaProjectionService::class.java).setAction(ACTION_STOP),
)
}
}
}

View file

@ -1,29 +0,0 @@
package net.tensamin.client
import android.webkit.WebView
import java.lang.ref.WeakReference
import org.json.JSONObject
object MobileMediaEvents {
private var webView = WeakReference<WebView>(null)
fun attach(value: WebView) {
webView = WeakReference(value)
}
fun detach() {
webView.clear()
}
fun emitError(message: String) {
emit("tensamin-mobile-screen-error", JSONObject().put("message", message))
}
fun emit(name: String, detail: JSONObject = JSONObject()) {
val view = webView.get() ?: return
val script = "window.dispatchEvent(new CustomEvent(" +
JSONObject.quote(name) +
", { detail: " + detail.toString() + " }));"
view.post { view.evaluateJavascript(script, null) }
}
}

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": {

View file

@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@methanium/ui"; import { useIsMobile } from "@tensamin/ui";
type DeeplinkContextValue = { type DeeplinkContextValue = {
deeplinks: readonly string[]; deeplinks: readonly string[];

View file

@ -3,7 +3,7 @@ import {
Format, Format,
requestPermissions, requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner"; } from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@methanium/ui"; import { Button } from "@tensamin/ui";
import { toast } from "@tensamin/shared/log"; import { toast } from "@tensamin/shared/log";

2
apps/web/.gitignore vendored
View file

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

View file

@ -4,130 +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/hotkeys": "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:*",
"@methanium/ui": "*", "@tensamin/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"@tensamin/notifications": "workspace:*", "@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "workspace:*",
"aria-hidden": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie-es": "^3.0.0",
"d3-array": "^3.1.6",
"d3-color": "^3.1.0",
"d3-ease": "^3.0.1",
"d3-format": "^3.1.0",
"d3-interpolate": "^3.0.1",
"d3-path": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-time-format": "^4.1.0",
"d3-timer": "^3.0.1",
"date-fns": "^4.4.0",
"decimal.js-light": "^2.5.1",
"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": {

Binary file not shown.

Binary file not shown.

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