Compare commits

..
406 changed files with 10084 additions and 34599 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -5,116 +5,74 @@ on:
paths-ignore:
- flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs:
build-web:
runs-on: nixos
runs-on: docker
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#electron --command pnpm run build:packages
run: bun run build:packages
- 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
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:
runs-on: nixos
runs-on: docker
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#tauri --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#tauri --command pnpm run build:packages
run: bun run build:packages
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
nix profile add nixpkgs#gnused
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
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
- name: Build mobile
run: nix develop .#tauri --command pnpm run build:mobile
run: bun run build:mobile
- name: Upload mobile artifact
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
build-desktop:
runs-on: nixos
runs-on: docker
strategy:
matrix:
target: [linux]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#electron --command pnpm run build:packages
run: bun run build:packages
- name: Set Electron dev version
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)"
DEV_VERSION="$VERSION-dev-$SHORT_SHA"
@ -161,15 +117,9 @@ jobs:
pkg.version = process.env.DEV_VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
EOF
- name: Build Electron desktop
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
cd apps/electron
pnpm run package:raw
EOF
run: bun run build:desktop
- name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -178,22 +128,22 @@ jobs:
path: apps/electron/release/
release:
runs-on: nixos
runs-on: docker
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3
@ -210,27 +160,21 @@ jobs:
- name: Read version and hash
id: version
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")"
SHORT_SHA="$(git rev-parse --short HEAD)"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
EOF
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" pnpm run copy-releases
EOF
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create pre-release and upload files
env:
@ -241,7 +185,6 @@ jobs:
TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
test -d releases
@ -330,4 +273,3 @@ jobs:
-H "Authorization: token $TOKEN" \
-F "attachment=@$file"
done
EOF

View file

@ -6,116 +6,74 @@ on:
paths-ignore:
- flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs:
build-web:
runs-on: nixos
runs-on: docker
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#electron --command pnpm run build:packages
run: bun run build:packages
- 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
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:
runs-on: nixos
runs-on: docker
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#tauri --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#tauri --command pnpm run build:packages
run: bun run build:packages
- name: Setup Android Keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
nix profile add nixpkgs#gnused
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
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
bun -e "const content = process.env.KEYSTORE_PROPERTIES.replace(/\\n/g, '\n').replace(/\r/g, '').split('\n').map(l => l.trim()).filter(l => l).join('\n'); require('fs').writeFileSync('keystore.properties', content)"
- name: Build mobile
run: nix develop .#tauri --command pnpm run build:mobile
run: bun run build:mobile
- name: Upload mobile artifact
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
build-desktop:
runs-on: nixos
runs-on: docker
strategy:
matrix:
target: [linux]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq fakeroot dpkg rpm xz-utils
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Copy licenses
run: nix develop .#electron --command pnpm run copy-licenses
run: bun run copy-licenses
- name: Build packages
run: nix develop .#electron --command pnpm run build:packages
run: bun run build:packages
- name: Set Electron prod version
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")"
export VERSION
node -e '
@ -160,15 +116,9 @@ jobs:
pkg.version = process.env.VERSION;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
EOF
- name: Build Electron desktop
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
cd apps/electron
pnpm run package:raw
EOF
run: bun run build:desktop
- name: Upload desktop artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
@ -177,20 +127,20 @@ jobs:
path: apps/electron/release/
release:
runs-on: nixos
runs-on: docker
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
run: bun install --frozen-lockfile
- name: Download mobile artifact
uses: https://data.forgejo.org/actions/download-artifact@v3
@ -207,24 +157,18 @@ jobs:
- name: Read version
id: version
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
VERSION="$(node -p "require('./package.json').version")"
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "tag=$VERSION" >> "$FORGEJO_OUTPUT"
EOF
- name: Copy releases
env:
TENSAMIN_RELEASE_VERSION: ${{ steps.version.outputs.tag }}
TENSAMIN_RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -euo pipefail
ASSET_BASE_URL="${{ forgejo.api_url }}"
ASSET_BASE_URL="${ASSET_BASE_URL%/api/v1}/${{ forgejo.repository }}/releases/download/${{ steps.version.outputs.tag }}"
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" pnpm run copy-releases
EOF
FORGEJO_RELEASE_ASSET_BASE_URL="$ASSET_BASE_URL" bun --bun run copy-releases
- name: Create release and upload files
env:
@ -234,7 +178,6 @@ jobs:
SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
test -d releases
@ -288,18 +231,18 @@ jobs:
-H "Authorization: token $TOKEN" \
-F "attachment=@$file"
done
EOF
- name: Delete dev releases
- name: Delete dev releases for prod version
env:
TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }}
REPO: ${{ forgejo.repository }}
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
PAGE=1
PREFIX="$TAG-dev-"
DELETE_RELEASES=delete-dev-releases.tsv
: > "$DELETE_RELEASES"
@ -314,7 +257,8 @@ jobs:
test "$COUNT" -gt 0 || break
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"
PAGE="$((PAGE + 1))"
@ -327,13 +271,11 @@ jobs:
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/$release_id"
done < "$DELETE_RELEASES"
EOF
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)"
@ -382,4 +324,3 @@ jobs:
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push origin HEAD:dev
EOF

