Compare commits
1
.envrc
|
|
@ -1 +0,0 @@
|
|||
use flake
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -1,6 +1,3 @@
|
|||
node_modules
|
||||
releases
|
||||
.fallow
|
||||
.direnv
|
||||
keystore.jks
|
||||
keystore.properties
|
||||
.fallow/
|
||||
|
|
|
|||
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "mtp-type-maps"]
|
||||
path = mtp-type-maps
|
||||
url = https://git.methanium.net/tensamin/mtp-type-maps
|
||||
25
LICENSE
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
# Information
|
||||
|
||||
All dev releases get deleted upon creation of the latest prod release.
|
||||
6
TODO
|
|
@ -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
|
||||
2
apps/electron/.gitignore
vendored
|
|
@ -1,2 +1,2 @@
|
|||
dist
|
||||
release
|
||||
release
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
61
apps/electron/flake.lock
generated
Normal 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
|
|
@ -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'
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
||||
3
apps/tauri/.gitignore
vendored
|
|
@ -24,3 +24,6 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
.android
|
||||
|
||||
/src-tauri/gen/android/keystore.properties
|
||||
/src-tauri/gen/android/keystore.jks
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
96
apps/tauri/flake.lock
generated
Normal 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
|
|
@ -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}"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -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 Width: | Height: | Size: 2.8 KiB |
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
2400
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -15,19 +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"] }
|
||||
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]
|
||||
|
|
@ -35,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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@
|
|||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||