Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2ac4813a | |||
|
0c0a341f1e |
|||
|
f74ca52f4f |
|||
|
|
94f11f60f6 |
||
|
|
f5461bbd42 |
||
|
7c9d9529b4 |
|||
|
8a04b327fc |
|||
|
0a304e44f2 |
|||
|
094cb910aa |
|||
|
cf17365c8f |
|||
|
aa34bd962b |
|||
|
1d1b304bcf |
|||
|
|
0fbedf6733 | ||
|
96b81d30b1 |
|||
|
edcf0e15dd |
|||
|
5056f084b4 |
|||
|
ede0199858 |
|||
|
88a094c8bf |
|||
|
e43e03ff12 |
|||
|
97270abed8 |
|||
|
2a55c87df1 |
|||
|
|
57c7ceb27a | ||
|
|
d0918ff384 | ||
|
caa7d65b84 |
|||
| 0ac20f09e4 | |||
|
7b36218ffa |
|||
| 73db7e6b2b | |||
|
6e977bca7e |
|||
|
eaf3f61ebd |
|||
|
6f4cc62530 |
|||
|
eaeff1b8d5 |
|||
|
4016181db9 |
|||
| 2299555fe6 | |||
| f50b4eaebe | |||
|
825be4eb33 |
|||
|
1c90a0c859 |
|||
| 6dee7e8dcc | |||
| 41258a8483 | |||
| 746f0cc9a4 | |||
| c1baf3576d | |||
| 7ffbab6b08 | |||
| f1af0f210d | |||
| a40bf3a2c2 | |||
|
dbb407606b |
|||
| f763af13be | |||
|
f83e71be6b |
|||
|
b7c94123fa |
|||
|
32671f32fd |
|||
|
2b4207f8fa |
|||
|
5f44b89043 |
|||
|
31ff5a542f |
|||
|
99c38162a6 |
|||
|
ef24ea4ca0 |
|||
|
45d39cb47c |
|||
|
c182f2e9ac |
|||
|
ec019a4dff |
|||
|
b76143e2e2 |
|||
| 01b68300ab | |||
| 3954d776ce | |||
| 860fd2ec08 | |||
| dfb82373c6 | |||
| 927a719281 | |||
| 7f64659e23 | |||
| 35769b1aae | |||
| a731f8ee42 | |||
|
|
beafb623ba | ||
|
|
ac1bdf5ac2 | ||
|
22a4cd2f0e |
|||
|
3725587232 |
|||
| f0dcfbc63e | |||
| 00ff0b00d4 | |||
| 4d72469806 | |||
|
|
9ae7dbe35e | ||
|
|
2c405af72d | ||
|
652dab0351 |
|||
|
1ea13acc3b |
|||
| 29e80d6d02 | |||
| f3ca5d637c | |||
| 565f39c255 | |||
| 5090b67deb | |||
|
2061c3c393 |
|||
|
83427a344e |
|||
|
6f9ae31f7d |
|||
|
c442803412 |
|||
|
d59f9455bf |
|||
|
6dcec11426 |
|||
|
bda3bdcd0a |
|||
|
8946da05cc |
|||
| 60fbe3c9ec | |||
| 03735f9230 | |||
| 27b60005cc | |||
| 80d74b3ac7 | |||
| 3e80a37566 | |||
| 2f8bf376b7 | |||
| cc8cd9c017 | |||
|
1b17158ad5 |
|||
| bb25ed83ef | |||
| 959f6638ad | |||
| 48f04adfd9 | |||
| 0e2995454e | |||
| acc48c1207 | |||
| d66b404368 | |||
| 5914419aee | |||
| 625314cb5a | |||
| 6c145d293d | |||
| 4e97368f2f | |||
| d07202386f | |||
| a71a9effae | |||
| 7b3367cff0 | |||
| f14bfe6233 | |||
| 2faf100fa2 | |||
| faebd27286 | |||
| 5b2ac5315f | |||
| e61be9e5e0 | |||
| d30f7100b1 | |||
| e35fd3d96d | |||
|
f2343297e0 |
|||
|
5a7d5008a3 |
|||
| 51e83b8d9e | |||
| 9d5517fe30 | |||
| a88bda43eb | |||
| 16a288725d | |||
|
d30de8b404 |
|||
|
be8ea09cd6 |
|||
| 8da83a4900 | |||
|
2dc5be85e3 |
|||
| e11f46bd7c | |||
| 2ead5e17be | |||
|
611a4944b6 |
|||
| cf38260a87 | |||
| a6775b1d14 | |||
|
b5c5dd722b |
|||
| 5fb884edb0 | |||
| 52fcf7ca02 | |||
| 014bb0480a | |||
| 3bc4f49678 | |||
|
089fd152de |
|||
|
5044947525 |
|||
|
4a841de073 |
|||
| 43fd0beb8e | |||
| 07e31d1ad6 | |||
|
|
4c2b0e4c99 | ||
|
|
329a35bcfb | ||
| 70932ca135 | |||
| c1b10ef737 | |||
|
336b9464c9 |
|||
|
13fa3d8db7 |
|||
|
4cb38264d0 |
|||
|
560e829cab |
|||
|
ab577283f9 |
|||
|
85633a1c81 |
|||
|
915568f739 |
|||
|
b97f554c32 |
|||
|
|
dcb5d43056 | ||
|
|
cdc8d31505 | ||
| aee2a0963d | |||
|
1fc55ea562 |
|||
|
3ef0380122 |
|||
|
62aaa7c01d |
|||
|
b5ce3c554d |
|||
|
37b2f8747d |
|||
|
d355c83c02 |
|||
|
6f03604c90 |
|||
|
f1874042ba |
|||
|
5a26c2d401 |
|||
|
45e7591e40 |
|||
|
184b85190b |
|||
|
6a5236bafe |
|||
|
53728b7436 |
|||
|
5ece90ef4b |
|||
|
3666902ac9 |
|||
|
676dea2aa4 |
|||
|
a43c05f035 |
|||
|
781a90d2d1 |
|||
|
0dff15cc28 |
|||
|
5602b0a916 |
|||
|
6d865a93e8 |
|||
|
ace7973dff |
|||
|
5e79893137 |
|||
|
cfabda5d28 |
|||
|
25c7ee7c26 |
|||
|
f9f8d5c6c1 |
|||
|
9e037d8500 |
|||
|
17db895203 |
|||
|
bf8f449f03 |
|||
|
b6dfb4d019 |
|||
|
747b88f177 |
|||
|
cda0c48454 |
|||
|
|
d5a05e3a8b | ||
|
|
bf17f59a95 | ||
|
f41cf7cfa4 |
|||
|
9011768a2e |
|||
|
85d97a6969 |
|||
|
9a9dfe54ce |
|||
|
|
bd745a5f13 | ||
|
|
41bea6b107 | ||
| e44863e9cb | |||
| dcca8bd83d | |||
|
|
fd0e06b71e | ||
| 3ce708b86a | |||
| 790a1db788 | |||
| fb095db7a6 | |||
| f898da653b | |||
| 19ef13f068 | |||
| 7ba08090fe | |||
| d57940d1b3 | |||
| c11589079b | |||
| 943203eeaf | |||
| 92e5003989 | |||
| a08c41067e | |||
| 8dd73568ce | |||
| f61a77af31 | |||
| 0be502cc2e | |||
| d9bfdb655d | |||
| e0843a11a6 | |||
| 95a79892c6 | |||
| 76b64886da | |||
| 92dfea7329 | |||
| 27f08e92e5 | |||
| b4fe5edfbb | |||
| 2cd326d0b1 | |||
| ecca5241bc | |||
| 8ddc8db536 | |||
| 35414cdfcb | |||
| cabe83aa98 | |||
| de0a153bce | |||
| 2777ba34ca | |||
| f3ecb8f3dd | |||
| 7d6080edbf | |||
| cd2c2f8167 | |||
| 930663d495 | |||
| 4e69b8ef77 | |||
| fc6d8950d4 | |||
| 1b24e7cda1 | |||
| 1621defe34 | |||
| 82a483d7ab | |||
| 69149b73bd | |||
| 8bff0f503f | |||
| 25f39ac41d | |||
| 4a3be94fef | |||
| 7f75a36d73 | |||
| ee5955fc75 | |||
| 8e831fd993 | |||
| 20018f09a9 | |||
| b1e8af7ec3 | |||
| 05a94c58f9 | |||
| eef6c68426 | |||
| b704e266d6 | |||
| 018571c950 | |||
| 1dd8a979ee | |||
| 70740e31e6 | |||
| 4980ca8f9f | |||
| 692e1da4b0 | |||
| 3c7a28ceb2 | |||
| 10da530e9e | |||
| f81235c68d | |||
| d7aedad9b4 | |||
| d176de6cb1 | |||
| 63abd5697f | |||
| 3f2275179b | |||
| de3150b74b | |||
| 6df912f428 | |||
| 74b152969b | |||
| e3e90d4779 | |||
| 1828191317 | |||
| de09cf4ebd | |||
| 14518978d2 | |||
| 29f1ff2d54 | |||
| cc2f790c62 | |||
| 0e6b235c1b | |||
| b09ef8a997 | |||
| c484a3c303 | |||
| b100967fda | |||
| 6ed9ee1109 | |||
| 31e36b2abc | |||
| 94e9a67f6e | |||
| d3ab472531 | |||
|
|
f02536ca49 | ||
|
|
4a23f8455f | ||
| 37a9f97361 | |||
| 464d779569 | |||
| 3049e090be | |||
| 040d60a35f | |||
| 87e4a158e3 | |||
| 4a0f9e3b58 | |||
| b4662bc1a4 | |||
| 30783f8ded | |||
| 7dc68d1867 | |||
| ff41763d76 | |||
| f3cf836fcd | |||
| 41517631ee | |||
| 4c0f2fde91 | |||
| 1e90171ee9 | |||
|
|
9f3d610bb4 | ||
|
|
d545b91844 | ||
| b57bc0683c | |||
| 42dd3a1c49 | |||
| 73ff854f0f | |||
| 066db995c1 | |||
| 4068669704 | |||
| 9edd548466 | |||
| 28a3d72fad | |||
| 4f82b9c85b | |||
| 18e507b439 | |||
| f4088691c5 | |||
| 7a7ab3ad2e | |||
| 1a563f63a2 | |||
| be56080ba1 | |||
|
|
abc0cceb56 | ||
|
|
2a8fade420 | ||
| 67756255db | |||
| 4f58523e02 | |||
| 6442de2c30 | |||
| 2f9b6edf8c | |||
| 257ef320d8 | |||
| dce8686c0f | |||
| 4a53d5c6dc | |||
| 5ff85002fd | |||
| 123c0c831e | |||
| 5f92e5e696 | |||
| ed2fd09def | |||
| 6fda7f1c71 | |||
| 25e47604b3 | |||
|
|
74eb0d0f91 | ||
|
|
089e6c5b28 | ||
| 00598348ab | |||
| f30f262751 | |||
| dbd685bd43 | |||
| a82ee292ce | |||
| 4445852c42 | |||
|
|
b5e7b8e2c1 | ||
|
|
0c06516984 | ||
| 8f4f356746 | |||
| 6225d6f7f0 | |||
|
|
b0324fafdf | ||
| ed02f6ee6c | |||
| 972148f173 | |||
| 0e703c1913 | |||
| e327f47b68 | |||
| 713389227c | |||
| c5f0be7af2 | |||
| 919814ae0b | |||
|
|
f69918d84f | ||
| 34da442961 | |||
| 0d711ea55c | |||
|
|
01e8f9f7dd | ||
| 97e6b3d30e | |||
| 2440fedaad | |||
| e146ae2710 | |||
| a209ade10b | |||
| be80e2f387 | |||
| 15138a30da | |||
| 37338f12fb | |||
| edfb037864 | |||
| d16f260a3a | |||
| b0cdf0adf2 | |||
| 0c933e4890 | |||
| 78f354d0d3 | |||
| d297282320 | |||
| 7ea3b32286 | |||
| 1a71e2ddbf | |||
| 67b7926dab | |||
| 79beba44a4 | |||
| 809cbb5b5e | |||
| a0e3e85467 | |||
| 21274991f3 | |||
| 9e3a6040e3 | |||
| ebd2a364a7 | |||
| 167036533c | |||
| fd18ee6079 | |||
| 6f5f88573a | |||
| 370c3746f7 | |||
| aa811f4277 | |||
| 112c9d58da | |||
| 955b4d53da | |||
| ba59a49f98 | |||
| 9f483ce8d3 | |||
| 8b1b34db6a |
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[env]
|
||||||
|
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
||||||
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
18
.fallowrc.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||||
|
"entry": [
|
||||||
|
"src/index.{ts,tsx,js,jsx}",
|
||||||
|
"src/main.{ts,tsx,js,jsx}",
|
||||||
|
"apps/pwa/src/serviceWorker.ts",
|
||||||
|
"apps/tauri/render-version.ts",
|
||||||
|
"packages/**/*.test.ts"
|
||||||
|
],
|
||||||
|
"workspaces": {
|
||||||
|
"packages": ["packages/*", "apps/*"]
|
||||||
|
},
|
||||||
|
"duplicates": {
|
||||||
|
"minOccurrences": 3,
|
||||||
|
"ignore": ["**/scripts/**", "**.test.ts", "licenses/**"]
|
||||||
|
},
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
60
.forgejo/workflows/dependency-builds.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
name: Dependency builds
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ""
|
||||||
|
GITHUB_TOKEN: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
web:
|
||||||
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
|
name: Build web
|
||||||
|
runs-on: nixos
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git submodule update --init --recursive
|
||||||
|
- run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
- run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
- run: nix develop .#electron --command pnpm run build:web
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
|
name: Build desktop
|
||||||
|
runs-on: nixos
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git submodule update --init --recursive
|
||||||
|
- run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
- run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
- run: nix develop .#electron --command pnpm run build:desktop
|
||||||
|
|
||||||
|
native-mtp:
|
||||||
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
|
name: Test native MTP
|
||||||
|
runs-on: nixos
|
||||||
|
steps:
|
||||||
|
- run: nix profile add nixpkgs#nodejs_24
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git submodule update --init --recursive
|
||||||
|
- run: nix develop .#electron --command bash -lc 'cd apps/tauri/src-tauri && cargo test'
|
||||||
|
|
||||||
|
mobile:
|
||||||
|
if: ${{ github.actor == 'rasensprenger' }}
|
||||||
|
name: Build mobile
|
||||||
|
runs-on: nixos
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- run: git submodule update --init --recursive
|
||||||
|
- run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||||
|
- run: nix develop .#tauri --command pnpm run build:packages
|
||||||
|
- run: nix develop .#tauri --command pnpm --dir apps/tauri run build:mobile:ci
|
||||||
|
|
@ -1,76 +1,110 @@
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
|
paths-ignore:
|
||||||
|
- flake.nix
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#electron --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Build web
|
- name: Build web
|
||||||
run: bun run build:web
|
run: nix develop .#electron --command pnpm run build:web
|
||||||
|
|
||||||
- name: Install rsync
|
|
||||||
run: apt-get update && apt-get install -y rsync
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/
|
run: nix develop .#electron --command rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-dev/
|
||||||
|
|
||||||
build-mobile:
|
build-mobile:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#tauri --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#tauri --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
run: |
|
run: |
|
||||||
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
|
set -euo pipefail
|
||||||
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)"
|
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_BASE64 secret is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$KEYSTORE_PROPERTIES" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_PROPERTIES secret is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$KEYSTORE_BASE64" \
|
||||||
|
| tr -d '[:space:]' \
|
||||||
|
| base64 -d > keystore.jks
|
||||||
|
|
||||||
|
printf '%s' "$KEYSTORE_PROPERTIES" \
|
||||||
|
| sed 's/\\n/\n/g' \
|
||||||
|
| tr -d '\r' \
|
||||||
|
| sed 's|^[[:space:]]*storeFile[[:space:]]*=.*|storeFile=keystore.jks|' \
|
||||||
|
> keystore.properties
|
||||||
|
|
||||||
|
grep -q '^[[:space:]]*storeFile[[:space:]]*=' keystore.properties || printf '\nstoreFile=keystore.jks\n' >> keystore.properties
|
||||||
|
|
||||||
|
if [ ! -s keystore.jks ]; then
|
||||||
|
echo "Decoded keystore.jks is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s keystore.properties ]; then
|
||||||
|
echo "Generated keystore.properties is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^[[:space:]]*keyAlias[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing keyAlias"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Eq '^[[:space:]]*(keyPassword|password)[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing keyPassword or password"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Eq '^[[:space:]]*(storePassword|password)[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing storePassword or password"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build mobile
|
- name: Build mobile
|
||||||
run: bun run build:mobile
|
run: nix develop .#tauri --command pnpm run build:mobile
|
||||||
|
|
||||||
- name: Upload mobile artifact
|
- name: Upload mobile artifact
|
||||||
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
|
@ -79,40 +113,59 @@ jobs:
|
||||||
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||||
|
|
||||||
build-desktop:
|
build-desktop:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#electron --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Build desktop
|
- name: Set Electron dev version
|
||||||
run: bun run build:desktop
|
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"
|
||||||
|
export DEV_VERSION
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = "apps/electron/package.json";
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
pkg.version = process.env.DEV_VERSION;
|
||||||
|
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
|
||||||
|
'
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build Electron desktop
|
||||||
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
cd apps/electron
|
||||||
|
pnpm run package:raw
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Upload desktop artifacts
|
- name: Upload desktop artifacts
|
||||||
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: desktop-bundles
|
name: electron-desktop-${{ matrix.target }}
|
||||||
path: apps/tauri/src-tauri/target/release/bundle/
|
path: apps/electron/release/
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
needs: [build-web, build-mobile, build-desktop]
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
|
|
@ -120,14 +173,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Download mobile artifact
|
- name: Download mobile artifact
|
||||||
uses: https://data.forgejo.org/actions/download-artifact@v3
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
|
@ -138,21 +188,33 @@ jobs:
|
||||||
- name: Download desktop artifacts
|
- name: Download desktop artifacts
|
||||||
uses: https://data.forgejo.org/actions/download-artifact@v3
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: desktop-bundles
|
name: electron-desktop-linux
|
||||||
path: apps/tauri/src-tauri/target/release/bundle/
|
path: apps/electron/release/
|
||||||
|
|
||||||
- name: Copy releases
|
|
||||||
run: bun --bun run copy-releases
|
|
||||||
|
|
||||||
- name: Read version and hash
|
- name: Read version and hash
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
VERSION="$(node -p "require('./package.json').version")"
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
SHORT_SHA="$(git rev-parse --short HEAD)"
|
SHORT_SHA="$(git rev-parse --short HEAD)"
|
||||||
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
||||||
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
|
echo "short_sha=$SHORT_SHA" >> "$FORGEJO_OUTPUT"
|
||||||
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
|
echo "tag=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
|
||||||
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
|
echo "title=${VERSION}-dev-${SHORT_SHA}" >> "$FORGEJO_OUTPUT"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Copy releases
|
||||||
|
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
|
||||||
|
|
||||||
- name: Create pre-release and upload files
|
- name: Create pre-release and upload files
|
||||||
env:
|
env:
|
||||||
|
|
@ -163,12 +225,13 @@ jobs:
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
TITLE: ${{ steps.version.outputs.title }}
|
TITLE: ${{ steps.version.outputs.title }}
|
||||||
run: |
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
test -d releases
|
test -d releases
|
||||||
find releases -type f | grep -q .
|
find releases -type f | grep -q .
|
||||||
|
|
||||||
LATEST_PROD_TAG="$(git tag --list '*-prod' --sort=-v:refname | head -n 1 || true)"
|
LATEST_PROD_TAG="$(git for-each-ref refs/tags --sort=-creatordate --format='%(refname:short)' | awk '!/-/' | head -n 1 || true)"
|
||||||
|
|
||||||
if [ -n "$LATEST_PROD_TAG" ]; then
|
if [ -n "$LATEST_PROD_TAG" ]; then
|
||||||
RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')"
|
RAW_LOG="$(git log "$LATEST_PROD_TAG"..HEAD --pretty=format:'- %s')"
|
||||||
|
|
@ -231,9 +294,24 @@ jobs:
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
|
||||||
|
export ASSET_BASE_URL
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = "releases/electron-release-metadata.json";
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
metadata.version = process.env.TAG;
|
||||||
|
metadata.tag = process.env.TAG;
|
||||||
|
for (const artifact of metadata.artifacts || []) {
|
||||||
|
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||||
|
'
|
||||||
|
|
||||||
find releases -type f -print0 | while IFS= read -r -d '' file; do
|
find releases -type f -print0 | while IFS= read -r -d '' file; do
|
||||||
name="$(basename "$file")"
|
name="$(basename "$file")"
|
||||||
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
|
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@$file"
|
-F "attachment=@$file"
|
||||||
done
|
done
|
||||||
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,110 @@
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- flake.nix
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-web:
|
build-web:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#electron --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Build web
|
- name: Build web
|
||||||
run: bun run build:web
|
run: nix develop .#electron --command pnpm run build:web
|
||||||
|
|
||||||
- name: Install rsync
|
|
||||||
run: apt-get update && apt-get install -y rsync
|
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/
|
run: nix develop .#electron --command rsync -a --delete apps/web/dist/ /var/lib/www/tensamin-web-prod/
|
||||||
|
|
||||||
build-mobile:
|
build-mobile:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#tauri --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#tauri --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Setup Android Keystore
|
- name: Setup Android Keystore
|
||||||
env:
|
env:
|
||||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
|
||||||
run: |
|
run: |
|
||||||
bun -e "require('fs').writeFileSync('keystore.jks', Buffer.from(process.env.KEYSTORE_BASE64.replace(/\s+/g, ''), 'base64'))"
|
set -euo pipefail
|
||||||
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)"
|
|
||||||
|
if [ -z "$KEYSTORE_BASE64" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_BASE64 secret is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$KEYSTORE_PROPERTIES" ]; then
|
||||||
|
echo "ANDROID_KEYSTORE_PROPERTIES secret is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$KEYSTORE_BASE64" \
|
||||||
|
| tr -d '[:space:]' \
|
||||||
|
| base64 -d > keystore.jks
|
||||||
|
|
||||||
|
printf '%s' "$KEYSTORE_PROPERTIES" \
|
||||||
|
| sed 's/\\n/\n/g' \
|
||||||
|
| tr -d '\r' \
|
||||||
|
| sed 's|^[[:space:]]*storeFile[[:space:]]*=.*|storeFile=keystore.jks|' \
|
||||||
|
> keystore.properties
|
||||||
|
|
||||||
|
grep -q '^[[:space:]]*storeFile[[:space:]]*=' keystore.properties || printf '\nstoreFile=keystore.jks\n' >> keystore.properties
|
||||||
|
|
||||||
|
if [ ! -s keystore.jks ]; then
|
||||||
|
echo "Decoded keystore.jks is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s keystore.properties ]; then
|
||||||
|
echo "Generated keystore.properties is missing or empty"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q '^[[:space:]]*keyAlias[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing keyAlias"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Eq '^[[:space:]]*(keyPassword|password)[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing keyPassword or password"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Eq '^[[:space:]]*(storePassword|password)[[:space:]]*=' keystore.properties; then
|
||||||
|
echo "keystore.properties is missing storePassword or password"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Build mobile
|
- name: Build mobile
|
||||||
run: bun run build:mobile
|
run: nix develop .#tauri --command pnpm run build:mobile
|
||||||
|
|
||||||
- name: Upload mobile artifact
|
- name: Upload mobile artifact
|
||||||
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
|
|
@ -79,53 +113,67 @@ jobs:
|
||||||
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
path: apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||||
|
|
||||||
build-desktop:
|
build-desktop:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [linux]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Nix
|
|
||||||
uses: https://github.com/cachix/install-nix-action@v30
|
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Copy licenses
|
- name: Copy licenses
|
||||||
run: bun run copy-licenses
|
run: nix develop .#electron --command pnpm run copy-licenses
|
||||||
|
|
||||||
- name: Build packages
|
- name: Build packages
|
||||||
run: bun run build:packages
|
run: nix develop .#electron --command pnpm run build:packages
|
||||||
|
|
||||||
- name: Build desktop
|
- name: Set Electron prod version
|
||||||
run: bun run build:desktop
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
export VERSION
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = "apps/electron/package.json";
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
pkg.version = process.env.VERSION;
|
||||||
|
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
|
||||||
|
'
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build Electron desktop
|
||||||
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
cd apps/electron
|
||||||
|
pnpm run package:raw
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Upload desktop artifacts
|
- name: Upload desktop artifacts
|
||||||
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
uses: https://data.forgejo.org/actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: desktop-bundles
|
name: electron-desktop-${{ matrix.target }}
|
||||||
path: apps/tauri/src-tauri/target/release/bundle/
|
path: apps/electron/release/
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: docker
|
runs-on: nixos
|
||||||
needs: [build-web, build-mobile, build-desktop]
|
needs: [build-web, build-mobile, build-desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Packages
|
- name: Pull git submodules
|
||||||
run: apt-get update && apt-get install -y sudo curl jq
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Download mobile artifact
|
- name: Download mobile artifact
|
||||||
uses: https://data.forgejo.org/actions/download-artifact@v3
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
|
|
@ -136,18 +184,30 @@ jobs:
|
||||||
- name: Download desktop artifacts
|
- name: Download desktop artifacts
|
||||||
uses: https://data.forgejo.org/actions/download-artifact@v3
|
uses: https://data.forgejo.org/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: desktop-bundles
|
name: electron-desktop-linux
|
||||||
path: apps/tauri/src-tauri/target/release/bundle/
|
path: apps/electron/release/
|
||||||
|
|
||||||
- name: Copy releases
|
|
||||||
run: bun --bun run copy-releases
|
|
||||||
|
|
||||||
- name: Read version
|
- name: Read version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
VERSION="$(node -p "require('./package.json').version")"
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
|
||||||
echo "tag=${VERSION}-prod" >> "$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
|
||||||
|
|
||||||
- name: Create release and upload files
|
- name: Create release and upload files
|
||||||
env:
|
env:
|
||||||
|
|
@ -157,6 +217,7 @@ jobs:
|
||||||
SHA: ${{ forgejo.sha }}
|
SHA: ${{ forgejo.sha }}
|
||||||
TAG: ${{ steps.version.outputs.tag }}
|
TAG: ${{ steps.version.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
test -d releases
|
test -d releases
|
||||||
|
|
@ -190,9 +251,63 @@ jobs:
|
||||||
}')")"
|
}')")"
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
|
||||||
|
|
||||||
|
ASSET_BASE_URL="${API%/api/v1}/$REPO/releases/download/$TAG"
|
||||||
|
export ASSET_BASE_URL
|
||||||
|
node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = "releases/electron-release-metadata.json";
|
||||||
|
const metadata = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
metadata.version = process.env.TAG;
|
||||||
|
metadata.tag = process.env.TAG;
|
||||||
|
for (const artifact of metadata.artifacts || []) {
|
||||||
|
artifact.url = `${process.env.ASSET_BASE_URL}/${encodeURIComponent(artifact.name)}`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||||
|
'
|
||||||
|
|
||||||
find releases -type f -print0 | while IFS= read -r -d '' file; do
|
find releases -type f -print0 | while IFS= read -r -d '' file; do
|
||||||
name="$(basename "$file")"
|
name="$(basename "$file")"
|
||||||
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
|
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$name" \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-F "attachment=@$file"
|
-F "attachment=@$file"
|
||||||
done
|
done
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Delete dev releases
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ forgejo.token }}
|
||||||
|
API: ${{ forgejo.api_url }}
|
||||||
|
REPO: ${{ forgejo.repository }}
|
||||||
|
run: |
|
||||||
|
nix develop .#electron --command bash <<'EOF'
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PAGE=1
|
||||||
|
DELETE_RELEASES=delete-dev-releases.tsv
|
||||||
|
|
||||||
|
: > "$DELETE_RELEASES"
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
curl -fsS \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
"$API/repos/$REPO/releases?page=$PAGE&limit=50&pre-release=true" \
|
||||||
|
-o releases.json
|
||||||
|
|
||||||
|
COUNT="$(jq 'length' releases.json)"
|
||||||
|
test "$COUNT" -gt 0 || break
|
||||||
|
|
||||||
|
jq -r \
|
||||||
|
'.[] | select(.prerelease == true) | select(.tag_name | contains("-dev-")) | [.id, .tag_name] | @tsv' releases.json \
|
||||||
|
>> "$DELETE_RELEASES"
|
||||||
|
|
||||||
|
PAGE="$((PAGE + 1))"
|
||||||
|
done
|
||||||
|
|
||||||
|
while IFS="$(printf '\t')" read -r release_id release_tag; do
|
||||||
|
test -n "$release_id" || continue
|
||||||
|
echo "Deleting dev release $release_tag"
|
||||||
|
curl -fsS -X DELETE \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
"$API/repos/$REPO/releases/$release_id"
|
||||||
|
done < "$DELETE_RELEASES"
|
||||||
|
EOF
|
||||||
|
|
|
||||||
4
.gitignore
vendored
|
|
@ -1,2 +1,6 @@
|
||||||
node_modules
|
node_modules
|
||||||
releases
|
releases
|
||||||
|
.fallow
|
||||||
|
.direnv
|
||||||
|
keystore.jks
|
||||||
|
keystore.properties
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "mtp-type-maps"]
|
||||||
|
path = mtp-type-maps
|
||||||
|
url = https://git.methanium.net/tensamin/mtp-type-maps
|
||||||
|
|
@ -5,4 +5,5 @@ coverage
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
bun.lock
|
bun.lock
|
||||||
apps/tauri/src-tauri
|
apps/tauri/src-tauri
|
||||||
|
apps/electron/release
|
||||||
licenses
|
licenses
|
||||||
|
|
|
||||||
3
.vscode/extensions.json
vendored
|
|
@ -2,7 +2,6 @@
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"tauri-apps.tauri-vscode",
|
"tauri-apps.tauri-vscode",
|
||||||
"rust-lang.rust-analyzer",
|
"rust-lang.rust-analyzer",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss"
|
||||||
"antfu.vite"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
LICENSE
|
|
@ -1,16 +1,15 @@
|
||||||
Copyright (c) [2025] [Methanium]
|
Copyright (c) 2025 Methanium
|
||||||
|
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
This software is protected by copyright. Copying, editing,
|
No part of this software, source code, documentation, or
|
||||||
distributing, publicly performing, or any other use of this software
|
associated materials may be copied, reproduced, modified,
|
||||||
or its components, in source or binary form, is strictly prohibited without the express
|
distributed, published, sublicensed, sold, or used to create
|
||||||
written permission of the copyright holder.
|
derivative works without prior written permission from the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
FUTURE LICENSE ACCEPTANCE:
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||||
It is the copyright holder's intention to release this software in the future
|
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||||
under a license yet to be defined, which will, among other things,
|
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||||
allow private, non-commercial use. This statement does not constitute
|
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||||
a current license grant and does not alter the above
|
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||||
prohibition on use, copying, or modification. Until the formal
|
|
||||||
publication of such a future license, all rights remain
|
|
||||||
reserved.
|
|
||||||
|
|
|
||||||
3
README
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Information
|
||||||
|
|
||||||
|
All dev releases get deleted upon creation of the latest prod release.
|
||||||
6
TODO
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
- 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
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
dist
|
||||||
|
release
|
||||||
105
apps/electron/package.json
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
{
|
||||||
|
"name": "@tensamin/electron",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.3",
|
||||||
|
"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": "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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"electron": "^43.3.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "net.tensamin.client",
|
||||||
|
"productName": "Tensamin",
|
||||||
|
"executableName": "tensamin",
|
||||||
|
"artifactName": "Tensamin-${version}-${os}-${arch}.${ext}",
|
||||||
|
"icon": "build/icons/icon.png",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"toolsets": {
|
||||||
|
"appimage": "1.0.3"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "../web/dist",
|
||||||
|
"to": "web"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build/icons",
|
||||||
|
"to": "icons",
|
||||||
|
"filter": [
|
||||||
|
"32x32.png",
|
||||||
|
"icon.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb",
|
||||||
|
"rpm"
|
||||||
|
],
|
||||||
|
"icon": "build/icons",
|
||||||
|
"executableName": "tensamin",
|
||||||
|
"category": "Network",
|
||||||
|
"maintainer": "Methanium",
|
||||||
|
"syncDesktopName": true,
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"Name": "Tensamin",
|
||||||
|
"StartupWMClass": "Tensamin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"portable"
|
||||||
|
],
|
||||||
|
"icon": "build/icons/icon.ico"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publish": null
|
||||||
|
}
|
||||||
|
}
|
||||||
71
apps/electron/scripts/generate-release-metadata.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
createReadStream,
|
||||||
|
existsSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import rootPackage from "../../../package.json" with { type: "json" };
|
||||||
|
|
||||||
|
const releaseDir = join(import.meta.dir, "..", "release");
|
||||||
|
const outDir = join(import.meta.dir, "..", "..", "..", "releases");
|
||||||
|
|
||||||
|
function sha256(filePath: string) {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk));
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", () => resolve(hash.digest("hex")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformFor(file: string) {
|
||||||
|
if (/win|nsis|portable|\.exe$/i.test(file)) return "windows";
|
||||||
|
if (/mac|darwin|\.dmg$/i.test(file)) return "macos";
|
||||||
|
return "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
function archFor(file: string) {
|
||||||
|
if (/arm64|aarch64/i.test(file)) return "arm64";
|
||||||
|
return "x64";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(releaseDir)) {
|
||||||
|
throw new Error(`Missing Electron release directory: ${releaseDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = readdirSync(releaseDir)
|
||||||
|
.filter((file) => !file.endsWith(".blockmap") && !file.endsWith(".yml"))
|
||||||
|
.map((file) => join(releaseDir, file))
|
||||||
|
.filter((file) => statSync(file).isFile());
|
||||||
|
|
||||||
|
const artifacts = await Promise.all(
|
||||||
|
files.map(async (filePath) => ({
|
||||||
|
name: basename(filePath),
|
||||||
|
platform: platformFor(filePath),
|
||||||
|
arch: archFor(filePath),
|
||||||
|
url: `__FORGEJO_RELEASE_ASSET_URL__/${encodeURIComponent(basename(filePath))}`,
|
||||||
|
sha256: await sha256(filePath),
|
||||||
|
size: statSync(filePath).size,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
version: rootPackage.version,
|
||||||
|
tag: rootPackage.version,
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
artifacts,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
join(outDir, "electron-release-metadata.json"),
|
||||||
|
`${JSON.stringify(metadata, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(outDir, "SHA256SUMS"),
|
||||||
|
`${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`,
|
||||||
|
);
|
||||||
11
apps/electron/scripts/generate-signing-key.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { generateKeyPairSync } from "node:crypto";
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
|
||||||
|
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||||
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("TENSAMIN_UPDATE_PRIVATE_KEY_PEM=");
|
||||||
|
console.log(privateKey.trim());
|
||||||
|
console.log("\nTENSAMIN_UPDATE_PUBLIC_KEY_PEM=");
|
||||||
|
console.log(publicKey.trim());
|
||||||
511
apps/electron/src/main/main.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
desktopCapturer,
|
||||||
|
globalShortcut,
|
||||||
|
ipcMain,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
} from "electron";
|
||||||
|
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");
|
||||||
|
app.commandLine.appendSwitch("v", "1");
|
||||||
|
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" &&
|
||||||
|
!process.env.TENSAMIN_ENABLE_VULKAN
|
||||||
|
) {
|
||||||
|
app.commandLine.appendSwitch("disable-features", "Vulkan");
|
||||||
|
}
|
||||||
|
|
||||||
|
function verboseLog(...args: unknown[]) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log("[tensamin:electron]", ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRendererIndex() {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return resolve(__dirname, "../../../web/dist/index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
return join(process.resourcesPath, "web", "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowIcon() {
|
||||||
|
if (process.platform === "darwin") return undefined;
|
||||||
|
if (app.isPackaged) return join(process.resourcesPath, "icons", "icon.png");
|
||||||
|
return resolve(__dirname, "../../build/icons/icon.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatform(): DesktopScreenShareCapabilities["platform"] {
|
||||||
|
if (process.platform === "linux") return "linux";
|
||||||
|
if (process.platform === "darwin") return "macos";
|
||||||
|
if (process.platform === "win32") return "windows";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScreenShareCapabilities(): DesktopScreenShareCapabilities {
|
||||||
|
const platform = getPlatform();
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime: "electron",
|
||||||
|
platform,
|
||||||
|
showAudioOutputSelector: platform === "linux",
|
||||||
|
showAudioSwitch: platform === "windows" || platform === "macos",
|
||||||
|
hasReliableSystemAudio: platform === "windows",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function execJson(command: string, args: string[]) {
|
||||||
|
verboseLog("exec", command, args.join(" "));
|
||||||
|
|
||||||
|
return new Promise<unknown>((resolvePromise, reject) => {
|
||||||
|
execFile(command, args, { timeout: 3000 }, (error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(stderr.trim() || error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePromise(JSON.parse(stdout));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAudioOutputs(): Promise<DesktopScreenShareAudioOutput[]> {
|
||||||
|
verboseLog("listAudioOutputs", { platform: process.platform });
|
||||||
|
|
||||||
|
if (process.platform !== "linux") return [];
|
||||||
|
|
||||||
|
const sinks = await execJson("pactl", ["--format=json", "list", "sinks"]);
|
||||||
|
if (!Array.isArray(sinks)) return [];
|
||||||
|
|
||||||
|
return sinks
|
||||||
|
.map((sink) => {
|
||||||
|
if (!sink || typeof sink !== "object") return null;
|
||||||
|
const record = sink as Record<string, unknown>;
|
||||||
|
const id = record.index == null ? undefined : String(record.index);
|
||||||
|
const name =
|
||||||
|
typeof record.description === "string" ? record.description : id;
|
||||||
|
if (!id || !name) return null;
|
||||||
|
return { id, name, isDefault: false };
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(output): output is DesktopScreenShareAudioOutput => output != null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listScreenShareSources() {
|
||||||
|
verboseLog("listScreenShareSources");
|
||||||
|
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ["screen", "window"],
|
||||||
|
thumbnailSize: { width: 320, height: 180 },
|
||||||
|
fetchWindowIcons: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sources.map((source) => ({
|
||||||
|
id: source.id,
|
||||||
|
kind: source.id.startsWith("screen:") ? "screen" : "window",
|
||||||
|
name: source.name,
|
||||||
|
subtitle: source.id,
|
||||||
|
thumbnail: source.thumbnail.isEmpty() ? null : source.thumbnail.toDataURL(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDisplayMediaHandler() {
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
async (_request, callback) => {
|
||||||
|
verboseLog("display media request", { selectedScreenShareSourceId });
|
||||||
|
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ["screen", "window"],
|
||||||
|
thumbnailSize: { width: 0, height: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = sources.find(
|
||||||
|
(source) => source.id === selectedScreenShareSourceId,
|
||||||
|
);
|
||||||
|
selectedScreenShareSourceId = null;
|
||||||
|
const video = selected ?? sources[0];
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
callback({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
callback({ video, audio: "loopback" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback({ video });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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");
|
||||||
|
|
||||||
|
ipcMain.handle(ipcChannels.listScreenShareSources, listScreenShareSources);
|
||||||
|
ipcMain.handle(ipcChannels.listScreenShareAudioOutputs, listAudioOutputs);
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.getScreenShareCapabilities,
|
||||||
|
getScreenShareCapabilities,
|
||||||
|
);
|
||||||
|
ipcMain.handle(
|
||||||
|
ipcChannels.selectScreenShareSource,
|
||||||
|
(_event, sourceId: unknown) => {
|
||||||
|
if (
|
||||||
|
typeof sourceId !== "string" ||
|
||||||
|
sourceId.length === 0 ||
|
||||||
|
sourceId.length > 256
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid screen share source id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedScreenShareSourceId = sourceId;
|
||||||
|
verboseLog("selected screen share source", sourceId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||||
|
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
||||||
|
ipcMain.handle(ipcChannels.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();
|
||||||
|
});
|
||||||
|
ipcMain.handle(ipcChannels.maximizeWindow, () => {
|
||||||
|
verboseLog("window:maximize");
|
||||||
|
if (!mainWindow) return;
|
||||||
|
|
||||||
|
if (mainWindow.isMaximized()) {
|
||||||
|
mainWindow.unmaximize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.maximize();
|
||||||
|
});
|
||||||
|
ipcMain.handle(ipcChannels.closeWindow, () => {
|
||||||
|
verboseLog("window:close");
|
||||||
|
mainWindow?.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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", {
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
electronVersion: process.versions.electron,
|
||||||
|
chromeVersion: process.versions.chrome,
|
||||||
|
nodeVersion: process.versions.node,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
rendererIndex,
|
||||||
|
argv: process.argv,
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 900,
|
||||||
|
minHeight: 600,
|
||||||
|
title: "Tensamin",
|
||||||
|
icon: getWindowIcon(),
|
||||||
|
frame: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, "../preload/preload.cjs"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mainWindow.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
verboseLog("blocked window open", url);
|
||||||
|
void shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
mainWindow.webContents.on(
|
||||||
|
"console-message",
|
||||||
|
(_event, level, message, line, sourceId) => {
|
||||||
|
const target = level >= 2 ? console.error : console.log;
|
||||||
|
target("[tensamin:renderer]", message, { level, line, sourceId });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mainWindow.webContents.on(
|
||||||
|
"did-fail-load",
|
||||||
|
(_event, errorCode, errorDescription, validatedURL) => {
|
||||||
|
console.error("[tensamin:electron] renderer failed to load", {
|
||||||
|
errorCode,
|
||||||
|
errorDescription,
|
||||||
|
validatedURL,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
mainWindow.webContents.on("did-finish-load", () => {
|
||||||
|
verboseLog("renderer finished loading", mainWindow?.webContents.getURL());
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||||
|
console.error("[tensamin:electron] renderer process gone", details);
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.on("unresponsive", () => {
|
||||||
|
console.error("[tensamin:electron] main window became unresponsive");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await mainWindow.loadFile(rendererIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
verboseLog("window-all-closed");
|
||||||
|
if (process.platform !== "darwin") app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("activate", () => {
|
||||||
|
verboseLog("activate");
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
console.error("[tensamin:electron] uncaught exception", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
console.error("[tensamin:electron] unhandled rejection", reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
verboseLog("waiting for app readiness");
|
||||||
|
await app.whenReady();
|
||||||
|
verboseLog("app ready");
|
||||||
|
registerIpc();
|
||||||
|
registerDisplayMediaHandler();
|
||||||
|
registerMediaPermissionHandler();
|
||||||
|
initTray(() => mainWindow);
|
||||||
|
await createWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void start().catch((error) => {
|
||||||
|
console.error("[tensamin:electron] failed to start", error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
128
apps/electron/src/main/secureStorage.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
}
|
||||||
60
apps/electron/src/main/tray.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
180
apps/electron/src/main/updates.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { app, net } from "electron";
|
||||||
|
import type {
|
||||||
|
ReleaseArtifact,
|
||||||
|
ReleaseMetadata,
|
||||||
|
UpdateCheckResult,
|
||||||
|
} from "../shared/ipc.js";
|
||||||
|
|
||||||
|
const metadataUrl = process.env.TENSAMIN_UPDATE_METADATA_URL;
|
||||||
|
|
||||||
|
function compareSemver(left: string, right: string) {
|
||||||
|
const leftParts = left
|
||||||
|
.split(/[.-]/)
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0);
|
||||||
|
const rightParts = right
|
||||||
|
.split(/[.-]/)
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0);
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDevVersion(version: string) {
|
||||||
|
return /-dev[.-]/.test(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformName() {
|
||||||
|
if (process.platform === "win32") return "windows";
|
||||||
|
if (process.platform === "darwin") return "macos";
|
||||||
|
if (process.platform === "linux") return "linux";
|
||||||
|
return process.platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archName() {
|
||||||
|
if (process.arch === "x64") return "x64";
|
||||||
|
if (process.arch === "arm64") return "arm64";
|
||||||
|
return process.arch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestText(url: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = net.request(url);
|
||||||
|
request.on("response", (response) => {
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
reject(
|
||||||
|
new Error(`Update request failed with HTTP ${response.statusCode}.`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
});
|
||||||
|
request.on("error", reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBuffer(url: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = net.request(url);
|
||||||
|
request.on("response", (response) => {
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
reject(new Error(`Download failed with HTTP ${response.statusCode}.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
response.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
request.on("error", reject);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256File(filePath: string) {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
stream.on("data", (chunk) => hash.update(chunk));
|
||||||
|
stream.on("error", reject);
|
||||||
|
stream.on("end", resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectArtifact(
|
||||||
|
metadata: ReleaseMetadata,
|
||||||
|
): ReleaseArtifact | undefined {
|
||||||
|
const platform = platformName();
|
||||||
|
const arch = archName();
|
||||||
|
|
||||||
|
return metadata.artifacts.find(
|
||||||
|
(artifact) => artifact.platform === platform && artifact.arch === arch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
||||||
|
const currentVersion = app.getVersion();
|
||||||
|
|
||||||
|
if (!metadataUrl) {
|
||||||
|
return { available: false, currentVersion, latestVersion: currentVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
await requestText(metadataUrl),
|
||||||
|
) as ReleaseMetadata;
|
||||||
|
const artifact = selectArtifact(metadata);
|
||||||
|
|
||||||
|
if (isDevVersion(metadata.version) !== isDevVersion(currentVersion)) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: metadata.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDevVersion(currentVersion)) {
|
||||||
|
if (!artifact || metadata.version === currentVersion) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: metadata.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: metadata.version,
|
||||||
|
artifact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artifact || compareSemver(metadata.version, currentVersion) <= 0) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: metadata.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion: metadata.version,
|
||||||
|
artifact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadVerifiedArtifact(artifact: ReleaseArtifact) {
|
||||||
|
const updatesDir = join(app.getPath("userData"), "updates");
|
||||||
|
await rm(updatesDir, { recursive: true, force: true });
|
||||||
|
await mkdir(updatesDir, { recursive: true });
|
||||||
|
|
||||||
|
const destination = join(updatesDir, basename(artifact.name));
|
||||||
|
await writeFile(destination, await requestBuffer(artifact.url), {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actualHash = await sha256File(destination);
|
||||||
|
if (actualHash !== artifact.sha256) {
|
||||||
|
await rm(destination, { force: true });
|
||||||
|
throw new Error("Downloaded update failed checksum verification.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
107
apps/electron/src/preload/preload.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
import {
|
||||||
|
ipcChannels,
|
||||||
|
type DesktopCallStatus,
|
||||||
|
type DesktopGlobalHotkeyBinding,
|
||||||
|
type DesktopScreenShareSource,
|
||||||
|
secureStorageLimits,
|
||||||
|
} 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: () =>
|
||||||
|
ipcRenderer.invoke(ipcChannels.listScreenShareSources),
|
||||||
|
listScreenShareAudioOutputs: () =>
|
||||||
|
ipcRenderer.invoke(ipcChannels.listScreenShareAudioOutputs),
|
||||||
|
getScreenShareCapabilities: () =>
|
||||||
|
ipcRenderer.invoke(ipcChannels.getScreenShareCapabilities),
|
||||||
|
selectScreenShareSource: (sourceId: DesktopScreenShareSource["id"]) => {
|
||||||
|
if (typeof sourceId !== "string" || sourceId.length === 0) {
|
||||||
|
return Promise.reject(new Error("Invalid screen share source id."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipcRenderer.invoke(ipcChannels.selectScreenShareSource, sourceId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
getVersion: () => ipcRenderer.invoke(ipcChannels.getVersion),
|
||||||
|
},
|
||||||
|
updates: {
|
||||||
|
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
close: () => windowAction(ipcChannels.closeWindow),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("tensaminDesktop", desktopApi);
|
||||||
|
contextBridge.exposeInMainWorld(
|
||||||
|
"tensaminShowWindowControls",
|
||||||
|
process.env.TENSAMIN_HIDE_CONTROLS == null,
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TensaminDesktopApi = typeof desktopApi;
|
||||||
88
apps/electron/src/shared/ipc.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
export type DesktopScreenShareSource = {
|
||||||
|
id: string;
|
||||||
|
kind: "screen" | "window";
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DesktopScreenShareAudioOutput = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DesktopScreenShareCapabilities = {
|
||||||
|
runtime: "electron";
|
||||||
|
platform: "linux" | "macos" | "windows" | "other";
|
||||||
|
showAudioOutputSelector: boolean;
|
||||||
|
showAudioSwitch: boolean;
|
||||||
|
hasReliableSystemAudio: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type 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;
|
||||||
|
arch: string;
|
||||||
|
url: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReleaseMetadata = {
|
||||||
|
version: string;
|
||||||
|
tag: string;
|
||||||
|
publishedAt: string;
|
||||||
|
artifacts: ReleaseArtifact[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateCheckResult =
|
||||||
|
| { available: false; currentVersion: string; latestVersion: string }
|
||||||
|
| {
|
||||||
|
available: true;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
artifact: ReleaseArtifact;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ipcChannels = {
|
||||||
|
listScreenShareSources: "desktopMedia:listScreenShareSources",
|
||||||
|
listScreenShareAudioOutputs: "desktopMedia:listScreenShareAudioOutputs",
|
||||||
|
getScreenShareCapabilities: "desktopMedia:getScreenShareCapabilities",
|
||||||
|
selectScreenShareSource: "desktopMedia:selectScreenShareSource",
|
||||||
|
minimizeWindow: "window:minimize",
|
||||||
|
maximizeWindow: "window:maximize",
|
||||||
|
closeWindow: "window:close",
|
||||||
|
getVersion: "app:getVersion",
|
||||||
|
checkForUpdates: "updates:checkForUpdates",
|
||||||
|
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;
|
||||||
14
apps/electron/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"types": ["node", "electron"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
36
apps/pwa/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "@tensamin/pwa",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./vite": "./src/vite.ts",
|
||||||
|
"./runtime": "./src/runtime.tsx"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "pnpm exec prettier --write .",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.worker.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@methanium/ui": "*",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"@tensamin/crypto": "workspace:*",
|
||||||
|
"@tensamin/shared": "workspace:*",
|
||||||
|
"@tensamin/storage": "workspace:*",
|
||||||
|
"mtp": "*",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"vite-plugin-pwa": "^1.1.0",
|
||||||
|
"workbox-core": "^7.3.0",
|
||||||
|
"workbox-precaching": "^7.3.0",
|
||||||
|
"workbox-routing": "^7.3.0",
|
||||||
|
"workbox-strategies": "^7.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.1.2",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
189
apps/pwa/src/runtime.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const launchedFiles: File[] = [];
|
||||||
|
const fileListeners = new Set<(file: File) => void>();
|
||||||
|
|
||||||
|
function emitLaunchedFile(file: File) {
|
||||||
|
if (fileListeners.size === 0) launchedFiles.push(file);
|
||||||
|
else for (const listener of fileListeners) listener(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeTuFileLaunch(listener: (file: File) => void) {
|
||||||
|
fileListeners.add(listener);
|
||||||
|
for (const file of launchedFiles.splice(0)) listener(file);
|
||||||
|
return () => {
|
||||||
|
fileListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalledPwa() {
|
||||||
|
return (
|
||||||
|
window.matchMedia("(display-mode: standalone)").matches ||
|
||||||
|
window.matchMedia("(display-mode: window-controls-overlay)").matches ||
|
||||||
|
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationServerKey(value: string) {
|
||||||
|
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||||
|
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enablePush() {
|
||||||
|
if (!("Notification" in window))
|
||||||
|
throw new Error("Notifications are not supported by this browser.");
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted")
|
||||||
|
throw new Error("Notification permission was not granted.");
|
||||||
|
|
||||||
|
const publicKey = import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY;
|
||||||
|
if (
|
||||||
|
!publicKey ||
|
||||||
|
!("serviceWorker" in navigator) ||
|
||||||
|
!("PushManager" in window)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription =
|
||||||
|
(await registration.pushManager.getSubscription()) ??
|
||||||
|
(await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: applicationServerKey(publicKey),
|
||||||
|
}));
|
||||||
|
await setDatabaseEntry("keys", "push-subscription", subscription.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstalledPwaRuntime() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!("serviceWorker" in navigator) ||
|
||||||
|
!["http:", "https:"].includes(window.location.protocol)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let reloading = false;
|
||||||
|
const handleControllerChange = () => {
|
||||||
|
if (reloading) return;
|
||||||
|
reloading = true;
|
||||||
|
// A newly activated service worker must reload the document it controls.
|
||||||
|
// eslint-disable-next-line tensamin/no-window-location-reload
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
"controllerchange",
|
||||||
|
handleControllerChange,
|
||||||
|
);
|
||||||
|
void navigator.serviceWorker
|
||||||
|
.register(
|
||||||
|
import.meta.env.DEV ? "/dev-sw.js?dev-sw" : "/serviceWorker.js",
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((registration) => {
|
||||||
|
const watchWorker = (worker: ServiceWorker) => {
|
||||||
|
worker.addEventListener("statechange", () => {
|
||||||
|
if (worker.state !== "installed") return;
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
toast.success("Tensamin is ready for offline startup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast("A Tensamin update is ready", {
|
||||||
|
duration: Infinity,
|
||||||
|
action: {
|
||||||
|
label: "Update",
|
||||||
|
onClick: () => worker.postMessage({ type: "SKIP_WAITING" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (registration.installing) watchWorker(registration.installing);
|
||||||
|
registration.addEventListener("updatefound", () => {
|
||||||
|
if (registration.installing) watchWorker(registration.installing);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("Failed to register the Tensamin service worker", error);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
navigator.serviceWorker.removeEventListener(
|
||||||
|
"controllerchange",
|
||||||
|
handleControllerChange,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const launchQueue = (
|
||||||
|
window as Window & {
|
||||||
|
launchQueue?: {
|
||||||
|
setConsumer: (
|
||||||
|
consumer: (params: {
|
||||||
|
files?: Array<{ getFile: () => Promise<File> }>;
|
||||||
|
}) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).launchQueue;
|
||||||
|
launchQueue?.setConsumer((params) => {
|
||||||
|
for (const handle of params.files ?? []) {
|
||||||
|
void handle.getFile().then(emitLaunchedFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!("Notification" in window) ||
|
||||||
|
Notification.permission !== "default" ||
|
||||||
|
localStorage.getItem("pwa-push-hint")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem("pwa-push-hint", "shown");
|
||||||
|
toast("Enable message notifications", {
|
||||||
|
duration: Infinity,
|
||||||
|
action: {
|
||||||
|
label: "Enable",
|
||||||
|
onClick: () => {
|
||||||
|
void enablePush()
|
||||||
|
.then(() => toast.success("Notifications enabled"))
|
||||||
|
.catch((error: unknown) =>
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Could not enable notifications",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
"Notification" in window &&
|
||||||
|
Notification.permission === "granted" &&
|
||||||
|
import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY
|
||||||
|
) {
|
||||||
|
void enablePush().catch((error: unknown) => {
|
||||||
|
console.error("Failed to refresh the Web Push subscription", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PwaRuntime() {
|
||||||
|
if (isTauri() || !isInstalledPwa()) return null;
|
||||||
|
return <InstalledPwaRuntime />;
|
||||||
|
}
|
||||||
161
apps/pwa/src/serviceWorker.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { base64ToBytes } from "mtp";
|
||||||
|
import { clientsClaim } from "workbox-core";
|
||||||
|
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
|
||||||
|
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||||
|
import { createHandlerBoundToURL } from "workbox-precaching";
|
||||||
|
import { CacheFirst } from "workbox-strategies";
|
||||||
|
|
||||||
|
import { decryptChatText, unwrapChatSecret } from "@tensamin/crypto/chatSecret";
|
||||||
|
import { loadSecureBrowserValue } from "@tensamin/storage/browserSecure";
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
type PushPayload = {
|
||||||
|
version: 1;
|
||||||
|
senderId: number;
|
||||||
|
sender: string;
|
||||||
|
avatar?: string;
|
||||||
|
message: { content: string };
|
||||||
|
secret: {
|
||||||
|
chatId: string;
|
||||||
|
secretId: string;
|
||||||
|
version: number;
|
||||||
|
encryptedSecret: string;
|
||||||
|
kemCiphertext: string;
|
||||||
|
wrappingScheme: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPushPayload(value: unknown): value is PushPayload {
|
||||||
|
if (!value || typeof value !== "object") return false;
|
||||||
|
const payload = value as Partial<PushPayload>;
|
||||||
|
const message = payload.message as
|
||||||
|
Partial<PushPayload["message"]> | undefined;
|
||||||
|
const secret = payload.secret as Partial<PushPayload["secret"]> | undefined;
|
||||||
|
const stringValues = [
|
||||||
|
payload.sender,
|
||||||
|
message?.content,
|
||||||
|
secret?.chatId,
|
||||||
|
secret?.secretId,
|
||||||
|
secret?.encryptedSecret,
|
||||||
|
secret?.kemCiphertext,
|
||||||
|
secret?.wrappingScheme,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
payload.version === 1 &&
|
||||||
|
typeof payload.senderId === "number" &&
|
||||||
|
Number.isSafeInteger(payload.senderId) &&
|
||||||
|
payload.senderId > 0 &&
|
||||||
|
typeof secret?.version === "number" &&
|
||||||
|
stringValues.every((item) => typeof item === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptPush(payload: PushPayload) {
|
||||||
|
const keyring = await loadSecureBrowserValue<string>("mtp_keyring");
|
||||||
|
if (!keyring) throw new Error("MTP credentials are unavailable.");
|
||||||
|
const chatSecret = await unwrapChatSecret({
|
||||||
|
encryptedSecret: base64ToBytes(payload.secret.encryptedSecret),
|
||||||
|
kemCiphertext: base64ToBytes(payload.secret.kemCiphertext),
|
||||||
|
keyring,
|
||||||
|
chatId: payload.secret.chatId,
|
||||||
|
secretId: payload.secret.secretId,
|
||||||
|
version: payload.secret.version,
|
||||||
|
wrappingScheme: payload.secret.wrappingScheme,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await decryptChatText(chatSecret, payload.message.content);
|
||||||
|
} finally {
|
||||||
|
chatSecret.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
cleanupOutdatedCaches();
|
||||||
|
const precacheManifest = self.__WB_MANIFEST;
|
||||||
|
precacheAndRoute(precacheManifest);
|
||||||
|
|
||||||
|
if (
|
||||||
|
precacheManifest.some((entry) =>
|
||||||
|
(typeof entry === "string" ? entry : entry.url).endsWith("index.html"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
registerRoute(
|
||||||
|
new NavigationRoute(createHandlerBoundToURL("index.html"), {
|
||||||
|
denylist: [/^\/api\//],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
registerRoute(
|
||||||
|
({ request, url }) =>
|
||||||
|
url.origin === self.location.origin &&
|
||||||
|
["font", "image", "style"].includes(request.destination),
|
||||||
|
new CacheFirst({ cacheName: "tensamin-static-v1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
let payload: PushPayload | undefined;
|
||||||
|
try {
|
||||||
|
const value = event.data?.json() as unknown;
|
||||||
|
if (isPushPayload(value)) payload = value;
|
||||||
|
} catch {
|
||||||
|
// The generic notification below is safe for malformed payloads.
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = "Open Tensamin to view the encrypted message.";
|
||||||
|
if (payload) {
|
||||||
|
try {
|
||||||
|
body = await decryptPush(payload);
|
||||||
|
} catch {
|
||||||
|
// Do not leak credential or decryption failures in the notification.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.registration.showNotification(payload?.sender ?? "Tensamin", {
|
||||||
|
body,
|
||||||
|
icon: payload?.avatar || "./icons/icon-192.png",
|
||||||
|
badge: "./icons/notification-badge.png",
|
||||||
|
tag: payload ? `message-${payload.senderId}` : "message",
|
||||||
|
data: { url: payload ? `/chat?id=${payload.senderId}` : "/" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigatorWithBadge = self.navigator as WorkerNavigator & {
|
||||||
|
setAppBadge?: (contents?: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
await navigatorWithBadge.setAppBadge?.().catch(() => undefined);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const target = new URL(
|
||||||
|
String(
|
||||||
|
(event.notification.data as { url?: string } | undefined)?.url ?? "/",
|
||||||
|
),
|
||||||
|
self.location.origin,
|
||||||
|
);
|
||||||
|
const windows = await self.clients.matchAll({
|
||||||
|
type: "window",
|
||||||
|
includeUncontrolled: true,
|
||||||
|
});
|
||||||
|
for (const client of windows) {
|
||||||
|
if ("navigate" in client) await client.navigate(target.href);
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(target.href);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if ((event.data as { type?: string } | undefined)?.type === "SKIP_WAITING") {
|
||||||
|
void self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
18
apps/pwa/src/style.css
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
@media (display-mode: standalone), (display-mode: fullscreen) {
|
||||||
|
[data-pwa-root] {
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-right: env(safe-area-inset-right, 0px);
|
||||||
|
padding-left: env(safe-area-inset-left, 0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (display-mode: window-controls-overlay) and (min-width: 768px) {
|
||||||
|
[data-pwa-navbar] {
|
||||||
|
min-height: env(titlebar-area-height, 3.375rem);
|
||||||
|
padding-left: max(1px, env(titlebar-area-x, 0px));
|
||||||
|
padding-right: max(
|
||||||
|
0px,
|
||||||
|
calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
apps/pwa/src/vite.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
const pwaDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const tauriIcons = resolve(pwaDirectory, "../tauri/src-tauri/icons");
|
||||||
|
const androidResources = resolve(
|
||||||
|
pwaDirectory,
|
||||||
|
"../tauri/src-tauri/gen/android/app/src/main/res",
|
||||||
|
);
|
||||||
|
|
||||||
|
function emitIcons(): Plugin {
|
||||||
|
const icons = [
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-180.png",
|
||||||
|
source: resolve(tauriIcons, "ios/AppIcon-60x60@3x.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-192.png",
|
||||||
|
source: resolve(androidResources, "mipmap-xxxhdpi/ic_launcher.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-96.png",
|
||||||
|
source: resolve(androidResources, "mipmap-xhdpi/ic_launcher.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-512.png",
|
||||||
|
source: resolve(tauriIcons, "icon.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-maskable-512.png",
|
||||||
|
source: resolve(tauriIcons, "icon.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/icon-monochrome-432.png",
|
||||||
|
source: resolve(
|
||||||
|
androidResources,
|
||||||
|
"mipmap-xxxhdpi/ic_launcher_monochrome.png",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: "icons/notification-badge.png",
|
||||||
|
source: resolve(androidResources, "drawable/ic_notification_small.png"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "tensamin-pwa-icons",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((request, response, next) => {
|
||||||
|
const pathname = request.url
|
||||||
|
? new URL(request.url, "http://localhost").pathname.slice(1)
|
||||||
|
: "";
|
||||||
|
const icon = icons.find(({ fileName }) => fileName === pathname);
|
||||||
|
if (!icon) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader("Content-Type", "image/png");
|
||||||
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
|
response.end(readFileSync(icon.source));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
generateBundle() {
|
||||||
|
for (const icon of icons) {
|
||||||
|
this.emitFile({
|
||||||
|
type: "asset",
|
||||||
|
fileName: icon.fileName,
|
||||||
|
source: readFileSync(icon.source),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: "post",
|
||||||
|
handler() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: "link",
|
||||||
|
attrs: {
|
||||||
|
rel: "apple-touch-icon",
|
||||||
|
sizes: "180x180",
|
||||||
|
href: "./icons/icon-180.png",
|
||||||
|
},
|
||||||
|
injectTo: "head",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "meta",
|
||||||
|
attrs: { name: "apple-mobile-web-app-capable", content: "yes" },
|
||||||
|
injectTo: "head",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "meta",
|
||||||
|
attrs: {
|
||||||
|
name: "apple-mobile-web-app-status-bar-style",
|
||||||
|
content: "black-translucent",
|
||||||
|
},
|
||||||
|
injectTo: "head",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "meta",
|
||||||
|
attrs: {
|
||||||
|
name: "apple-mobile-web-app-title",
|
||||||
|
content: "Tensamin",
|
||||||
|
},
|
||||||
|
injectTo: "head",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "meta",
|
||||||
|
attrs: { name: "theme-color", content: "#006a67" },
|
||||||
|
injectTo: "head",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tensaminPwa(): Plugin[] {
|
||||||
|
return [
|
||||||
|
emitIcons(),
|
||||||
|
...VitePWA({
|
||||||
|
strategies: "injectManifest",
|
||||||
|
srcDir: resolve(pwaDirectory, "src"),
|
||||||
|
filename: "serviceWorker.ts",
|
||||||
|
injectRegister: null,
|
||||||
|
registerType: "prompt",
|
||||||
|
buildBase: "/",
|
||||||
|
manifestFilename: "manifest.json",
|
||||||
|
includeAssets: ["favicon.ico", "icons/*.png"],
|
||||||
|
manifest: {
|
||||||
|
id: "/",
|
||||||
|
name: "Tensamin",
|
||||||
|
short_name: "Tensamin",
|
||||||
|
description: "Private messaging and calls with Tensamin.",
|
||||||
|
start_url: "/",
|
||||||
|
scope: "/",
|
||||||
|
display: "standalone",
|
||||||
|
display_override: ["window-controls-overlay", "standalone"],
|
||||||
|
background_color: "#001f1e",
|
||||||
|
theme_color: "#006a67",
|
||||||
|
categories: ["social", "communication"],
|
||||||
|
orientation: "any",
|
||||||
|
launch_handler: { client_mode: "focus-existing" },
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icons/icon-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-maskable-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-monochrome-432.png",
|
||||||
|
sizes: "432x432",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "monochrome",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: "Chats",
|
||||||
|
short_name: "Chats",
|
||||||
|
url: "/",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icons/icon-96.png",
|
||||||
|
sizes: "96x96",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Settings",
|
||||||
|
short_name: "Settings",
|
||||||
|
url: "/settings",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icons/icon-96.png",
|
||||||
|
sizes: "96x96",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
file_handlers: [
|
||||||
|
{
|
||||||
|
action: "/login",
|
||||||
|
accept: { "application/x-tensamin-user": [".tu"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2,wasm,mp3,wav}"],
|
||||||
|
globIgnores: ["assets/v2/**"],
|
||||||
|
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: "module",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
33
apps/pwa/todo.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Web Push Backend TODO
|
||||||
|
|
||||||
|
The client can subscribe and decrypt version 1 push payloads, but reliable delivery requires backend support.
|
||||||
|
|
||||||
|
- Generate and securely store a VAPID key pair. Expose only the public key to the web build as `VITE_WEB_PUSH_PUBLIC_KEY`.
|
||||||
|
- Add authenticated MTP requests for registering, replacing, and deleting a browser `PushSubscription` per user and installation.
|
||||||
|
- Persist the endpoint, `p256dh`, `auth`, expiration time, stable installation ID, and last-seen time.
|
||||||
|
- Remove subscriptions when a push service returns HTTP 404 or 410 and rate-limit registrations per user.
|
||||||
|
- Send pushes when an encrypted live message cannot be delivered to an active browser client. Define duplicate suppression for clients that receive both MTP and Web Push.
|
||||||
|
- Keep the JSON payload within push-provider limits and use this version 1 shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"senderId": 123,
|
||||||
|
"sender": "Display name",
|
||||||
|
"avatar": "https://optional.example/avatar",
|
||||||
|
"message": { "content": "base64 encrypted message content" },
|
||||||
|
"secret": {
|
||||||
|
"chatId": "123:456",
|
||||||
|
"secretId": "chat:123:456:main",
|
||||||
|
"version": 1,
|
||||||
|
"encryptedSecret": "base64 wrapped chat secret",
|
||||||
|
"kemCiphertext": "base64 KEM ciphertext",
|
||||||
|
"wrappingScheme": "mtp-chat-secret-kem-chacha20poly1305-hkdf-sha256-v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ensure the wrapped secret is intended for the receiving user's MTP keyring. The server must never receive plaintext message content or plaintext chat secrets.
|
||||||
|
- Decide how edits, deletions, reactions, calls, read states, and per-chat notification cancellation map to push events.
|
||||||
|
- Add subscription rotation handling and unregister subscriptions when a user logs out or clears application data.
|
||||||
|
- Configure production HTTPS, SPA route fallback, `application/manifest+json` for `manifest.json`, and `Cache-Control: no-cache` for the service worker.
|
||||||
23
apps/pwa/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client", "vite-plugin-pwa/client", "node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/runtime.tsx", "src/vite.ts"]
|
||||||
|
}
|
||||||
21
apps/pwa/tsconfig.worker.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "WebWorker"],
|
||||||
|
"types": ["vite-plugin-pwa/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/serviceWorker.ts"]
|
||||||
|
}
|
||||||
3
apps/tauri/.gitignore
vendored
|
|
@ -24,6 +24,3 @@ dist-ssr
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.android
|
.android
|
||||||
|
|
||||||
/src-tauri/gen/android/keystore.properties
|
|
||||||
/src-tauri/gen/android/keystore.jks
|
|
||||||
|
|
|
||||||
BIN
apps/tauri/android.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/tauri/background.png
Normal file
|
After Width: | Height: | Size: 7.7 MiB |
96
apps/tauri/flake.lock
generated
|
|
@ -1,96 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
{
|
|
||||||
description = "Tauri 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"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
desktopRuntimeLibs = with pkgs; [
|
|
||||||
glib
|
|
||||||
gtk3
|
|
||||||
gdk-pixbuf
|
|
||||||
pango
|
|
||||||
cairo
|
|
||||||
atk
|
|
||||||
at-spi2-atk
|
|
||||||
alsa-lib
|
|
||||||
dbus
|
|
||||||
cups
|
|
||||||
expat
|
|
||||||
libdrm
|
|
||||||
libgbm
|
|
||||||
libglvnd
|
|
||||||
mesa
|
|
||||||
libxkbcommon
|
|
||||||
libx11
|
|
||||||
libxcomposite
|
|
||||||
libxdamage
|
|
||||||
libxext
|
|
||||||
libxfixes
|
|
||||||
libxrandr
|
|
||||||
libxcb
|
|
||||||
systemd
|
|
||||||
nspr
|
|
||||||
nss
|
|
||||||
];
|
|
||||||
in {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs;
|
|
||||||
[
|
|
||||||
jdk17
|
|
||||||
rustToolchain
|
|
||||||
gradle
|
|
||||||
nodejs
|
|
||||||
pkg-config
|
|
||||||
]
|
|
||||||
++ desktopRuntimeLibs
|
|
||||||
++ [
|
|
||||||
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}"
|
|
||||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath desktopRuntimeLibs}:$LD_LIBRARY_PATH"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
8
apps/tauri/logo.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"default": "./logo.svg",
|
||||||
|
|
||||||
|
"android_fg": "./android.png",
|
||||||
|
"android_bg": "./background.png",
|
||||||
|
"android_fg_scale": 100,
|
||||||
|
"android_monochrome": "./monochrome.png"
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 997 KiB |
271
apps/tauri/logo.svg
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="90.476906mm"
|
||||||
|
height="90.476906mm"
|
||||||
|
viewBox="0 0 90.476906 90.476906"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
sodipodi:docname="logo_square_outline_gen.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="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: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="swatch15"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop15" /></linearGradient><linearGradient
|
||||||
|
id="swatch10"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop10" /></linearGradient><linearGradient
|
||||||
|
id="swatch3"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#000000;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop3" /></linearGradient><linearGradient
|
||||||
|
id="swatch2"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#b8f8ff;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop2" /></linearGradient><linearGradient
|
||||||
|
id="swatch1"
|
||||||
|
inkscape:swatch="solid"><stop
|
||||||
|
style="stop-color:#031616;stop-opacity:1;"
|
||||||
|
offset="0"
|
||||||
|
id="stop1" /></linearGradient><inkscape:path-effect
|
||||||
|
effect="fillet_chamfer"
|
||||||
|
id="path-effect2"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
|
||||||
|
radius="3"
|
||||||
|
unit="px"
|
||||||
|
method="auto"
|
||||||
|
mode="F"
|
||||||
|
chamfer_steps="1"
|
||||||
|
flexible="false"
|
||||||
|
use_knot_distance="true"
|
||||||
|
apply_no_radius="true"
|
||||||
|
apply_with_radius="true"
|
||||||
|
only_selected="false"
|
||||||
|
hide_knots="false" /><inkscape:path-effect
|
||||||
|
effect="bspline"
|
||||||
|
id="path-effect6"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1.3"
|
||||||
|
weight="33.333333"
|
||||||
|
steps="2"
|
||||||
|
helper_size="0"
|
||||||
|
apply_no_weight="true"
|
||||||
|
apply_with_weight="true"
|
||||||
|
only_selected="false"
|
||||||
|
uniform="false" /><inkscape:path-effect
|
||||||
|
effect="spiro"
|
||||||
|
id="path-effect5"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1" /><clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath1"><path
|
||||||
|
id="path3"
|
||||||
|
style="fill:#043b3c;stroke-width:0.320821"
|
||||||
|
inkscape:label="arrow"
|
||||||
|
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
|
||||||
|
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient15"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient16"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient17"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient18"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient19"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient20"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient21"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="232.99823"
|
||||||
|
y1="238.94554"
|
||||||
|
x2="337.22971"
|
||||||
|
y2="238.94554" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient22"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient23"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient24"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#swatch1"
|
||||||
|
id="linearGradient25"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="63.191292"
|
||||||
|
y1="148.5"
|
||||||
|
x2="146.82355"
|
||||||
|
y2="148.5" /><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
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-281.06837,-119.33314)"
|
||||||
|
><g
|
||||||
|
id="g2"
|
||||||
|
transform="matrix(0.77770172,0,0,0.82714179,104.17421,-33.291714)"
|
||||||
|
inkscape:label="background"
|
||||||
|
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
clip-path="url(#clipPath1)"><path
|
||||||
|
id="rect1"
|
||||||
|
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1"
|
||||||
|
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3"
|
||||||
|
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3-6"
|
||||||
|
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
|
||||||
|
id="rect1-1-3-6-2"
|
||||||
|
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
|
||||||
|
transform="rotate(-75)"
|
||||||
|
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><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
|
||||||
|
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
|
||||||
|
id="g1"
|
||||||
|
inkscape:label="outline"
|
||||||
|
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
|
||||||
|
id="path15-3-5"
|
||||||
|
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
inkscape:label="filler"
|
||||||
|
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
|
||||||
|
transform="translate(159.54375,40.87617)" /></g><path
|
||||||
|
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
|
||||||
|
id="path2"
|
||||||
|
sodipodi:nodetypes="cccccccc"
|
||||||
|
inkscape:label="bolt" /><path
|
||||||
|
id="path1"
|
||||||
|
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
|
||||||
|
inkscape:label="dark_outline"
|
||||||
|
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
BIN
apps/tauri/monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/tauri/monochrome_cropped.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -4,48 +4,33 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./context": {
|
|
||||||
"types": "./src/context.tsx",
|
|
||||||
"default": "./src/context.tsx"
|
|
||||||
},
|
|
||||||
"./controls": {
|
|
||||||
"types": "./src/windowControls.tsx",
|
|
||||||
"default": "./src/windowControls.tsx"
|
|
||||||
},
|
|
||||||
"./deeplinkHandler": {
|
"./deeplinkHandler": {
|
||||||
"types": "./src/deeplinkHandler.tsx",
|
"types": "./src/deeplinkHandler.tsx",
|
||||||
"default": "./src/deeplinkHandler.tsx"
|
"default": "./src/deeplinkHandler.tsx"
|
||||||
},
|
|
||||||
"./qrCodeScanner": {
|
|
||||||
"types": "./src/qrCodeScanner.tsx",
|
|
||||||
"default": "./src/qrCodeScanner.tsx"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:mobile:raw": "tauri android dev",
|
"dev:mobile:raw": "adb reverse tcp:3000 tcp:3000 && tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
|
||||||
|
"start-adb:mobile:raw": "adb devices",
|
||||||
"build:mobile:raw": "tauri android build",
|
"build:mobile:raw": "tauri android build",
|
||||||
"dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi",
|
"build:mobile:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug",
|
||||||
"build:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi",
|
"dev:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run dev:mobile:raw; else pnpm run dev:mobile:raw; fi",
|
||||||
"dev:desktop:raw": "tauri dev",
|
"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:desktop:raw": "tauri build",
|
"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",
|
||||||
"dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi",
|
"gen-icons": "tauri icon ./logo.json && node scripts/sync-electron-icons.ts",
|
||||||
"build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi",
|
"format": "pnpm exec prettier --write .",
|
||||||
"gen-icons": "tauri icon ./logo.png",
|
|
||||||
"format": "bunx prettier --write .",
|
|
||||||
"lint": "eslint src"
|
"lint": "eslint src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@methanium/ui": "*",
|
||||||
"@tauri-apps/plugin-barcode-scanner": "~2",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tauri-apps/plugin-deep-link": "~2",
|
"@tauri-apps/plugin-deep-link": "~2.4.9",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
|
||||||
"@tensamin/ui": "*",
|
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"lucide-react": "^1.14.0",
|
"react": "^19.2.8",
|
||||||
"react": "^19.2.0",
|
"react-dom": "^19.2.8"
|
||||||
"react-dom": "^19.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2"
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
|
"@types/node": "^26.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Outputs
|
|
||||||
|
|
||||||
- ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
|
||||||
- ./src-tauri/target/release/bundle/deb/Tensamin*{version}*{arch}.deb
|
|
||||||
- ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm
|
|
||||||
68
apps/tauri/render-version.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Args
|
||||||
|
const isUnrender = process.argv.includes("--unrender");
|
||||||
|
|
||||||
|
// Version Source
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, "utf8"));
|
||||||
|
|
||||||
|
const packageVersion: string = packageJson.version;
|
||||||
|
|
||||||
|
if (!packageVersion && !isUnrender) {
|
||||||
|
throw new Error("No version found in package.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetVersion = isUnrender ? PLACEHOLDER_VERSION : packageVersion;
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function updateCargoToml(content: string): string {
|
||||||
|
const regex = /^version\s*=\s*".*"$/m;
|
||||||
|
|
||||||
|
if (!regex.test(content)) {
|
||||||
|
throw new Error("Could not find version field in Cargo.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(regex, `version = "${targetVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTauriConfig(content: string): string {
|
||||||
|
const regex = /"version"\s*:\s*".*"/;
|
||||||
|
|
||||||
|
if (!regex.test(content)) {
|
||||||
|
throw new Error("Could not find version field in tauri.conf.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(regex, `"version": "${targetVersion}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Cargo.toml
|
||||||
|
const cargoToml = fs.readFileSync(cargoTomlPath, "utf8");
|
||||||
|
|
||||||
|
const updatedCargoToml = updateCargoToml(cargoToml);
|
||||||
|
|
||||||
|
fs.writeFileSync(cargoTomlPath, updatedCargoToml, "utf8");
|
||||||
|
|
||||||
|
// Update tauri.conf.json
|
||||||
|
const tauriConfig = fs.readFileSync(tauriConfigPath, "utf8");
|
||||||
|
|
||||||
|
const updatedTauriConfig = updateTauriConfig(tauriConfig);
|
||||||
|
|
||||||
|
fs.writeFileSync(tauriConfigPath, updatedTauriConfig, "utf8");
|
||||||
|
|
||||||
|
// Finished
|
||||||
|
if (isUnrender) {
|
||||||
|
console.log(`Unrendered versions back to ${PLACEHOLDER_VERSION}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Rendered version ${targetVersion}`);
|
||||||
|
}
|
||||||
53
apps/tauri/scripts/delete-mobile.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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);
|
||||||
31
apps/tauri/scripts/sync-electron-icons.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { copyFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const tauriDir = resolve(scriptDir, "..");
|
||||||
|
const clientDir = resolve(tauriDir, "../..");
|
||||||
|
const tauriIconsDir = join(tauriDir, "src-tauri", "icons");
|
||||||
|
const electronIconsDir = join(clientDir, "apps", "electron", "build", "icons");
|
||||||
|
|
||||||
|
const desktopIcons = [
|
||||||
|
"32x32.png",
|
||||||
|
"64x64.png",
|
||||||
|
"128x128.png",
|
||||||
|
"128x128@2x.png",
|
||||||
|
"icon.png",
|
||||||
|
"icon.ico",
|
||||||
|
"icon.icns",
|
||||||
|
];
|
||||||
|
|
||||||
|
await mkdir(electronIconsDir, { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
desktopIcons.map((icon) =>
|
||||||
|
copyFile(join(tauriIconsDir, icon), join(electronIconsDir, icon)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Synced ${desktopIcons.length} desktop icons to ${electronIconsDir}`,
|
||||||
|
);
|
||||||
4032
apps/tauri/src-tauri/Cargo.lock
generated
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tensamin"
|
name = "tensamin"
|
||||||
version = "0.1.0"
|
version = "0.0.0"
|
||||||
description = "Privacy focused messanger"
|
description = "Privacy focused messanger"
|
||||||
authors = ["methanium"]
|
authors = ["methanium"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -15,43 +15,34 @@ name = "mobile_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22"
|
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
|
||||||
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "a5c8d4f0c898c78351e9d54124886c86e789a22a", features = ["client", "crypto"] }
|
||||||
|
webpki-root-certs = "1"
|
||||||
tauri-plugin-deep-link = "2"
|
tauri-plugin-deep-link = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies]
|
tauri-plugin-log = "2"
|
||||||
xcap = "0.4.1"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
||||||
version = "2"
|
version = "2"
|
||||||
features = []
|
features = []
|
||||||
default-features = true
|
default-features = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies.tauri]
|
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
|
||||||
version = "2"
|
version = "2"
|
||||||
features = ["compression", "common-controls-v6", "dynamic-acl"]
|
features = []
|
||||||
default-features = true
|
default-features = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies.tauri]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
version = "2"
|
jni = "0.22"
|
||||||
features = ["common-controls-v6", "cef", "compression", "dynamic-acl", "x11"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies.tauri]
|
|
||||||
version = "2"
|
|
||||||
features = ["x11", "common-controls-v6", "cef", "compression", "dynamic-acl"]
|
|
||||||
default-features = false
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
|
||||||
tauri-plugin-barcode-scanner = "2"
|
|
||||||
tauri-plugin-app-events = "0.2"
|
|
||||||
|
|
||||||
[patch.crates-io.tauri]
|
[patch.crates-io.tauri]
|
||||||
git = "https://github.com/tauri-apps/tauri"
|
git = "https://github.com/tauri-apps/tauri"
|
||||||
branch = "feat/cef"
|
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,9 @@
|
||||||
"main"
|
"main"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"core:window:default",
|
"deep-link:default",
|
||||||
"core:window:allow-start-dragging",
|
"notification:default",
|
||||||
"core:window:allow-close",
|
"log:default"
|
||||||
"core:window:allow-toggle-maximize",
|
|
||||||
"core:window:allow-minimize",
|
|
||||||
"core:event:default",
|
|
||||||
"deep-link:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
{
|
{
|
||||||
"identifier": "mobile-capability",
|
"identifier": "mobile-capability",
|
||||||
"platforms": [
|
"platforms": ["android", "iOS"],
|
||||||
"android",
|
"windows": ["main"],
|
||||||
"iOS"
|
|
||||||
],
|
|
||||||
"windows": [
|
|
||||||
"main"
|
|
||||||
],
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
"core:event:default",
|
||||||
"deep-link:default",
|
"deep-link:default",
|
||||||
"app-events:default",
|
"notification:default",
|
||||||
"barcode-scanner:default",
|
"log:default"
|
||||||
"barcode-scanner:allow-scan",
|
|
||||||
"barcode-scanner:allow-cancel"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ android {
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
isJniDebuggable = true
|
isJniDebuggable = true
|
||||||
|
|
@ -60,26 +62,29 @@ android {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "1.8"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = JvmTarget.JVM_1_8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rust {
|
rust {
|
||||||
rootDirRel = "../../../"
|
rootDirRel = "../../../"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.webkit:webkit:1.14.0")
|
implementation("androidx.webkit:webkit:1.16.0")
|
||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.14.0")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
apply(from = "tauri.build.gradle.kts")
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,4 @@
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<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 -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
@ -39,6 +49,30 @@
|
||||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MediaProjectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
|
<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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,114 @@
|
||||||
package net.tensamin.client
|
package net.tensamin.client
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.ViewGroup
|
import android.view.MotionEvent
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private var contentRoot: FrameLayout? = null
|
private var contentRoot: FrameLayout? = null
|
||||||
private var contentChild: android.view.View? = null
|
private var contentChild: android.view.View? = null
|
||||||
private var previousUsableHeight = 0
|
private var previousUsableHeight = 0
|
||||||
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||||
|
private var mediaWebView: WebView? = null
|
||||||
|
private var pendingScreenAudio: Boolean? = null
|
||||||
|
|
||||||
|
private val screenCaptureLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
val includeAudio = pendingScreenAudio
|
||||||
|
pendingScreenAudio = null
|
||||||
|
|
||||||
|
if (includeAudio == null) return@registerForActivityResult
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode != Activity.RESULT_OK || data == null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission was denied")
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cameraPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) {
|
||||||
|
emitCameraPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val screenAudioPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
|
webView.settings.apply {
|
||||||
|
setSupportZoom(false)
|
||||||
|
builtInZoomControls = false
|
||||||
|
displayZoomControls = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockingMultiTouch = false
|
||||||
|
webView.setOnTouchListener { _, event ->
|
||||||
|
val shouldBlock = blockingMultiTouch || event.pointerCount > 1
|
||||||
|
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_POINTER_DOWN -> {
|
||||||
|
// Cancel the one-finger gesture before consuming the rest of the pinch.
|
||||||
|
MotionEvent.obtain(event).let { cancelEvent ->
|
||||||
|
cancelEvent.action = MotionEvent.ACTION_CANCEL
|
||||||
|
webView.onTouchEvent(cancelEvent)
|
||||||
|
cancelEvent.recycle()
|
||||||
|
}
|
||||||
|
blockingMultiTouch = true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> blockingMultiTouch = false
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeAccessibilityBridge.attach(webView)
|
||||||
|
mediaWebView = webView
|
||||||
|
MobileMediaEvents.attach(webView)
|
||||||
|
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
NativeMtpBridge.nativeAttach(applicationContext)
|
||||||
|
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||||
|
NativeMtpBridge.startService(this)
|
||||||
|
}
|
||||||
|
NativeAccessibilityBridge.nativeAttach(applicationContext)
|
||||||
installKeyboardResizeWorkaround()
|
installKeyboardResizeWorkaround()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
NativeMtpBridge.nativeSetUiState(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
NativeMtpBridge.nativeSetUiState(false)
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
attachLayoutListener?.let { listener ->
|
attachLayoutListener?.let { listener ->
|
||||||
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
|
||||||
|
|
@ -30,9 +116,87 @@ class MainActivity : TauriActivity() {
|
||||||
attachLayoutListener = null
|
attachLayoutListener = null
|
||||||
contentRoot = null
|
contentRoot = null
|
||||||
contentChild = null
|
contentChild = null
|
||||||
|
mediaWebView?.let {
|
||||||
|
NativeAccessibilityBridge.detach(it)
|
||||||
|
it.removeJavascriptInterface("tensaminMobileMedia")
|
||||||
|
}
|
||||||
|
mediaWebView = null
|
||||||
|
MobileMediaEvents.detach()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (pendingScreenAudio != null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission is already pending")
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingScreenAudio = includeAudio
|
||||||
|
if (
|
||||||
|
includeAudio &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
} else {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchScreenCaptureIntent() {
|
||||||
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScreenShare() {
|
||||||
|
runOnUiThread {
|
||||||
|
pendingScreenAudio = null
|
||||||
|
MediaProjectionService.stop(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestCameraPermission() {
|
||||||
|
runOnUiThread {
|
||||||
|
cameraPermissionLauncher.launch(
|
||||||
|
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitCameraPermission() {
|
||||||
|
val detail = JSONObject()
|
||||||
|
.put(
|
||||||
|
"camera",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
"microphone",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class MobileMediaJavascriptInterface {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
this@MainActivity.startScreenShare(includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun stopScreenShare() {
|
||||||
|
this@MainActivity.stopScreenShare()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun requestCameraPermission() {
|
||||||
|
this@MainActivity.requestCameraPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun installKeyboardResizeWorkaround() {
|
private fun installKeyboardResizeWorkaround() {
|
||||||
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
||||||
contentRoot = content
|
contentRoot = content
|
||||||
|
|
@ -68,16 +232,20 @@ class MainActivity : TauriActivity() {
|
||||||
child: android.view.View,
|
child: android.view.View,
|
||||||
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
|
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
|
||||||
) {
|
) {
|
||||||
val visibleFrame = Rect()
|
|
||||||
child.getWindowVisibleDisplayFrame(visibleFrame)
|
|
||||||
|
|
||||||
val rootHeight = child.rootView.height
|
val rootHeight = child.rootView.height
|
||||||
if (rootHeight <= 0) return
|
if (rootHeight <= 0) return
|
||||||
|
|
||||||
val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
|
val imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true
|
||||||
val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom)
|
val imeHeight = if (imeVisible) {
|
||||||
val keyboardVisible = keyboardHeight > rootHeight * 0.15
|
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||||
val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val usableHeight = if (imeHeight in 1 until rootHeight) {
|
||||||
|
rootHeight - imeHeight
|
||||||
|
} else {
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
|
|
||||||
if (previousUsableHeight == usableHeight) return
|
if (previousUsableHeight == usableHeight) return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
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.app.NotificationCompat
|
||||||
|
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 = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_share)
|
||||||
|
.setContentTitle("Tensamin is sharing your screen")
|
||||||
|
.setContentText("Tap Stop to end screen sharing")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(NotificationCompat.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
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.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Process
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class MtpForegroundService : Service() {
|
||||||
|
private var started = false
|
||||||
|
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = refreshNotification()
|
||||||
|
override fun onLost(network: Network) = refreshNotification()
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
|
||||||
|
refreshNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
getSystemService(ConnectivityManager::class.java)
|
||||||
|
.registerDefaultNetworkCallback(networkCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
connectionStatus = "Connecting"
|
||||||
|
val notification = buildNotification(this, displayedStatus(this))
|
||||||
|
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?) {
|
||||||
|
val preferences = getSharedPreferences(SERVICE_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (now - preferences.getLong(LAST_TASK_RESTART, 0) < TASK_RESTART_COOLDOWN_MS) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preferences.edit().putLong(LAST_TASK_RESTART, now).commit()
|
||||||
|
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
|
||||||
|
startService(Intent(this, MtpForegroundService::class.java))
|
||||||
|
}
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
// Tauri cannot recreate its WebView after the UI task is removed while this process survives.
|
||||||
|
Process.killProcess(Process.myPid())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
||||||
|
if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNotification() {
|
||||||
|
updateNotification(this, connectionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
private const val SERVICE_PREFERENCES = "tensamin-service"
|
||||||
|
private const val LAST_TASK_RESTART = "last-task-restart"
|
||||||
|
private const val TASK_RESTART_COOLDOWN_MS = 15_000L
|
||||||
|
@Volatile private var connectionStatus = "Connecting"
|
||||||
|
|
||||||
|
fun updateNotification(context: Context, status: String) {
|
||||||
|
if (!MtpSecureStore.isEnabled(context)) return
|
||||||
|
connectionStatus = status
|
||||||
|
createChannel(context)
|
||||||
|
context.getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIFICATION_ID, buildNotification(context, displayedStatus(context)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun displayedStatus(context: Context): String {
|
||||||
|
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
val network = connectivity.activeNetwork ?: return "No network"
|
||||||
|
val capabilities = connectivity.getNetworkCapabilities(network) ?: return "No network"
|
||||||
|
return if (
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) connectionStatus else "No network"
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
object NativeAccessibilityBridge {
|
||||||
|
const val DEFAULT_INITIAL_SCALE = 290
|
||||||
|
private const val MIN_INITIAL_SCALE = 210
|
||||||
|
private const val MAX_INITIAL_SCALE = 500
|
||||||
|
private const val PREFERENCES = "tensamin-accessibility"
|
||||||
|
private const val INITIAL_SCALE = "initial-scale"
|
||||||
|
|
||||||
|
private var webView = WeakReference<WebView>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
System.loadLibrary("mobile_lib")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic external fun nativeAttach(context: Context)
|
||||||
|
|
||||||
|
fun attach(webView: WebView) {
|
||||||
|
this.webView = WeakReference(webView)
|
||||||
|
webView.setInitialScale(getInitialScale(webView.context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach(webView: WebView) {
|
||||||
|
if (this.webView.get() === webView) this.webView.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getInitialScale(context: Context): Int {
|
||||||
|
val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
val storedScale = preferences.getInt(INITIAL_SCALE, DEFAULT_INITIAL_SCALE)
|
||||||
|
val scale = storedScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
|
||||||
|
if (scale != storedScale) preferences.edit().putInt(INITIAL_SCALE, scale).apply()
|
||||||
|
return scale
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setInitialScale(context: Context, initialScale: Int) {
|
||||||
|
val nextScale = initialScale.coerceIn(MIN_INITIAL_SCALE, MAX_INITIAL_SCALE)
|
||||||
|
context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putInt(INITIAL_SCALE, nextScale)
|
||||||
|
.apply()
|
||||||
|
webView.get()?.let { currentWebView ->
|
||||||
|
currentWebView.post { currentWebView.setInitialScale(nextScale) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
|
|
@ -1,5 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 35 KiB |
|
|
@ -1,4 +1,4 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">tensamin</string>
|
<string name="app_name">Tensamin</string>
|
||||||
<string name="main_activity_title">tensamin</string>
|
<string name="main_activity_title">Tensamin</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import com.android.build.gradle.LibraryExtension
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
|
|
@ -7,7 +7,7 @@ buildscript {
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.android.tools.build:gradle:8.11.0")
|
classpath("com.android.tools.build:gradle:8.11.0")
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,3 @@ dependencies {
|
||||||
compileOnly(gradleApi())
|
compileOnly(gradleApi())
|
||||||
implementation("com.android.tools.build:gradle:8.11.0")
|
implementation("com.android.tools.build:gradle:8.11.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,12 @@ import org.gradle.api.GradleException
|
||||||
import org.gradle.api.logging.LogLevel
|
import org.gradle.api.logging.LogLevel
|
||||||
import org.gradle.api.tasks.Input
|
import org.gradle.api.tasks.Input
|
||||||
import org.gradle.api.tasks.TaskAction
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
import org.gradle.process.ExecOperations
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
open class BuildTask : DefaultTask() {
|
open class BuildTask @Inject constructor(
|
||||||
|
private val execOperations: ExecOperations,
|
||||||
|
) : DefaultTask() {
|
||||||
@Input
|
@Input
|
||||||
var rootDirRel: String? = null
|
var rootDirRel: String? = null
|
||||||
@Input
|
@Input
|
||||||
|
|
@ -50,7 +54,7 @@ open class BuildTask : DefaultTask() {
|
||||||
val release = release ?: throw GradleException("release cannot be null")
|
val release = release ?: throw GradleException("release cannot be null")
|
||||||
val args = listOf("tauri", "android", "android-studio-script");
|
val args = listOf("tauri", "android", "android-studio-script");
|
||||||
|
|
||||||
project.exec {
|
execOperations.exec {
|
||||||
workingDir(File(project.projectDir, rootDirRel))
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
executable(executable)
|
executable(executable)
|
||||||
args(args)
|
args(args)
|
||||||
|
|
@ -65,4 +69,4 @@ open class BuildTask : DefaultTask() {
|
||||||
args(listOf("--target", target))
|
args(listOf("--target", target))
|
||||||
}.assertNormalExitValue()
|
}.assertNormalExitValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#Tue May 10 19:22:52 CST 2022
|
#Tue May 10 19:22:52 CST 2022
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 15 KiB |