5
.gitignore vendored
View file

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

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

View file

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

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

View file

@ -5,7 +5,6 @@ import {
app,
BrowserWindow,
desktopCapturer,
globalShortcut,
ipcMain,
session,
shell,
@ -13,29 +12,14 @@ import {
import { checkForUpdates } from "./updates.js";
import {
ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareAudioOutput,
type DesktopScreenShareCapabilities,
} 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 verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | 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) {
app.commandLine.appendSwitch("enable-logging", "stderr");
@ -43,20 +27,6 @@ if (verbose) {
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 (
process.platform === "linux" &&
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() {
verboseLog("registering ipc handlers");
@ -241,55 +188,6 @@ function registerIpc() {
);
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
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, () => {
verboseLog("window: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() {
const rendererIndex = getRendererIndex();
verboseLog("creating main window", {
@ -480,10 +294,6 @@ app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
});
app.on("will-quit", () => {
globalShortcut.unregisterAll();
});
if (verbose) {
process.on("uncaughtException", (error) => {
console.error("[tensamin:electron] uncaught exception", error);
@ -500,8 +310,6 @@ async function start() {
verboseLog("app ready");
registerIpc();
registerDisplayMediaHandler();
registerMediaPermissionHandler();
initTray(() => mainWindow);
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 {
ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareSource,
secureStorageLimits,
} from "../shared/ipc.js";
import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
function windowAction(channel: string) {
return () => ipcRenderer.invoke(channel);
}
function validKey(key: string) {
return (
typeof key === "string" &&
key.length > 0 &&
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
);
}
const desktopApi = {
media: {
listScreenShareSources: () =>
@ -41,56 +27,6 @@ const desktopApi = {
updates: {
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: {
minimize: () => windowAction(ipcChannels.minimizeWindow),
maximize: () => windowAction(ipcChannels.maximizeWindow),

View file

@ -20,27 +20,6 @@ export type DesktopScreenShareCapabilities = {
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 = {
name: string;
platform: string;
@ -76,13 +55,4 @@ export const ipcChannels = {
closeWindow: "window:close",
getVersion: "app:getVersion",
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;

View file

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

View file

@ -24,3 +24,6 @@ dist-ssr
*.sw?
.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/) -->
<svg
width="90.476906mm"
height="90.476906mm"
viewBox="0 0 90.476906 90.476906"
width="90mm"
height="90mm"
viewBox="0 0 90 90"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="logo_square_outline_gen.svg"
sodipodi:docname="logo_raw.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
@ -24,32 +24,16 @@
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2"
inkscape:cx="191.5"
inkscape:cy="117.25"
inkscape:window-width="2500"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:zoom="0.99999999"
inkscape:cx="450"
inkscape:cy="291.5"
inkscape:window-width="1223"
inkscape:window-height="1369"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><inkscape:path-effect
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="defs1"><linearGradient
id="swatch15"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
@ -67,7 +51,7 @@
id="stop3" /></linearGradient><linearGradient
id="swatch2"
inkscape:swatch="solid"><stop
style="stop-color:#b8f8ff;stop-opacity:1;"
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop2" /></linearGradient><linearGradient
id="swatch1"
@ -200,23 +184,13 @@
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
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
y2="148.5" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-281.06837,-119.33314)"
><g
transform="translate(-62.941767,-104.13688)"><g
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"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path
@ -239,19 +213,11 @@
id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><path
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
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
id="g3"
inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"
transform="matrix(0.98854883,0,0,0.98930922,222.52896,17.64618)"><g
transform="translate(3.3653788,0.84760028)"><g
id="g1"
inkscape:label="outline"
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -4,6 +4,10 @@
"version": "0.0.0",
"type": "module",
"exports": {
"./controls": {
"types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx"
},
"./deeplinkHandler": {
"types": "./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}",
"start-adb:mobile:raw": "adb devices",
"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",
"start-adb:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run start-adb:mobile:raw; else pnpm run start-adb:mobile:raw; fi",
"build:mobile": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run build:mobile:raw; else pnpm run build:mobile:raw; fi",
"gen-icons": "tauri icon ./logo.json && node scripts/sync-electron-icons.ts",
"format": "pnpm exec prettier --write .",
"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; then nix develop --command bun start-adb:mobile:raw; else bun start-adb: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 && bun scripts/sync-electron-icons.ts",
"format": "bunx prettier --write .",
"lint": "eslint src"
},
"dependencies": {
@ -31,7 +35,7 @@
"@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*",
"@methanium/ui": "*",
"@tensamin/ui": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},

View file

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

View file

@ -1,53 +0,0 @@
import { spawnSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
const devicesResult = spawnSync("adb", ["devices", "-l"], {
encoding: "utf8",
});
if (devicesResult.status !== 0) {
process.stderr.write(devicesResult.stderr);
process.exit(devicesResult.status ?? 1);
}
const devices = devicesResult.stdout
.split("\n")
.slice(1)
.map((line) => line.trim())
.filter((line) => /\sdevice(?:\s|$)/.test(line));
if (devices.length === 0) {
console.error("No connected ADB devices found.");
process.exit(1);
}
let selectedDevice = devices[0];
if (devices.length > 1) {
console.log("Select a device:");
devices.forEach((device, index) => console.log(`${index + 1}) ${device}`));
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await readline.question("Device: ");
readline.close();
const selectedIndex = Number(answer) - 1;
if (!Number.isInteger(selectedIndex) || !devices[selectedIndex]) {
console.error("Invalid device selection.");
process.exit(1);
}
selectedDevice = devices[selectedIndex];
}
const serial = selectedDevice.split(/\s+/, 1)[0];
const uninstallResult = spawnSync(
"adb",
["-s", serial, "uninstall", "net.tensamin.client.dev"],
{ stdio: "inherit" },
);
process.exit(uninstallResult.status ?? 1);

File diff suppressed because it is too large Load diff

View file

@ -15,20 +15,16 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
tauri-build = { version = "2", features = [] }
[dependencies]
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
mtp = { git = "https://git.methanium.net/methanium/mtp.git", rev = "d10266198d62ca9e14a8b1a55d2ab108b24e756e", features = ["client", "crypto"] }
mtp-transport = { git = "https://git.methanium.net/methanium/mtp.git", rev = "a81ac4efca46fbf0e708c1a0f184735fc9bf7fec" }
webpki-root-certs = "1"
tauri-plugin-deep-link = "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"
[target.'cfg(target_os = "android")'.dependencies.tauri]
@ -36,12 +32,10 @@ version = "2"
features = []
default-features = true
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.21"
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
tauri-plugin-app-events = "0.2"
[patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"
branch = "feat/cef"

View file

@ -6,9 +6,20 @@
"main"
],
"permissions": [
"core:default",
"opener: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",
"deep-link: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

@ -9,10 +9,15 @@
],
"permissions": [
"deep-link:default",
"app-events:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"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

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

View file

@ -1,15 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@ -48,30 +39,6 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<service
android:name=".MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".MtpForegroundService"
android:exported="false"
android:stopWithTask="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the user-enabled encrypted messaging connection and receives incoming messages" />
</service>
<receiver
android:name=".MtpBootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View file

@ -1,88 +1,28 @@
package net.tensamin.client
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Rect
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null
private var previousUsableHeight = 0
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(290)
mediaWebView = webView
MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
}
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this)
}
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround()
}
override fun onResume() {
super.onResume()
NativeMtpBridge.nativeSetUiState(true)
}
override fun onPause() {
NativeMtpBridge.nativeSetUiState(false)
super.onPause()
}
override fun onDestroy() {
attachLayoutListener?.let { listener ->
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
@ -90,84 +30,9 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null
contentRoot = null
contentChild = null
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
mediaWebView = null
MobileMediaEvents.detach()
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() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.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) }
}
}

View file

@ -1,17 +0,0 @@
package net.tensamin.client
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class MtpBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (
intent.action == Intent.ACTION_BOOT_COMPLETED &&
MtpSecureStore.isEnabled(context) &&
MtpSecureStore.hasConfig(context)
) {
NativeMtpBridge.startService(context)
}
}
}

View file

@ -1,120 +0,0 @@
package net.tensamin.client
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class MtpForegroundService : Service() {
private var started = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
MtpSecureStore.setEnabled(this, false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
if (started) return START_STICKY
createChannel(this)
val notification = buildNotification(this, "Connecting")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
val config = MtpSecureStore.loadConfig(this)
if (config == null || !MtpSecureStore.isEnabled(this)) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
try {
NativeMtpBridge.nativeAttach(applicationContext)
NativeMtpBridge.nativeStart(config)
started = true
NativeMtpBridge.log(2, "Started MTP foreground service")
} catch (error: Throwable) {
NativeMtpBridge.log(0, "Failed to start MTP foreground service", error)
updateNotification(this, "Connection failed")
}
return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
startService(Intent(this, MtpForegroundService::class.java))
}
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop()
super.onDestroy()
}
companion object {
private const val CHANNEL_ID = "tensamin-connection"
private const val NOTIFICATION_ID = 2201
private const val ACTION_STOP = "net.tensamin.client.STOP_MTP"
fun updateNotification(context: Context, status: String) {
if (!MtpSecureStore.isEnabled(context)) return
createChannel(context)
context.getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification(context, status))
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Background connection",
NotificationManager.IMPORTANCE_LOW,
).apply { description = "Keeps Tensamin connected for incoming messages" },
)
}
private fun buildNotification(context: Context, status: String): Notification {
val openIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val stopIntent = PendingIntent.getService(
context,
1,
Intent(context, MtpForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle("Tensamin")
.setContentText(status)
.setContentIntent(openIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopIntent)
.build()
}
}
}

View file

@ -1,74 +0,0 @@
package net.tensamin.client
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
object MtpSecureStore {
private const val KEY_ALIAS = "tensamin-mtp-config"
private const val PREFS = "tensamin-mtp"
private const val CONFIG = "config"
private const val ENABLED = "enabled"
fun saveConfig(context: Context, config: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val encrypted = cipher.doFinal(config.toByteArray(Charsets.UTF_8))
val payload = Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
preferences(context).edit().putString(CONFIG, payload).apply()
}
fun loadConfig(context: Context): String? {
val payload = preferences(context).getString(CONFIG, null) ?: return null
return runCatching {
val bytes = Base64.decode(payload, Base64.NO_WRAP)
require(bytes.size > 12)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
getOrCreateKey(),
GCMParameterSpec(128, bytes.copyOfRange(0, 12)),
)
String(cipher.doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
}.getOrNull()
}
fun hasConfig(context: Context): Boolean = loadConfig(context) != null
fun setEnabled(context: Context, enabled: Boolean) {
preferences(context).edit().putBoolean(ENABLED, enabled).apply()
}
fun isEnabled(context: Context): Boolean =
preferences(context).getBoolean(ENABLED, false)
private fun preferences(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return generator.generateKey()
}
}

View file

@ -1,161 +0,0 @@
package net.tensamin.client
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.annotation.Keep
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
@Keep
object NativeMtpBridge {
private const val MESSAGE_CHANNEL = "tensamin-messages"
init {
System.loadLibrary("mobile_lib")
}
@JvmStatic external fun nativeAttach(context: Context)
@JvmStatic external fun nativeStart(config: String)
@JvmStatic external fun nativeStop()
@JvmStatic external fun nativeSetUiState(visible: Boolean)
@JvmStatic external fun nativeLog(level: Int, message: String, details: String)
fun log(level: Int, message: String, error: Throwable? = null) {
val details = error?.stackTraceToString().orEmpty()
Log.println(if (level == 0) Log.ERROR else Log.INFO, "TensaminAndroid", "$message $details")
nativeLog(level, message, details)
}
fun storeConfig(context: Context, config: String) {
try {
MtpSecureStore.saveConfig(context, config)
if (MtpSecureStore.isEnabled(context)) startService(context)
log(2, "Stored native MTP credentials")
} catch (error: Throwable) {
log(0, "Failed to store native MTP credentials", error)
throw error
}
}
fun hasConfig(context: Context): Boolean = MtpSecureStore.hasConfig(context)
fun setServiceEnabled(context: Context, enabled: Boolean) {
MtpSecureStore.setEnabled(context, enabled)
if (enabled && MtpSecureStore.hasConfig(context)) startService(context) else stopService(context)
}
fun isIgnoringBatteryOptimizations(context: Context): Boolean =
context.getSystemService(PowerManager::class.java)
.isIgnoringBatteryOptimizations(context.packageName)
fun requestBatteryExemption(context: Context) {
val intent = Intent(
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:${context.packageName}"),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
fun startService(context: Context) {
ContextCompat.startForegroundService(
context,
Intent(context, MtpForegroundService::class.java),
)
}
fun stopService(context: Context) {
if (!context.stopService(Intent(context, MtpForegroundService::class.java))) nativeStop()
}
fun updateServiceStatus(context: Context, status: String) {
MtpForegroundService.updateNotification(context, status)
}
fun postMessageNotification(
context: Context,
senderId: Long,
sender: String,
body: String,
avatar: ByteArray,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
MESSAGE_CHANNEL,
"Messages",
NotificationManager.IMPORTANCE_HIGH,
).apply { description = "Incoming Tensamin messages" },
)
}
val openIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse("tensamin://chat?id=$senderId"),
context,
MainActivity::class.java,
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(
context,
senderId.hashCode(),
openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val avatarBitmap = avatar.takeIf { it.isNotEmpty() }?.let {
BitmapFactory.decodeByteArray(it, 0, it.size)
}
val avatarIcon = avatarBitmap?.let(IconCompat::createWithAdaptiveBitmap)
val person = Person.Builder()
.setName(sender)
.setKey(senderId.toString())
.setIcon(avatarIcon)
.build()
val shortcutId = "chat-$senderId"
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
.setShortLabel(sender)
.setLongLived(true)
.setPerson(person)
.setIntent(openIntent)
.apply { if (avatarIcon != null) setIcon(avatarIcon) }
.build()
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
val style = NotificationCompat.MessagingStyle(
Person.Builder().setName("You").build(),
).addMessage(body, System.currentTimeMillis(), person)
val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL)
.setSmallIcon(R.drawable.ic_notification_small)
.setContentTitle(sender)
.setContentText(body)
.setStyle(style)
.setShortcutId(shortcutId)
.setLocusId(LocusIdCompat(shortcutId))
.setLargeIcon(avatarBitmap)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
context.getSystemService(NotificationManager::class.java)
.notify(senderId.hashCode(), notification)
}
fun cancelMessageNotification(context: Context, senderId: Long) {
context.getSystemService(NotificationManager::class.java)
.cancel(senderId.hashCode())
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

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

View file

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

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

@ -1,14 +1,9 @@
mod mtp_backend;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.level(tauri_plugin_log::log::LevelFilter::Info)
.build(),
)
.plugin(tauri_plugin_notification::init());
.plugin(tauri_plugin_log::Builder::new().level(tauri_plugin_log::log::LevelFilter::Info).build())
.plugin(tauri_plugin_notification::init())
.plugin(ttp_tauri::init());
let builder = builder
.plugin(tauri_plugin_deep_link::init())
@ -17,21 +12,11 @@ pub fn run() {
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
let app = builder
.invoke_handler(tauri::generate_handler![
mtp_backend::mtp_request,
mtp_backend::mtp_status,
mtp_backend::mtp_store_credentials,
mtp_backend::mtp_has_credentials,
mtp_backend::mtp_load_keyring,
mtp_backend::mtp_set_enabled,
mtp_backend::mtp_set_ui_visible,
mtp_backend::mtp_post_message_notification,
mtp_backend::mtp_is_ignoring_battery_optimizations,
mtp_backend::mtp_request_battery_exemption,
])
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_app_events::init());
if let Err(error) = builder
.setup(|_app| {
mtp_backend::manager().attach_app(_app.handle().clone());
#[cfg(any(target_os = "linux", windows))]
{
use tauri_plugin_deep_link::DeepLinkExt;
@ -46,18 +31,9 @@ pub fn run() {
}
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|_app, event| {
#[cfg(target_os = "android")]
if let tauri::RunEvent::ExitRequested {
api, code: None, ..
} = event
{
if mtp_backend::manager().is_enabled() {
api.prevent_exit();
}
}
});
.run(tauri::generate_context!())
{
eprintln!("error while running tauri application: {error}");
panic!("error while running tauri application: {error}");
}
}

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