Compare commits

...
Author SHA1 Message Date
ab2ac4813a Merge pull request 'feat(pwa): remove install prompt' (#76) from dev into main
All checks were successful
/ build-web (push) Successful in 5m0s
/ build-desktop (linux) (push) Successful in 11m28s
/ build-mobile (push) Successful in 29m19s
/ release (push) Successful in 2m5s
Reviewed-on: #76
2026-08-30 16:59:43 +03:00
0c0a341f1e
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 5m22s
/ build-desktop (linux) (push) Successful in 10m44s
/ build-mobile (push) Successful in 28m17s
/ release (push) Successful in 2m4s
2026-08-30 15:58:40 +02:00
f74ca52f4f
feat(nix-flake): rework nix package 2026-08-30 15:58:34 +02:00
Alex Emmet
94f11f60f6
Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m37s
/ build-desktop (linux) (push) Successful in 12m5s
/ build-mobile (push) Successful in 25m44s
/ release (push) Successful in 1m58s
2026-08-29 16:11:40 +02:00
Alex Emmet
f5461bbd42
chore(mtp): update type-maps 2026-08-29 16:11:24 +02:00
7c9d9529b4
feat(workflows): allow manual runs for the dev deploy
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m13s
/ build-desktop (linux) (push) Successful in 14m39s
/ build-mobile (push) Successful in 38m15s
/ release (push) Successful in 3m8s
2026-08-29 12:57:44 +02:00
8a04b327fc
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-mobile (push) Failing after 1m44s
/ build-desktop (linux) (push) Failing after 10m56s
/ build-web (push) Failing after 10m56s
/ release (push) Has been cancelled
2026-08-28 20:02:35 +02:00
0a304e44f2
feat(mtp): move useful stuff over to mtp directly
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m14s
/ build-desktop (linux) (push) Successful in 11m36s
/ build-mobile (push) Successful in 24m26s
/ release (push) Successful in 1m39s
2026-08-27 23:30:33 +02:00
094cb910aa
Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 7m14s
/ build-mobile (push) Failing after 12m6s
/ build-desktop (linux) (push) Successful in 12m16s
/ release (push) Has been skipped
# Conflicts:
#	apps/pwa/src/runtime.tsx
#	flake.nix
#	packages/mtp/src/context.test.tsx
#	packages/mtp/src/context.tsx
2026-08-27 19:39:52 +02:00
cf17365c8f
Merge branch 'main' into dev 2026-08-27 19:36:41 +02:00
aa34bd962b
temp(mtp): temp commit stuff that needs to get a rework 2026-08-27 19:35:17 +02:00
1d1b304bcf
feat(workflows): remove env thing 2026-08-27 19:32:48 +02:00
forgejo-actions
0fbedf6733 (qol): update release flake hash
Some checks were skipped
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-27 12:59:08 +00:00
96b81d30b1
feat(pwa): keep service workers from getting registerd outside the pwa
Some checks failed
/ build-web (push) Successful in 5m32s
/ build-desktop (linux) (push) Successful in 9m55s
/ build-mobile (push) Successful in 18m33s
/ release (push) Failing after 1m42s
2026-08-27 14:38:53 +02:00
edcf0e15dd
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m3s
/ build-desktop (linux) (push) Successful in 11m9s
/ build-mobile (push) Failing after 11m15s
/ release (push) Has been skipped
2026-08-25 21:00:32 +02:00
5056f084b4
chore(mtp): update type-maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-desktop (linux) (push) Failing after 4s
/ build-web (push) Failing after 4s
/ build-mobile (push) Failing after 5s
/ release (push) Has been skipped
2026-08-25 20:58:32 +02:00
ede0199858
chore(mtp): udpate type maps
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m37s
/ build-desktop (linux) (push) Failing after 3m58s
/ build-mobile (push) Failing after 6m4s
/ release (push) Has been skipped
2026-08-25 20:48:56 +02:00
88a094c8bf
feat(pwa): remove update toast
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-25 20:46:14 +02:00
e43e03ff12
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-20 20:56:38 +02:00
97270abed8
Update workflows 2026-08-20 20:56:30 +02:00
2a55c87df1
[Updt] Mtp 0.3.0
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m32s
/ build-desktop (linux) (push) Failing after 2m47s
/ build-mobile (push) Failing after 5m29s
/ release (push) Has been skipped
2026-08-20 17:05:53 +02:00
forgejo-actions
57c7ceb27a (qol): update release flake hash 2026-08-18 20:46:45 +00:00
forgejo-actions
d0918ff384 (qol): update release flake hash 2026-08-18 20:46:44 +00:00
caa7d65b84
feat(pwa): remove install prompt
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 5m40s
/ build-desktop (linux) (push) Successful in 10m33s
/ build-mobile (push) Successful in 23m6s
/ release (push) Successful in 2m15s
2026-08-18 21:40:57 +02:00
0ac20f09e4 Merge pull request 'Add pwa & stuff' (#75) from dev into main
All checks were successful
/ build-web (push) Successful in 5m44s
/ build-desktop (linux) (push) Successful in 10m41s
/ build-mobile (push) Successful in 20m49s
/ release (push) Successful in 1m46s
Reviewed-on: #75
2026-08-18 22:32:16 +03:00
7b36218ffa
feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
2026-08-18 18:44:45 +02:00
73db7e6b2b Update flake.nix 2026-08-12 15:12:05 +02:00
6e977bca7e
feat(mobile): use drawer for gif picker
Some checks failed
/ build-desktop (linux) (push) Failing after 6m36s
/ build-web (push) Failing after 7m7s
/ build-mobile (push) Failing after 9m21s
/ release (push) Has been skipped
fix(mobile): some ui bugs
fix(mobile): disable pinch zooming
2026-08-12 02:16:56 +02:00
eaf3f61ebd
feat(android): add zoom slider
All checks were successful
/ build-web (push) Successful in 8m21s
/ build-desktop (linux) (push) Successful in 18m41s
/ build-mobile (push) Successful in 44m57s
/ release (push) Successful in 9m27s
fix(android): gray screen bug
2026-08-11 15:50:52 +02:00
6f4cc62530
feat(gif-picker): add gif groups
Some checks failed
/ build-web (push) Successful in 8m40s
/ build-mobile (push) Failing after 11m17s
/ build-desktop (linux) (push) Successful in 14m8s
/ release (push) Has been skipped
feat(gif-picker): increase default size
2026-08-11 13:01:09 +02:00
eaeff1b8d5
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m15s
/ build-mobile (push) Failing after 11m13s
/ build-desktop (linux) (push) Successful in 13m21s
/ release (push) Has been skipped
2026-08-10 15:27:53 +02:00
4016181db9
feat(mtp): update type maps
fix(renovate): workflow not pulling submodules
2026-08-10 15:27:47 +02:00
2299555fe6 Merge pull request 'chore(deps): update dependency com.android.tools.build:gradle to v9' (#73) from renovate/com.android.tools.build-gradle-9.x into dev
Some checks failed
/ build-web (push) Successful in 12m49s
/ build-mobile (push) Failing after 15m57s
/ build-desktop (linux) (push) Successful in 17m37s
/ release (push) Has been skipped
2026-08-10 14:00:35 +02:00
f50b4eaebe chore(deps): update dependency com.android.tools.build:gradle to v9
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 9m12s
Dependency builds / Build desktop (pull_request) Failing after 10m6s
Dependency builds / Build mobile (pull_request) Failing after 15m5s
2026-08-10 14:00:33 +02:00
825be4eb33
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m17s
/ build-mobile (push) Failing after 10m56s
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-10 13:48:42 +02:00
1c90a0c859
feat(status): make the set-status menu work
feat(user): optimise get() function for react
qol(todos): update todos
2026-08-10 13:48:32 +02:00
6dee7e8dcc Merge pull request 'chore(deps): update rust crate reqwest to 0.13' (#72) from renovate/reqwest-0.x into dev
Some checks failed
/ build-web (push) Successful in 12m8s
/ build-mobile (push) Failing after 15m1s
/ build-desktop (linux) (push) Successful in 17m16s
/ release (push) Has been skipped
2026-08-10 13:00:41 +02:00
41258a8483 chore(deps): update rust crate reqwest to 0.13
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 8m47s
Dependency builds / Build desktop (pull_request) Failing after 8m21s
Dependency builds / Build mobile (pull_request) Failing after 13m21s
2026-08-10 13:00:39 +02:00
746f0cc9a4 Merge pull request 'chore(deps): update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v2.4.10' (#71) from renovate/org.jetbrains.kotlin-kotlin-gradle-plugin-2.x into dev
Some checks failed
/ build-web (push) Successful in 10m21s
/ build-mobile (push) Failing after 19m34s
/ build-desktop (linux) (push) Successful in 19m43s
/ release (push) Has been skipped
2026-08-10 12:00:38 +02:00
c1baf3576d chore(deps): update dependency org.jetbrains.kotlin:kotlin-gradle-plugin to v2.4.10
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 12m5s
Dependency builds / Build mobile (pull_request) Failing after 16m5s
Dependency builds / Build desktop (pull_request) Failing after 16m13s
2026-08-10 12:00:36 +02:00
7ffbab6b08 Merge pull request 'chore(deps): update tauri-build digest to 2f11853' (#70) from renovate/tauri-build-digest into dev
All checks were successful
/ build-web (push) Successful in 13m53s
/ build-desktop (linux) (push) Successful in 19m49s
/ build-mobile (push) Successful in 30m21s
/ release (push) Successful in 3m24s
2026-08-10 11:00:37 +02:00
f1af0f210d chore(deps): update tauri-build digest to e2e585a
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 10m29s
Dependency builds / Build desktop (pull_request) Failing after 11m47s
Dependency builds / Build mobile (pull_request) Failing after 15m0s
2026-08-10 11:00:32 +02:00
a40bf3a2c2 Merge pull request 'chore(deps): update mtp-transport digest to b067614' (#69) from renovate/mtp-transport-digest into dev
All checks were successful
/ build-web (push) Successful in 8m22s
/ build-desktop (linux) (push) Successful in 13m59s
/ build-mobile (push) Successful in 25m47s
/ release (push) Successful in 3m26s
2026-08-10 00:16:46 +02:00
dbb407606b
feat(markdown): migrate to methanium/ui's markdown
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-10 00:16:42 +02:00
f763af13be chore(deps): update mtp-transport digest to b067614
Some checks failed
renovate/stability-days Updates have not met minimum release age requirement
Dependency builds / Build web (pull_request) Failing after 14m21s
Dependency builds / Build desktop (pull_request) Failing after 14m21s
Dependency builds / Build mobile (pull_request) Failing after 15m6s
2026-08-09 21:01:24 +02:00
f83e71be6b
fix(legal): license copy script
Some checks failed
/ build-web (push) Failing after 6m53s
/ build-desktop (linux) (push) Failing after 7m26s
/ build-mobile (push) Failing after 9m16s
/ release (push) Has been skipped
2026-08-09 20:16:24 +02:00
b7c94123fa
feat(chat): add media overlay
Some checks failed
/ build-desktop (linux) (push) Failing after 4m45s
/ build-web (push) Failing after 5m12s
/ build-mobile (push) Failing after 7m34s
/ release (push) Has been skipped
2026-08-09 16:46:37 +02:00
32671f32fd
wip(chat): overall improvements
Some checks failed
/ build-web (push) Failing after 4m55s
/ build-desktop (linux) (push) Failing after 5m0s
/ build-mobile (push) Failing after 7m32s
/ release (push) Has been skipped
2026-08-09 12:05:04 +02:00
2b4207f8fa
fix(deps): checksums
Some checks failed
/ build-desktop (linux) (push) Failing after 4m58s
/ build-web (push) Failing after 5m3s
/ build-mobile (push) Failing after 7m41s
/ release (push) Has been skipped
2026-08-09 11:53:50 +02:00
5f44b89043
chore(deps): update pnpm-lock.yaml
Some checks failed
/ build-web (push) Failing after 7m9s
/ build-desktop (linux) (push) Failing after 7m52s
/ build-mobile (push) Failing after 12m23s
/ release (push) Has been skipped
2026-08-08 23:30:08 +02:00
31ff5a542f
chore(mtp): update type-maps
Some checks failed
/ build-desktop (linux) (push) Failing after 4m37s
/ build-web (push) Failing after 4m37s
/ build-mobile (push) Failing after 7m11s
/ release (push) Has been skipped
2026-08-07 23:42:44 +02:00
99c38162a6
chore(babysit): cleanup slop
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-07 23:41:49 +02:00
ef24ea4ca0
Merge branch 'dev' of ssh://git.methanium.net/Tensamin/Client into dev
Some checks failed
/ build-web (push) Failing after 1m11s
/ build-mobile (push) Failing after 1m24s
/ build-desktop (linux) (push) Failing after 1m24s
/ release (push) Has been skipped
2026-08-07 23:18:07 +02:00
45d39cb47c
chore(deps): fully update repo (except rust)
Some checks failed
/ build-web (push) Failing after 5m51s
/ build-desktop (linux) (push) Failing after 5m58s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-07 23:10:28 +02:00
c182f2e9ac
Merge branch 'dev' of ssh://git.methanium.net/Tensamin/Client into dev 2026-08-07 23:09:03 +02:00
ec019a4dff
[Upd] User states 2026-08-07 23:05:26 +02:00
b76143e2e2
chore(deps): update everything
Some checks failed
/ build-desktop (linux) (push) Failing after 5m49s
/ build-web (push) Failing after 5m56s
/ build-mobile (push) Failing after 8m38s
/ release (push) Has been skipped
2026-08-07 21:51:35 +02:00
01b68300ab Merge pull request 'chore(deps): update dependency @codemirror/view to v6.43.8' (#60) from renovate/codemirror-view-6.x-lockfile into dev
Some checks failed
/ build-web (push) Failing after 6m18s
/ build-desktop (linux) (push) Failing after 6m25s
/ build-mobile (push) Failing after 9m5s
/ release (push) Has been skipped
2026-08-06 22:00:42 +02:00
3954d776ce chore(deps): update dependency @codemirror/view to v6.43.6
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 22:00:39 +02:00
860fd2ec08 Merge pull request 'chore(deps): update dependency @codemirror/state to v6.7.1' (#59) from renovate/codemirror-state-6.x-lockfile into dev
Some checks failed
/ build-web (push) Successful in 11m14s
/ build-desktop (linux) (push) Successful in 15m23s
/ build-mobile (push) Failing after 16m18s
/ release (push) Has been skipped
2026-08-06 21:00:47 +02:00
dfb82373c6 chore(deps): update dependency @codemirror/state to v6.7.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 21:00:43 +02:00
927a719281 Merge pull request 'chore(deps): update dependency @livekit/components-react to v2.9.23' (#63) from renovate/livekit-components-react-2.x-lockfile into dev
Some checks failed
/ build-web (push) Successful in 10m19s
/ build-desktop (linux) (push) Successful in 15m20s
/ build-mobile (push) Failing after 15m48s
/ release (push) Has been skipped
2026-08-06 20:01:30 +02:00
7f64659e23 Merge pull request 'chore(deps): update dependency @codemirror/lang-markdown to v6.5.2' (#58) from renovate/codemirror-lang-markdown-6.x-lockfile into dev
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-06 20:01:27 +02:00
35769b1aae chore(deps): update dependency @codemirror/lang-markdown to v6.5.1
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 20:01:22 +02:00
a731f8ee42 chore(deps): update dependency @livekit/components-react to v2.9.23
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 19:01:31 +02:00
forgejo-actions
beafb623ba (qol): update release flake hash 2026-08-06 14:47:25 +00:00
forgejo-actions
ac1bdf5ac2 (qol): update release flake hash 2026-08-06 14:47:23 +00:00
22a4cd2f0e
Merge branch 'main' into dev
Some checks failed
/ build-web (push) Failing after 10m19s
/ build-desktop (linux) (push) Failing after 10m19s
/ build-mobile (push) Failing after 12m47s
/ release (push) Has been skipped
2026-08-06 16:19:28 +02:00
3725587232
chore(deps): remove packageManager
Some checks failed
/ build-desktop (linux) (push) Failing after 3m24s
/ build-web (push) Failing after 3m32s
/ build-mobile (push) Failing after 5m15s
/ release (push) Has been skipped
2026-08-06 16:17:12 +02:00
f0dcfbc63e Merge pull request 'chore(deps): update pnpm to v11.20.0' (#54) from renovate/pnpm-11.x into dev
Some checks failed
/ build-web (push) Failing after 3m53s
/ build-desktop (linux) (push) Failing after 3m48s
/ build-mobile (push) Failing after 6m52s
/ release (push) Has been skipped
2026-08-06 14:19:42 +02:00
00ff0b00d4 Merge pull request 'chore(deps): update dependency reselect to v5.2.0' (#53) from renovate/reselect-5.x into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 14:19:40 +02:00
4d72469806 Merge pull request 'chore(deps): update rust crate jni to 0.22' (#56) from renovate/jni-0.x into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 14:19:40 +02:00
forgejo-actions
9ae7dbe35e (qol): update release flake hash 2026-08-06 12:19:37 +00:00
forgejo-actions
2c405af72d (qol): update release flake hash 2026-08-06 12:19:35 +00:00
652dab0351
Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
/ build-web (push) Successful in 8m19s
/ build-desktop (linux) (push) Successful in 14m8s
/ build-mobile (push) Successful in 24m11s
/ release (push) Successful in 3m42s
2026-08-06 13:52:22 +02:00
1ea13acc3b
fix(mobile): build errors 2026-08-06 13:49:16 +02:00
29e80d6d02 chore(deps): update rust crate jni to 0.22 2026-08-06 13:02:05 +02:00
f3ca5d637c chore(deps): update pnpm to v11.20.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 12:02:46 +02:00
565f39c255 chore(deps): update dependency reselect to v5.2.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 12:02:38 +02:00
5090b67deb Merge pull request '(feat): dep updates' (#52) from dev into main
Some checks failed
/ build-web (push) Successful in 17m38s
/ build-desktop (linux) (push) Successful in 26m46s
/ build-mobile (push) Failing after 36m56s
/ release (push) Has been skipped
Reviewed-on: #52
2026-08-06 11:53:46 +02:00
2061c3c393
(feat): enable renovate
Some checks failed
/ build-web (push) Successful in 10m15s
/ build-desktop (linux) (push) Successful in 26m4s
/ build-mobile (push) Failing after 35m54s
/ release (push) Has been skipped
2026-08-06 11:53:05 +02:00
83427a344e
(chore): refresh dependency licenses
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 11:50:56 +02:00
6f9ae31f7d
Merge pull request 'chore(deps): update tauri-build digest to c0bd0d5' (#39) into dev 2026-08-06 11:49:04 +02:00
c442803412
Merge pull request 'chore(deps): update dependency androidx.test.espresso:espresso-core to v3.7.0' (#44) into dev
# Conflicts:
#	apps/tauri/src-tauri/gen/android/app/build.gradle.kts
2026-08-06 11:49:04 +02:00
d59f9455bf
Merge pull request 'chore(deps): update dependency androidx.activity:activity-ktx to v1.13.0' (#43) into dev
# Conflicts:
#	apps/tauri/src-tauri/gen/android/app/build.gradle.kts
2026-08-06 11:48:52 +02:00
6dcec11426
(chore): pause renovate updates 2026-08-06 11:48:34 +02:00
bda3bdcd0a
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 11:47:30 +02:00
8946da05cc
(temp): go back to 0.0.11 2026-08-06 11:47:22 +02:00
60fbe3c9ec Merge pull request 'chore(deps): update dependency androidx.test.ext:junit to v1.3.0' (#45) from renovate/androidx.test.ext-junit-1.x into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-06 11:46:58 +02:00
03735f9230 Merge pull request 'chore(deps): update dependency androidx.webkit:webkit to v1.16.0' (#46) from renovate/androidx.webkit-webkit-1.x into dev
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 11:46:56 +02:00
27b60005cc Merge pull request 'chore(deps): update dependency com.android.tools.build:gradle to v8.13.2' (#47) from renovate/com.android.tools.build-gradle-8.x into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 11:46:55 +02:00
80d74b3ac7 Merge pull request 'chore(deps): update dependency com.google.android.material:material to v1.14.0' (#48) from renovate/com.google.android.material-material-1.x into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 11:46:53 +02:00
3e80a37566 Merge pull request 'chore(deps): update dependency deepfilternet3-noise-filter to v1.3.0' (#49) from renovate/deepfilternet3-noise-filter-1.x into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 11:46:52 +02:00
2f8bf376b7 Merge pull request 'chore(deps): update dependency esbuild to ^0.28.0' (#50) from renovate/esbuild-0.x into dev
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-06 11:46:49 +02:00
cc8cd9c017 Merge pull request 'chore(deps): update dependency recharts to v3.10.1' (#51) from renovate/recharts-3.x into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 11:46:49 +02:00
1b17158ad5
(fix): mobile notification showing "Connected" if the device is offline
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-06 11:46:44 +02:00
bb25ed83ef chore(deps): update dependency recharts to v3.10.1
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 09:01:55 +02:00
959f6638ad chore(deps): update dependency esbuild to ^0.28.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 08:01:48 +02:00
48f04adfd9 chore(deps): update dependency deepfilternet3-noise-filter to v1.3.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 08:01:45 +02:00
0e2995454e chore(deps): update dependency com.google.android.material:material to v1.14.0 2026-08-06 07:01:41 +02:00
acc48c1207 chore(deps): update dependency com.android.tools.build:gradle to v8.13.2 2026-08-06 07:01:40 +02:00
d66b404368 chore(deps): update dependency androidx.webkit:webkit to v1.16.0 2026-08-06 06:01:38 +02:00
5914419aee chore(deps): update dependency androidx.test.ext:junit to v1.3.0 2026-08-06 06:01:36 +02:00
625314cb5a chore(deps): update dependency androidx.test.espresso:espresso-core to v3.7.0 2026-08-06 05:01:48 +02:00
6c145d293d chore(deps): update dependency androidx.activity:activity-ktx to v1.13.0 2026-08-06 05:01:47 +02:00
4e97368f2f chore(deps): update tauri-build digest to c0bd0d5 2026-08-06 05:01:02 +02:00
d07202386f Merge pull request 'chore(deps): update dependency @tanstack/react-store to ^0.11.0' (#41) from renovate/tanstack-react-store-0.x into dev
Some checks failed
/ build-web (push) Failing after 5m39s
/ build-desktop (linux) (push) Failing after 6m46s
/ build-mobile (push) Failing after 7m53s
/ release (push) Has been skipped
2026-08-06 04:01:02 +02:00
a71a9effae Merge pull request 'chore(deps): update dependency @tanstack/store to ^0.11.0' (#42) from renovate/tanstack-store-0.x into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 04:01:01 +02:00
7b3367cff0 Merge pull request 'chore(deps): update mtp-transport digest to e8b7c43' (#38) from renovate/mtp-transport-digest into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-06 04:00:58 +02:00
f14bfe6233 chore(deps): update mtp-transport digest to 7182272 2026-08-06 04:00:54 +02:00
2faf100fa2 chore(deps): update dependency @tanstack/store to ^0.11.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 03:01:45 +02:00
faebd27286 chore(deps): update dependency @tanstack/react-store to ^0.11.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 03:01:43 +02:00
5b2ac5315f Merge pull request 'chore(deps): update dependency @tanstack/devtools-event-client to ^0.5.0' (#40) from renovate/tanstack-devtools-event-client-0.x into dev
Some checks failed
/ build-web (push) Failing after 6m7s
/ build-desktop (linux) (push) Failing after 7m5s
/ build-mobile (push) Failing after 9m22s
/ release (push) Has been skipped
2026-08-06 03:01:03 +02:00
e61be9e5e0 Merge pull request 'chore(deps): update mtp digest to e8b7c43' (#37) from renovate/mtp-digest into dev
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
2026-08-06 03:01:00 +02:00
d30f7100b1 chore(deps): update mtp digest to 7182272 2026-08-06 03:00:56 +02:00
e35fd3d96d chore(deps): update dependency @tanstack/devtools-event-client to ^0.5.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 02:01:43 +02:00
f2343297e0
(feat): bump version
All checks were successful
/ build-web (push) Successful in 17m57s
/ build-desktop (linux) (push) Successful in 23m37s
/ build-mobile (push) Successful in 33m48s
/ release (push) Successful in 3m32s
2026-08-06 00:53:44 +02:00
5a7d5008a3
Merge remote-tracking branch 'origin/main' into dev
Some checks failed
/ build-web (push) Successful in 16m1s
/ build-mobile (push) Failing after 23m4s
/ build-desktop (linux) (push) Successful in 24m56s
/ release (push) Has been skipped
# Conflicts:
#	apps/tauri/src-tauri/Cargo.lock
#	apps/tauri/src-tauri/Cargo.toml
2026-08-06 00:50:24 +02:00
51e83b8d9e Merge pull request 'chore(deps): update rust crate serde to v1.0.229' (#35) from renovate/serde-1.x-lockfile into dev 2026-08-06 00:01:56 +02:00
9d5517fe30 Merge pull request 'chore(deps): update dependency gradle to v8.14.5' (#34) from renovate/gradle-8.x into dev
All checks were successful
/ build-web (push) Successful in 8m5s
/ build-desktop (linux) (push) Successful in 13m12s
/ build-mobile (push) Successful in 23m53s
/ release (push) Successful in 3m38s
2026-08-06 00:01:53 +02:00
a88bda43eb chore(deps): update rust crate serde to v1.0.229 2026-08-06 00:01:50 +02:00
16a288725d chore(deps): update dependency gradle to v8.14.5 2026-08-06 00:01:37 +02:00
d30de8b404
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
# Conflicts:
#	apps/tauri/src-tauri/Cargo.lock
#	apps/tauri/src-tauri/Cargo.toml
2026-08-06 00:01:20 +02:00
be8ea09cd6
(fix): mobile builds 2026-08-05 23:58:50 +02:00
8da83a4900 Merge pull request 'chore(deps): update tauri-build digest to 0aeadb6' (#33) from renovate/tauri-build-digest into dev
Some checks failed
/ build-web (push) Successful in 8m9s
/ build-desktop (linux) (push) Successful in 12m59s
/ build-mobile (push) Failing after 13m30s
/ release (push) Has been skipped
2026-08-05 23:18:44 +02:00
2dc5be85e3
(fix): mobile builds
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-05 23:18:39 +02:00
e11f46bd7c chore(deps): update tauri-build digest to 0aeadb6 2026-08-05 23:01:16 +02:00
2ead5e17be Merge pull request '(feat): update deps' (#31) from dev into main
Some checks failed
/ build-web (push) Successful in 8m5s
/ build-desktop (linux) (push) Successful in 12m58s
/ build-mobile (push) Failing after 13m26s
/ release (push) Has been skipped
Reviewed-on: #31
2026-08-05 22:17:26 +02:00
611a4944b6
(feat): update deps
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-05 22:17:08 +02:00
cf38260a87 Merge pull request 'chore(deps): update mtp-transport digest to a81ac4e' (#29) from renovate/mtp-transport-digest into main
Some checks failed
/ build-web (push) Failing after 6m25s
/ build-desktop (linux) (push) Failing after 6m26s
/ build-mobile (push) Failing after 8m45s
/ release (push) Has been skipped
2026-08-05 22:07:33 +02:00
a6775b1d14 Merge pull request '(feat): update renovate' (#30) from dev into main
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
Reviewed-on: #30
2026-08-05 22:07:30 +02:00
b5c5dd722b
(feat): update renovate
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
2026-08-05 22:07:16 +02:00
5fb884edb0 chore(deps): update mtp-transport digest to a81ac4e 2026-08-05 22:00:59 +02:00
52fcf7ca02 Merge pull request 'chore(deps): update dependency @base-ui/utils to v0.3.2' (#25) from renovate/base-ui-utils-0.x into main
Some checks failed
/ build-web (push) Failing after 3m52s
/ build-desktop (linux) (push) Failing after 3m50s
/ build-mobile (push) Failing after 7m7s
/ release (push) Has been skipped
Reviewed-on: #25
2026-08-05 21:46:55 +02:00
014bb0480a Merge pull request 'chore(deps): update dependency @radix-ui/react-use-effect-event to ^0.0.5' (#26) from renovate/radix-ui-react-use-effect-event-0.x into main
Some checks failed
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
Reviewed-on: #26
2026-08-05 21:46:49 +02:00
3bc4f49678 Merge pull request 'dev' (#27) from dev into main
Some checks failed
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
Reviewed-on: #27
2026-08-05 21:46:27 +02:00
089fd152de
Merge remote-tracking branch 'origin/main' into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-05 21:45:16 +02:00
5044947525
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-08-05 21:42:02 +02:00
4a841de073
(feat): improved mobile notifications
(feat): add lint rules
(feat): improve markdown inline code box
2026-08-05 21:41:55 +02:00
43fd0beb8e chore(deps): update dependency @radix-ui/react-use-effect-event to ^0.0.5
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-05 21:00:47 +02:00
07e31d1ad6 chore(deps): update dependency @base-ui/utils to v0.3.2
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-05 21:00:39 +02:00
forgejo-actions
4c2b0e4c99 (qol): update release flake hash 2026-08-05 18:45:33 +00:00
forgejo-actions
329a35bcfb (qol): update release flake hash 2026-08-05 18:45:31 +00:00
70932ca135 Merge pull request 'chore: Configure Renovate' (#24) from renovate/configure into main
All checks were successful
/ build-web (push) Successful in 14m4s
/ build-desktop (linux) (push) Successful in 19m0s
/ build-mobile (push) Successful in 26m17s
/ release (push) Successful in 3m31s
Reviewed-on: #24
2026-08-05 20:16:14 +02:00
c1b10ef737 Add renovate.json 2026-08-05 20:14:58 +02:00
336b9464c9
(feat): update mtp
All checks were successful
/ build-web (push) Successful in 8m15s
/ build-desktop (linux) (push) Successful in 13m3s
/ build-mobile (push) Successful in 23m22s
/ release (push) Successful in 3m31s
2026-08-05 16:35:32 +02:00
13fa3d8db7
(feat): add mobile notifications
Some checks failed
/ build-web (push) Successful in 8m31s
/ build-desktop (linux) (push) Successful in 14m5s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(feat): improve mobile ui & ux
2026-08-05 16:19:46 +02:00
4cb38264d0
(feat): improve chat ui
All checks were successful
/ build-web (push) Successful in 7m57s
/ build-desktop (linux) (push) Successful in 12m32s
/ build-mobile (push) Successful in 20m14s
/ release (push) Successful in 3m38s
2026-08-05 00:30:11 +02:00
560e829cab
(feat): add day date separator
Some checks failed
/ build-web (push) Successful in 8m3s
/ build-mobile (push) Failing after 11m38s
/ build-desktop (linux) (push) Failing after 11m46s
/ release (push) Has been skipped
2026-08-04 23:51:32 +02:00
ab577283f9
(feat): add draft caching
All checks were successful
/ build-web (push) Successful in 8m1s
/ build-desktop (linux) (push) Successful in 12m44s
/ build-mobile (push) Successful in 19m57s
/ release (push) Successful in 3m26s
2026-08-02 01:25:39 +02:00
85633a1c81
(feat): add hotkeys
Some checks failed
/ build-web (push) Successful in 7m53s
/ build-desktop (linux) (push) Successful in 12m24s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-08-02 01:10:05 +02:00
915568f739
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
All checks were successful
/ build-web (push) Successful in 15m25s
/ build-desktop (linux) (push) Successful in 21m27s
/ build-mobile (push) Successful in 32m52s
/ release (push) Successful in 4m5s
2026-08-01 13:44:45 +02:00
b97f554c32
(feat): update version 2026-08-01 13:44:39 +02:00
forgejo-actions
dcb5d43056 (qol): update release flake hash 2026-08-01 00:28:28 +00:00
forgejo-actions
cdc8d31505 (qol): update release flake hash 2026-08-01 00:28:26 +00:00
aee2a0963d Merge pull request '(feat): big call and chatting stuff' (#23) from dev into main
Some checks failed
/ build-web (push) Successful in 13m34s
/ build-desktop (linux) (push) Successful in 20m28s
/ build-mobile (push) Successful in 33m20s
/ release (push) Failing after 3m48s
Reviewed-on: #23
2026-08-01 02:05:39 +02:00
1fc55ea562
(fix): lint issue
All checks were successful
/ build-web (push) Successful in 7m35s
/ build-desktop (linux) (push) Successful in 12m12s
/ build-mobile (push) Successful in 19m35s
/ release (push) Successful in 3m28s
2026-08-01 01:40:36 +02:00
3ef0380122
(feat): update package.json
Some checks failed
/ build-web (push) Failing after 6m42s
/ build-desktop (linux) (push) Failing after 7m17s
/ build-mobile (push) Failing after 9m13s
/ release (push) Has been skipped
2026-08-01 01:25:56 +02:00
62aaa7c01d
(feat): improve mobile
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(feat): add camera sharing
(fix): vite tauri connection
(fix): methanium/ui theme not applied to call popout
2026-08-01 01:25:41 +02:00
b5ce3c554d
(feat): improve mobile cal ui a bit
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m10s
/ build-mobile (push) Successful in 19m34s
/ release (push) Successful in 3m31s
(feat): add popout for call ui on mobile
(fix): fix cache and profile avatar upload stuff
2026-07-31 22:17:08 +02:00
37b2f8747d
(fix): lint issue
All checks were successful
/ build-web (push) Successful in 7m48s
/ build-desktop (linux) (push) Successful in 12m16s
/ build-mobile (push) Successful in 19m51s
/ release (push) Successful in 3m28s
2026-07-31 17:50:49 +02:00
d355c83c02
(feat): add a small zoom to the android app
Some checks failed
/ build-web (push) Failing after 6m40s
/ build-desktop (linux) (push) Failing after 7m12s
/ build-mobile (push) Failing after 9m12s
/ release (push) Has been skipped
(fix): save ReplyId correctly
(qol): update todo
2026-07-31 15:17:30 +02:00
6f03604c90
(fix): small notification logic error
All checks were successful
/ build-web (push) Successful in 7m45s
/ build-desktop (linux) (push) Successful in 12m15s
/ build-mobile (push) Successful in 19m39s
/ release (push) Successful in 3m27s
(fix): call ui
2026-07-31 00:59:57 +02:00
f1874042ba
(fix): live reactions, message and call invites
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m10s
/ build-mobile (push) Successful in 19m43s
/ release (push) Successful in 3m35s
(fix): call ui
2026-07-31 00:29:48 +02:00
5a26c2d401
(feat): add preview button to the call settings page
Some checks failed
/ build-desktop (linux) (push) Failing after 6m25s
/ build-web (push) Successful in 7m29s
/ build-mobile (push) Successful in 19m23s
/ release (push) Has been skipped
2026-07-30 23:57:24 +02:00
45e7591e40
(feat): update todos
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
2026-07-30 23:52:02 +02:00
184b85190b
(feat): add sounds
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-07-30 23:51:38 +02:00
6a5236bafe
(feat): add reply box to messages
All checks were successful
/ build-web (push) Successful in 7m40s
/ build-desktop (linux) (push) Successful in 12m6s
/ build-mobile (push) Successful in 19m4s
/ release (push) Successful in 3m25s
(feat): update methanium ui
(fix): user data caching
(fix): other random stuff
(qol): update todo
2026-07-30 23:01:42 +02:00
53728b7436
(fix): add conversation
Some checks failed
/ build-web (push) Failing after 1m2s
/ build-mobile (push) Failing after 1m15s
/ build-desktop (linux) (push) Failing after 1m15s
/ release (push) Has been skipped
2026-07-29 11:46:39 +02:00
5ece90ef4b
(fix): retry when iota is unavailable
Some checks failed
/ build-mobile (push) Failing after 1m0s
/ build-web (push) Failing after 1m13s
/ build-desktop (linux) (push) Failing after 1m13s
/ release (push) Has been skipped
2026-07-29 02:37:18 +02:00
3666902ac9
(fix): connections (again)
Some checks failed
/ build-web (push) Failing after 58s
/ build-mobile (push) Failing after 1m11s
/ build-desktop (linux) (push) Failing after 1m11s
/ release (push) Has been skipped
(qol): update todo
2026-07-28 23:53:53 +02:00
676dea2aa4
(fix): connections
Some checks failed
/ build-web (push) Failing after 1m0s
/ build-desktop (linux) (push) Failing after 1m13s
/ build-mobile (push) Failing after 1m13s
/ release (push) Has been skipped
2026-07-28 12:45:08 +02:00
a43c05f035
(fix): fix connection issues
Some checks failed
/ build-web (push) Failing after 1m14s
/ build-desktop (linux) (push) Failing after 1m14s
/ build-mobile (push) Failing after 1m14s
/ release (push) Has been skipped
2026-07-28 01:02:26 +02:00
781a90d2d1
(feat): update mtp
All checks were successful
/ build-web (push) Successful in 7m33s
/ build-desktop (linux) (push) Successful in 12m9s
/ build-mobile (push) Successful in 18m54s
/ release (push) Successful in 3m29s
2026-07-27 22:54:34 +02:00
0dff15cc28
(fix): broken connections
Some checks failed
/ build-web (push) Failing after 6m34s
/ build-desktop (linux) (push) Failing after 6m44s
/ build-mobile (push) Failing after 8m36s
/ release (push) Has been skipped
2026-07-27 21:37:56 +02:00
5602b0a916
(feat): methanium ui stuff
All checks were successful
/ build-web (push) Successful in 8m10s
/ build-desktop (linux) (push) Successful in 12m42s
/ build-mobile (push) Successful in 19m38s
/ release (push) Successful in 3m29s
2026-07-27 16:19:03 +02:00
6d865a93e8
(fix): workflows
All checks were successful
/ build-web (push) Successful in 7m36s
/ build-desktop (linux) (push) Successful in 12m8s
/ build-mobile (push) Successful in 18m57s
/ release (push) Successful in 3m24s
2026-07-26 22:48:54 +02:00
ace7973dff
(feat): big methanium/ui migration
Some checks failed
/ build-web (push) Failing after 5m27s
/ build-desktop (linux) (push) Failing after 5m54s
/ build-mobile (push) Failing after 8m0s
/ release (push) Has been skipped
(feat): update message state icons
(qol): update todo
(qol): formatted
2026-07-26 18:55:46 +02:00
5e79893137
(fix): methanium/ui hash in pnpm lock
All checks were successful
/ build-web (push) Successful in 7m50s
/ build-desktop (linux) (push) Successful in 12m29s
/ build-mobile (push) Successful in 19m23s
/ release (push) Successful in 3m22s
2026-07-25 23:07:28 +02:00
cfabda5d28
(fix): mtp double connections
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-07-25 23:05:04 +02:00
25c7ee7c26
(fix): actually migrate all the imports from tensamin/ui to methanium/ui
Some checks failed
/ build-web (push) Failing after 4m33s
/ build-desktop (linux) (push) Failing after 4m36s
/ build-mobile (push) Failing after 7m7s
/ release (push) Has been skipped
(feat): update legal loading screen
2026-07-25 18:12:54 +02:00
f9f8d5c6c1
(feat): migrate tensamin/ui to methanium/ui
Some checks failed
/ build-web (push) Failing after 4m22s
/ build-desktop (linux) (push) Failing after 4m25s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-07-25 18:06:24 +02:00
9e037d8500
(chore): update license
All checks were successful
/ build-web (push) Successful in 7m4s
/ build-desktop (linux) (push) Successful in 11m35s
/ build-mobile (push) Successful in 18m19s
/ release (push) Successful in 2m58s
2026-07-25 13:10:30 +02:00
17db895203
(feat): add custom lint rules
Some checks failed
/ build-web (push) Successful in 7m4s
/ build-desktop (linux) (push) Successful in 11m31s
/ build-mobile (push) Successful in 18m12s
/ release (push) Failing after 3m2s
(qol): update todo
(chore): update licenses
2026-07-24 12:44:08 +02:00
bf8f449f03
(wip): add onboarding
All checks were successful
/ build-web (push) Successful in 7m19s
/ build-desktop (linux) (push) Successful in 11m52s
/ build-mobile (push) Successful in 18m26s
/ release (push) Successful in 2m59s
(fix): login page reloading
2026-07-24 12:18:05 +02:00
b6dfb4d019
(feat): update chromium path
All checks were successful
/ build-web (push) Successful in 8m40s
/ build-desktop (linux) (push) Successful in 13m25s
/ build-mobile (push) Successful in 20m9s
/ release (push) Successful in 3m2s
2026-07-21 13:57:32 +02:00
747b88f177
Merge remote-tracking branch 'refs/remotes/origin/dev' into dev
Some checks failed
/ build-web (push) Successful in 8m13s
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-mobile (push) Has been cancelled
2026-07-21 13:43:42 +02:00
cda0c48454
(feat): improve secure storage 2026-07-21 13:42:52 +02:00
forgejo-actions
d5a05e3a8b (qol): update release flake hash 2026-07-21 10:17:29 +00:00
forgejo-actions
bf17f59a95 (qol): update release flake hash 2026-07-21 10:17:27 +00:00
f41cf7cfa4
Merge 2026-07-21 11:39:28 +02:00
9011768a2e
(fix): nix flake 2026-07-21 11:37:44 +02:00
85d97a6969
Merge 2026-07-21 11:36:22 +02:00
9a9dfe54ce
(fix): nix flake 2026-07-21 11:35:36 +02:00
forgejo-actions
bd745a5f13 (qol): update release flake hash 2026-07-21 09:19:49 +00:00
forgejo-actions
41bea6b107 (qol): update release flake hash 2026-07-21 09:19:47 +00:00
e44863e9cb Merge pull request 'TTP -> MTP, A lot of other stuff' (#21) from dev into main
All checks were successful
/ build-web (push) Successful in 6m49s
/ build-desktop (linux) (push) Successful in 11m25s
/ build-mobile (push) Successful in 19m28s
/ release (push) Successful in 3m4s
Reviewed-on: #21
2026-07-21 10:57:32 +02:00
forgejo-actions
4a23f8455f (qol): update release flake hash 2026-06-16 10:07:07 +00:00
37a9f97361 Merge pull request '(feat): add theming for testers' (#20) from dev into main
All checks were successful
/ build-web (push) Successful in 1m19s
/ build-desktop (linux) (push) Successful in 6m37s
/ build-mobile (push) Successful in 15m49s
/ release (push) Successful in 32s
Reviewed-on: #20
2026-06-16 11:50:47 +02:00
forgejo-actions
d545b91844 (qol): update release flake hash 2026-06-09 14:39:00 +00:00
b57bc0683c Merge pull request '(fix): typescript error' (#19) from dev into main
All checks were successful
/ build-web (push) Successful in 1m49s
/ build-desktop (linux) (push) Successful in 9m0s
/ build-mobile (push) Successful in 20m25s
/ release (push) Successful in 32s
Reviewed-on: #19
2026-06-09 16:18:04 +02:00
73ff854f0f Merge pull request '(feat): call improvements' (#18) from dev into main
Some checks failed
/ build-desktop (linux) (push) Failing after 1m29s
/ build-web (push) Failing after 1m42s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
Reviewed-on: #18
2026-06-09 16:15:01 +02:00
forgejo-actions
abc0cceb56 (qol): update release flake hash 2026-06-03 07:26:40 +00:00
67756255db Merge pull request '(fix): a few mobile related bugs' (#17) from dev into main
All checks were successful
/ build-web (push) Successful in 1m22s
/ build-desktop (linux) (push) Successful in 5m12s
/ build-mobile (push) Successful in 12m0s
/ release (push) Successful in 29s
Reviewed-on: #17
2026-06-03 09:14:14 +02:00
257ef320d8 Merge pull request '(feat): remove cleanup workflow' (#16) from dev into main
Some checks failed
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
Reviewed-on: #16
2026-06-02 22:47:28 +02:00
4a53d5c6dc Merge pull request '(fix): cleanup workflow' (#15) from dev into main
Some checks failed
/ release (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
Reviewed-on: #15
2026-06-02 22:33:05 +02:00
forgejo-actions
089e6c5b28 (qol): update release flake hash 2026-05-29 13:21:12 +00:00
00598348ab Merge pull request '(feat): some workflow updates' (#14) from dev into main
All checks were successful
/ build-web (push) Successful in 2m53s
/ build-desktop (linux) (push) Successful in 7m34s
/ build-mobile (push) Successful in 21m41s
/ release (push) Successful in 54s
Reviewed-on: #14
2026-05-29 14:58:44 +02:00
forgejo-actions
0c06516984 (qol): update release flake hash 2026-05-28 14:09:23 +00:00
8f4f356746 Merge pull request '(feat): now using the .deb instead of the .AppImage for nixos' (#13) from dev into main
All checks were successful
/ build-web (push) Successful in 3m38s
/ build-desktop (linux) (push) Successful in 9m5s
/ build-mobile (push) Successful in 26m16s
/ release (push) Successful in 55s
Reviewed-on: #13
2026-05-28 15:42:18 +02:00
351 changed files with 22111 additions and 15867 deletions

2
.cargo/config.toml Normal file
View file

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

View file

@ -3,6 +3,7 @@
"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"
],

View 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

View file

@ -1,23 +1,21 @@
on:
workflow_dispatch:
push:
branches:
- dev
paths-ignore:
- flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs:
build-web:
runs-on: nixos
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -36,12 +34,12 @@ jobs:
build-mobile:
runs-on: nixos
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile
@ -56,8 +54,6 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
nix profile add nixpkgs#gnused
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
@ -122,12 +118,12 @@ jobs:
matrix:
target: [linux]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -172,14 +168,14 @@ jobs:
runs-on: nixos
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile

View file

@ -6,19 +6,16 @@ on:
paths-ignore:
- flake.nix
env:
NIX_CONFIG: experimental-features = nix-command flakes
jobs:
build-web:
runs-on: nixos
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -37,12 +34,12 @@ jobs:
build-mobile:
runs-on: nixos
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile
@ -57,8 +54,6 @@ jobs:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PROPERTIES: ${{ secrets.ANDROID_KEYSTORE_PROPERTIES }}
run: |
nix profile add nixpkgs#gnused
set -euo pipefail
if [ -z "$KEYSTORE_BASE64" ]; then
@ -123,12 +118,12 @@ jobs:
matrix:
target: [linux]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -171,12 +166,12 @@ jobs:
runs-on: nixos
needs: [build-web, build-mobile, build-desktop]
steps:
- name: Install node
run: nix profile add nixpkgs#nodejs_24
- name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -316,58 +311,3 @@ jobs:
"$API/repos/$REPO/releases/$release_id"
done < "$DELETE_RELEASES"
EOF
- name: Update root flake release hash
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
nix develop .#electron --command bash <<'EOF'
set -eu
DEB="$(find releases -maxdepth 1 -type f -name 'Tensamin-*-linux-amd64.deb' -print -quit)"
test -n "$DEB"
HASH="$(node -e 'const fs = require("fs"); const crypto = require("crypto"); const file = process.argv[1]; console.log("sha256-" + crypto.createHash("sha256").update(fs.readFileSync(file)).digest("base64"));' "$DEB")"
export HASH
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on main."
else
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push
fi
git fetch origin dev
git worktree add ../dev-flake-update origin/dev
cd ../dev-flake-update
node -e '
const fs = require("fs");
const version = process.env.TAG;
const hash = process.env.HASH;
let content = fs.readFileSync("flake.nix", "utf8");
content = content.replace(/version = "[^"]+";/, `version = "${version}";`);
content = content.replace(/x86_64DebHash = "sha256-[^"]+";/, `x86_64DebHash = "${hash}";`);
fs.writeFileSync("flake.nix", content);
'
if git diff --quiet -- flake.nix; then
echo "flake.nix already has the current release hash on dev."
exit 0
fi
git add flake.nix
git -c user.name="forgejo-actions" -c user.email="forgejo-actions@localhost" commit -m "(qol): update release flake hash"
git push origin HEAD:dev
EOF

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ node_modules
releases
.fallow
.direnv
keystore.jks
keystore.properties

3
.gitmodules vendored Normal file
View file

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

25
LICENSE
View file

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

View file

6
TODO Normal file
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View file

@ -28,12 +28,11 @@
"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"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^25.9.1",
"electron": "^39.2.7",
"electron-builder": "^26.0.12",
"esbuild": "^0.25.11",
"@types/node": "^26.1.2",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
"esbuild": "^0.28.1",
"typescript": "~6.0.3"
},
"build": {
@ -95,7 +94,11 @@
"target": [
"dmg"
],
"icon": "build/icons/icon.icns"
"icon": "build/icons/icon.icns",
"extendInfo": {
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls."
}
},
"publish": null
}

View file

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

View file

@ -34,10 +34,6 @@ function validateValue(value: unknown): asserts value is string {
}
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
if (!safeStorage.isEncryptionAvailable()) {
return { available: false, backend: null };
}
const backend =
process.platform === "linux"
? safeStorage.getSelectedStorageBackend()
@ -48,7 +44,9 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
: null;
return {
available: process.platform !== "linux" || backend !== "basic_text",
available:
safeStorage.isEncryptionAvailable() &&
(process.platform !== "linux" || backend !== "basic_text"),
backend,
};
}

View file

@ -8,14 +8,12 @@ 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);
if (app.isPackaged)
return path.join(process.resourcesPath, "icons", filename);
return path.resolve(__dirname, "../../build/icons", filename);
}
export function setTrayCallStatus(
inCall: boolean,
iconDataUrl?: string,
) {
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
if (!tray) return;
if (inCall && iconDataUrl) {

View file

@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron";
import {
ipcChannels,
type DesktopCallStatus,
type DesktopGlobalHotkeyBinding,
type DesktopScreenShareSource,
secureStorageLimits,
} from "../shared/ipc.js";
@ -55,6 +56,23 @@ const desktopApi = {
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) =>

View file

@ -31,6 +31,11 @@ export type DesktopSecureStorageStatus = {
backend: string | null;
};
export type DesktopGlobalHotkeyBinding = {
id: string;
accelerator: string;
};
export const secureStorageLimits = {
maxKeyBytes: 256,
maxValueBytes: 1024 * 1024,
@ -77,4 +82,7 @@ export const ipcChannels = {
saveSecureStorage: "secureStorage:save",
deleteSecureStorage: "secureStorage:delete",
clearSecureStorage: "secureStorage:clear",
setGlobalHotkeyBindings: "hotkeys:setBindings",
setGlobalHotkeysSuspended: "hotkeys:setSuspended",
globalHotkeyTriggered: "hotkeys:triggered",
} as const;

36
apps/pwa/package.json Normal file
View 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
View 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 />;
}

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

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -4,23 +4,16 @@
"version": "0.0.0",
"type": "module",
"exports": {
"./controls": {
"types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx"
},
"./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx"
},
"./qrCodeScanner": {
"types": "./src/qrCodeScanner.tsx",
"default": "./src/qrCodeScanner.tsx"
}
},
"scripts": {
"dev:mobile:raw": "tauri android dev --host ${TAURI_DEV_HOST:-127.0.0.1}",
"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:ci": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && tauri android build --debug",
"dev:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run dev:mobile:raw; else pnpm run dev:mobile:raw; fi",
"start-adb:mobile": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run start-adb:mobile:raw; else pnpm run start-adb:mobile:raw; fi",
"build:mobile": "node render-version.ts && trap 'node render-version.ts --unrender' EXIT && if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop ../..#tauri --command pnpm run build:mobile:raw; else pnpm run build:mobile:raw; fi",
@ -29,18 +22,15 @@
"lint": "eslint src"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-barcode-scanner": "~2",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-notification": "~2",
"@methanium/ui": "*",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-deep-link": "~2.4.9",
"@tensamin/shared": "workspace:*",
"@tensamin/ui": "*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/node": "^25.9.1"
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^26.1.2"
}
}

View 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);

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,17 @@ name = "mobile_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { git = "https://github.com/tauri-apps/tauri", branch = "feat/cef", features = [] }
tauri-build = { git = "https://github.com/tauri-apps/tauri", rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41", features = [] }
[dependencies]
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
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-notification = "2"
tauri-plugin-log = "2"
@ -30,10 +35,14 @@ version = "2"
features = []
default-features = true
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
tauri-plugin-barcode-scanner = "2"
tauri-plugin-app-events = "0.2"
[target.'cfg(not(target_os = "android"))'.dependencies.tauri]
version = "2"
features = []
default-features = true
[target.'cfg(target_os = "android")'.dependencies]
jni = "0.22"
[patch.crates-io.tauri]
git = "https://github.com/tauri-apps/tauri"
branch = "feat/cef"
rev = "4af26a3f7f8b692d62cca549bbacd93f5ce90b41"

View file

@ -6,10 +6,7 @@
"main"
],
"permissions": [
"core:default",
"opener:default",
"core:window:default",
"core:event:default",
"deep-link:default",
"notification:default",
"log:default"

View file

@ -1,18 +1,10 @@
{
"identifier": "mobile-capability",
"platforms": [
"android",
"iOS"
],
"windows": [
"main"
],
"platforms": ["android", "iOS"],
"windows": ["main"],
"permissions": [
"core:event:default",
"deep-link:default",
"app-events:default",
"barcode-scanner:default",
"barcode-scanner:allow-scan",
"barcode-scanner:allow-cancel",
"notification:default",
"log:default"
]

View file

@ -1,3 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
import java.io.FileInputStream
@ -61,26 +62,29 @@ android {
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.webkit:webkit:1.16.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.activity:activity-ktx:1.13.0")
implementation("com.google.android.material:material:1.14.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
}
apply(from = "tauri.build.gradle.kts")

View file

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

View file

@ -1,28 +1,114 @@
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.view.ViewGroup
import android.view.MotionEvent
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null
private var previousUsableHeight = 0
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var mediaWebView: WebView? = null
private var pendingScreenAudio: Boolean? = null
private val screenCaptureLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val includeAudio = pendingScreenAudio
pendingScreenAudio = null
if (includeAudio == null) return@registerForActivityResult
val data = result.data
if (result.resultCode != Activity.RESULT_OK || data == null) {
MobileMediaEvents.emitError("Screen capture permission was denied")
return@registerForActivityResult
}
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) {
emitCameraPermission()
}
private val screenAudioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
launchScreenCaptureIntent()
}
override fun onWebViewCreate(webView: WebView) {
webView.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?) {
WindowCompat.setDecorFitsSystemWindows(window, true)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
super.onCreate(savedInstanceState)
NativeMtpBridge.nativeAttach(applicationContext)
if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) {
NativeMtpBridge.startService(this)
}
NativeAccessibilityBridge.nativeAttach(applicationContext)
installKeyboardResizeWorkaround()
}
override fun onResume() {
super.onResume()
NativeMtpBridge.nativeSetUiState(true)
}
override fun onPause() {
NativeMtpBridge.nativeSetUiState(false)
super.onPause()
}
override fun onDestroy() {
attachLayoutListener?.let { listener ->
contentRoot?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
@ -30,9 +116,87 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null
contentRoot = null
contentChild = null
mediaWebView?.let {
NativeAccessibilityBridge.detach(it)
it.removeJavascriptInterface("tensaminMobileMedia")
}
mediaWebView = null
MobileMediaEvents.detach()
super.onDestroy()
}
private fun startScreenShare(includeAudio: Boolean) {
runOnUiThread {
if (pendingScreenAudio != null) {
MobileMediaEvents.emitError("Screen capture permission is already pending")
return@runOnUiThread
}
pendingScreenAudio = includeAudio
if (
includeAudio &&
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} else {
launchScreenCaptureIntent()
}
}
}
private fun launchScreenCaptureIntent() {
val manager = getSystemService(MediaProjectionManager::class.java)
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
}
private fun stopScreenShare() {
runOnUiThread {
pendingScreenAudio = null
MediaProjectionService.stop(this)
}
}
private fun requestCameraPermission() {
runOnUiThread {
cameraPermissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
)
}
}
private fun emitCameraPermission() {
val detail = JSONObject()
.put(
"camera",
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
.put(
"microphone",
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED,
)
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
}
private inner class MobileMediaJavascriptInterface {
@JavascriptInterface
fun startScreenShare(includeAudio: Boolean) {
this@MainActivity.startScreenShare(includeAudio)
}
@JavascriptInterface
fun stopScreenShare() {
this@MainActivity.stopScreenShare()
}
@JavascriptInterface
fun requestCameraPermission() {
this@MainActivity.requestCameraPermission()
}
}
private fun installKeyboardResizeWorkaround() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
contentRoot = content
@ -68,16 +232,20 @@ class MainActivity : TauriActivity() {
child: android.view.View,
insets: WindowInsetsCompat? = ViewCompat.getRootWindowInsets(child),
) {
val visibleFrame = Rect()
child.getWindowVisibleDisplayFrame(visibleFrame)
val rootHeight = child.rootView.height
if (rootHeight <= 0) return
val imeHeight = insets?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0
val keyboardHeight = maxOf(imeHeight, rootHeight - visibleFrame.bottom)
val keyboardVisible = keyboardHeight > rootHeight * 0.15
val usableHeight = if (keyboardVisible) rootHeight - keyboardHeight else ViewGroup.LayoutParams.MATCH_PARENT
val imeVisible = insets?.isVisible(WindowInsetsCompat.Type.ime()) == true
val imeHeight = if (imeVisible) {
insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
} else {
0
}
val usableHeight = if (imeHeight in 1 until rootHeight) {
rootHeight - imeHeight
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
if (previousUsableHeight == usableHeight) return

View file

@ -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),
)
}
}
}

View file

@ -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) }
}
}

View file

@ -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)
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}
}

View file

@ -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) }
}
}
}

View file

@ -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())
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -1,4 +1,4 @@
import com.android.build.gradle.LibraryExtension
import com.android.build.api.dsl.LibraryExtension
buildscript {
repositories {
@ -7,7 +7,7 @@ buildscript {
}
dependencies {
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")
}
}

View file

@ -20,4 +20,3 @@ dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}

View file

@ -5,8 +5,12 @@ import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
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
var rootDirRel: String? = null
@Input
@ -50,7 +54,7 @@ open class BuildTask : DefaultTask() {
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
execOperations.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)

View file

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

Binary file not shown.

View file

@ -0,0 +1,135 @@
#[cfg(not(target_os = "android"))]
const DEFAULT_INITIAL_SCALE: i32 = 290;
const MIN_INITIAL_SCALE: i32 = 210;
const MAX_INITIAL_SCALE: i32 = 500;
#[tauri::command]
pub fn accessibility_get_initial_scale() -> Result<i32, String> {
android_get_initial_scale()
}
#[tauri::command]
pub fn accessibility_set_initial_scale(initial_scale: i32) -> Result<(), String> {
if !(MIN_INITIAL_SCALE..=MAX_INITIAL_SCALE).contains(&initial_scale) {
return Err(format!(
"Initial scale must be between {MIN_INITIAL_SCALE} and {MAX_INITIAL_SCALE}"
));
}
android_set_initial_scale(initial_scale)
}
#[cfg(not(target_os = "android"))]
fn android_get_initial_scale() -> Result<i32, String> {
Ok(DEFAULT_INITIAL_SCALE)
}
#[cfg(not(target_os = "android"))]
fn android_set_initial_scale(_: i32) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
mod android {
use std::sync::OnceLock;
use jni::{
jni_sig, jni_str,
objects::{Global, JClass, JObject, JValue},
Env, EnvUnowned, JavaVM,
};
struct Host {
vm: JavaVM,
context: Global<JObject<'static>>,
bridge: Global<JObject<'static>>,
}
static HOST: OnceLock<Host> = OnceLock::new();
fn attach(env: &mut Env, context: JObject) -> Result<(), String> {
if HOST.get().is_some() {
return Ok(());
}
let class = env
.find_class(jni_str!("net/tensamin/client/NativeAccessibilityBridge"))
.map_err(|error| error.to_string())?;
let bridge = env
.get_static_field(
class,
jni_str!("INSTANCE"),
jni_sig!("Lnet/tensamin/client/NativeAccessibilityBridge;"),
)
.and_then(|value| value.l())
.map_err(|error| error.to_string())?;
HOST.set(Host {
vm: env.get_java_vm().map_err(|error| error.to_string())?,
context: env
.new_global_ref(context)
.map_err(|error| error.to_string())?,
bridge: env
.new_global_ref(bridge)
.map_err(|error| error.to_string())?,
})
.map_err(|_| "Android accessibility host is already attached".to_string())
}
fn with_env<T>(call: impl FnOnce(&mut Env, &Host) -> Result<T, String>) -> Result<T, String> {
let host = HOST
.get()
.ok_or("Android accessibility host is not attached")?;
host.vm
.attach_current_thread(|env| Ok::<_, jni::errors::Error>(call(env, host)))
.map_err(|error| error.to_string())?
}
pub fn get_initial_scale() -> Result<i32, String> {
with_env(|env, host| {
env.call_method(
host.bridge.as_obj(),
jni_str!("getInitialScale"),
jni_sig!("(Landroid/content/Context;)I"),
&[JValue::Object(host.context.as_obj())],
)
.and_then(|value| value.i())
.map_err(|error| error.to_string())
})
}
pub fn set_initial_scale(initial_scale: i32) -> Result<(), String> {
with_env(|env, host| {
env.call_method(
host.bridge.as_obj(),
jni_str!("setInitialScale"),
jni_sig!("(Landroid/content/Context;I)V"),
&[
JValue::Object(host.context.as_obj()),
JValue::Int(initial_scale),
],
)
.map_err(|error| error.to_string())?;
Ok(())
})
}
#[no_mangle]
pub extern "system" fn Java_net_tensamin_client_NativeAccessibilityBridge_nativeAttach<
'caller,
>(
mut env: EnvUnowned<'caller>,
_class: JClass,
context: JObject<'caller>,
) {
let _ = env.with_env(|env| {
let _ = attach(env, context);
Ok::<_, jni::errors::Error>(())
});
}
}
#[cfg(target_os = "android")]
use android::{
get_initial_scale as android_get_initial_scale, set_initial_scale as android_set_initial_scale,
};

View file

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

View file

@ -1,9 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use lib::log;
fn main() {
mobile_lib::run();
log("Test test 123")
}

File diff suppressed because it is too large Load diff

View file

@ -7,13 +7,13 @@ import {
} from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui";
import { useIsMobile } from "@methanium/ui";
type DeeplinkContextValue = {
export const deeplinkContext = createContext<{
deeplinks: readonly string[];
};
export const deeplinkContext = createContext<DeeplinkContextValue | undefined>(
} | undefined>(
undefined,
);

View file

@ -1,38 +0,0 @@
import {
scan,
Format,
requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@tensamin/ui";
import { toast } from "@tensamin/shared/log";
export default function QrCodeScanner({
onData,
}: {
onData: (data: string) => void;
}) {
return (
<Button
onClick={() => {
requestPermissions()
.catch((err) => {
toast("error", err.message);
})
.then(() =>
scan({ windowed: false, formats: [Format.QRCode] })
.catch((err) => {
toast("error", err.message);
})
.then((data) => {
if (data) {
onData(data.content);
}
}),
);
}}
>
Open QR Code Scanner
</Button>
);
}

View file

@ -2,8 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="./favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<title>Tensamin</title>
</head>
<body>

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -6,54 +6,66 @@ import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@tensamin/ui";
import { Card, CardHeader } from "@tensamin/ui";
import { Skeleton } from "@tensamin/ui";
Button,
} from "@methanium/ui";
import { Skeleton } from "@methanium/ui";
import { getStatusColor } from "@tensamin/shared/data";
export function Basic({
user,
extra,
}: {
user: User;
user: Pick<
User,
"Display" | "Username" | "Avatar" | "OnlineStatus" | "Status"
>;
extra?: React.ReactNode;
}) {
const display = user.Display || user.Username || "Unknown";
const onlineStatus = user.OnlineStatus || "user_borked";
const avatar = user.Avatar
? `data:image/webp;base64,${user.Avatar}`
: undefined;
return (
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
<div className="relative shrink-0 overflow-visible">
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<Tooltip>
<TooltipTrigger
render={
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
<div
style={{
backgroundColor: getStatusColor(user.OnlineStatus),
}}
className="h-2.25 w-2.25 rounded-full"
/>
</div>
}
/>
<TooltipContent>
{user.OnlineStatus.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
<p>{user.Display}</p>
</div>
<div className="pr-1">{extra}</div>
</CardHeader>
</Card>
<Button
render={<div />}
nativeButton={false}
variant="outline"
className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!"
>
<div className="relative shrink-0 overflow-visible">
<Avatar>
<AvatarImage src={avatar} />
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<Tooltip>
<TooltipTrigger
render={
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
<div
style={{
backgroundColor: getStatusColor(onlineStatus),
}}
className="h-2.25 w-2.25 rounded-full"
/>
</div>
}
/>
<TooltipContent>
{onlineStatus
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</TooltipContent>
</Tooltip>
</div>
<div className="flex w-full flex-col items-start justify-center text-[15px]">
<p>{display}</p>
<p className="text-xs text-muted-foreground">{user.Status}</p>
</div>
<div className="pr-1">{extra}</div>
</Button>
);
}

View file

@ -1,10 +1,23 @@
import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui";
import Text from "@tensamin/markdown/text";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
import { Text } from "@methanium/ui/markdown";
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
export default function Profile({ user }: { user: User }) {
export default function Profile({
user,
}: {
user: Pick<
User,
| "UserId"
| "Display"
| "Username"
| "Avatar"
| "About"
| "IotaId"
| "PublicKey"
>;
}) {
const [showAdvancedInformation, setShowAdvancedInformation] = useState(false);
return (
@ -24,7 +37,7 @@ export default function Profile({ user }: { user: User }) {
<Text value={user.About || ""} />
<Button
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
variant="link"
variant="ghost"
aria-expanded={showAdvancedInformation}
onClick={() => setShowAdvancedInformation((show) => !show)}
>

View file

@ -4,17 +4,29 @@ import {
PopoverContent,
PopoverTrigger,
useIsMobile,
} from "@tensamin/ui";
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
} from "@methanium/ui";
import {
ArrowLeft,
EllipsisVertical,
House,
Phone,
Settings,
User,
} from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper";
import { Skeleton } from "@tensamin/ui";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
import { Skeleton } from "@methanium/ui";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@methanium/ui";
import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui";
import { WindowControls as Controls } from "@tensamin/ui";
import { SidebarTrigger, useSidebar } from "@methanium/ui";
import { WindowControls as Controls } from "@methanium/ui";
import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile";
@ -35,50 +47,71 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [userInfoOpen, setUserInfoOpen] = useState(false);
const { callId } = useCall();
return (
<div
data-tauri-drag-region
className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
data-pwa-navbar
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
>
<div className="flex items-center justify-center gap-2">
{forMobile ? (
<SidebarTrigger
className="w-9 h-9 aspect-square rounded-lg ml-2"
variant="outline"
>
<ArrowLeft className="size-4.5" />
</SidebarTrigger>
render={({ onClick }) => (
<Button
onClick={onClick}
className="w-9 h-9! ml-2"
variant="ghost"
>
<ArrowLeft className="size-4.5" />
</Button>
)}
/>
) : (
<>
<Button
onClick={() => navigate({ to: "/" })}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg"
variant="outline"
>
<House className="size-4.5" />
</Button>
<Button
onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg"
variant="outline"
>
<Settings className="size-4.5" />
</Button>
</>
)}
{isMobile && pathname === "/call" && callId && (
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
)}
{pathname === "/chat" && id && (
<Wrapper
userId={id}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"About",
"IotaId",
"PublicKey",
]}
component={(user) =>
isMobile ? (
<p className="font-medium text-[1.07rem]">{user?.Display}</p>
) : (
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger
render={
render={({ onClick }) => (
<Button
onClick={onClick}
variant="link"
className="px-0! text-foreground"
className="text-foreground px-0! border-0! bg-none! bg-transparent!"
style={{
textDecorationLine: "none",
}}
@ -87,7 +120,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
{user?.Display}
</p>
</Button>
}
)}
/>
<PopoverContent side="bottom">
<Profile user={user} />
@ -109,7 +142,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => {
void joinCall(id);
}}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg"
variant="outline"
>
<Phone />
@ -124,7 +157,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
currentCalls[0].CallId,
);
}}
className="w-9 h-9 aspect-square rounded-lg"
className="w-9 h-9! aspect-square rounded-lg"
>
<Phone />
</Button>
@ -161,6 +194,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)}
</>
)}
{isMobile && pathname === "/call" && callId && (
<Button variant="ghost">
<EllipsisVertical />
</Button>
)}
<Controls className="mr-2" />
</div>
</div>
@ -175,7 +213,7 @@ export function MobileNavbar() {
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
<SidebarTrigger
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
variant="link"
>
<User className="size-4.5" />
@ -186,7 +224,7 @@ export function MobileNavbar() {
navigate({ to: "/" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
variant="link"
>
<House className="size-4.5" />
@ -197,7 +235,7 @@ export function MobileNavbar() {
navigate({ to: "/settings" });
setOpenMobile(false);
}}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
variant="link"
>
<Settings className="size-4.5" />

View file

@ -1,13 +1,24 @@
import { Button, cn, useIsMobile } from "@tensamin/ui";
import { Input } from "@tensamin/ui";
import { Label } from "@tensamin/ui";
import { Button, cn, useIsMobile } from "@methanium/ui";
import { Input } from "@methanium/ui";
import { Label } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context";
import { log, toast } from "@tensamin/shared/log";
import { File } from "lucide-react";
import * as React from "react";
import {
type ChangeEvent,
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { z } from "zod";
import { isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
import { subscribeTuFileLaunch } from "@tensamin/pwa/runtime";
import {
parseTuFileContent,
persistMtpCredentials,
} from "@tensamin/storage/credentials";
import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({
id: z.uuidv4(),
@ -27,86 +38,36 @@ const formSchema = z.object({
mtp_keyring: z.string().min(1).max(92),
});
/**
* Parses a .tu file payload into credentials.
* @param rawFileContent UTF-8 file content from an uploaded .tu file.
* @returns Parsed user id and private key credentials.
*/
function parseTuFileContent(rawFileContent: string): {
userId: number;
privateKey: string;
domain: string | null;
} {
if (rawFileContent.trim().length === 0) {
throw new Error("File is empty");
} else if (!rawFileContent.includes("::")) {
throw new Error("Invalid file");
} else if (rawFileContent.split("::").length !== 2) {
throw new Error("Invalid file");
}
const left = rawFileContent.split("::")[0];
const right = rawFileContent.split("::")[1];
if (left.length === 0) {
throw new Error("Invalid file");
} else if (right.length === 0) {
throw new Error("Invalid file");
} else if (isNaN(Number(left)) && !left.includes("@")) {
throw new Error("Invalid file");
}
const [userIdString, privateKey] = rawFileContent.split("::");
const userId = isNaN(Number(userIdString))
? Number(userIdString.split("@")[0])
: Number(userIdString);
const domain = userIdString.includes("@") ? userIdString.split("@")[1] : null;
if (!userId || !privateKey) {
throw new Error("Invalid file");
}
console.log({
domain,
userId,
});
return { userId, privateKey, domain };
}
export default function Form() {
const isMobile = useIsMobile();
const uploadRef = React.useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const { save } = useStorage();
const loginPendingRef = React.useRef(false);
const uploadRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = useState(false);
const { load, save } = useStorage();
const navigate = useNavigate();
const loginPendingRef = useRef(false);
const persistLogin = React.useCallback(
const persistLogin = useCallback(
async (userId: number, privateKey: string, domain?: string | null) => {
if (loginPendingRef.current) return false;
loginPendingRef.current = true;
try {
if (domain) await save("omega_url", `https://${domain}/`);
await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now());
await save("user_id", userId);
window.history.replaceState(
null,
"",
window.location.protocol === "file:" ? "#/" : "/",
);
window.location.reload();
await persistMtpCredentials({
storage: { load, save },
userId,
keyring: privateKey,
domain,
});
await navigate({ to: "/", replace: true });
return true;
} finally {
loginPendingRef.current = false;
}
},
[save],
[load, navigate, save],
);
// Process dropped files
const processDroppedFile = React.useCallback(
const processDroppedFile = useCallback(
async (file: globalThis.File): Promise<void> => {
try {
if (!file.name.endsWith(".tu")) {
@ -126,9 +87,14 @@ export default function Form() {
[persistLogin],
);
useEffect(
() => subscribeTuFileLaunch(processDroppedFile),
[processDroppedFile],
);
// Handle .tu files
const handleFileInputChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const handleFileInputChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.currentTarget.files?.[0];
if (!file) {
@ -142,7 +108,7 @@ export default function Form() {
);
// Drag and drop listener
React.useEffect(() => {
useEffect(() => {
let dragCounter = 0;
const handleDragEnter = (event: DragEvent) => {
@ -212,8 +178,8 @@ export default function Form() {
* @param event Form submit event.
* @returns Promise that resolves after login processing.
*/
const handleCredentialsSubmit = React.useCallback(
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
const handleCredentialsSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@ -264,32 +230,10 @@ export default function Form() {
return (
<div className="relative flex md:flex-row flex-col gap-15">
{isTauri() && isMobile ? (
<>
<QrCodeScanner
onData={async (data) => {
if (!data.startsWith("tensamin://tu::")) {
toast("error", "Invalid QR code");
return;
}
const decoded = data.replace("tensamin://tu::", "");
try {
const { userId, privateKey, domain } =
parseTuFileContent(decoded);
await persistLogin(userId, privateKey, domain);
} catch (error) {
log(0, "login", "red", error);
toast("error", "Failed to parse QR code data");
}
}}
/>
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
</>
{isMobile ? (
<Button onClick={() => uploadRef.current?.click()}>
Select .tu file
</Button>
) : (
<div
onClick={() => uploadRef.current?.click()}

View file

@ -26,33 +26,28 @@ import {
SelectValue,
SelectContent,
SelectItem,
} from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui";
} from "@methanium/ui";
import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox";
import { useShowMobileNavbar } from "@/routes/app/useShowMobileNavbar";
import { Ellipsis, Check } from "lucide-react";
import { useState } from "react";
import type { User } from "@tensamin/user/context";
import type z from "zod";
import { mtp } from "@tensamin/shared/data";
import { useUser, type User } from "@tensamin/user/context";
import { mtp, userPresencePreferenceSchema } from "@tensamin/shared/data";
import { useMTP } from "@tensamin/mtp";
import {
onlineStatusLabels,
onlineStatusOptions,
type OnlineStatus,
} from "./status-options";
type OnlineStatus = z.infer<typeof mtp.GetUserData.response.shape.OnlineStatus>;
const onlineStatusLabels: Record<OnlineStatus, string> = {
user_online: "Online",
user_offline: "Offline",
user_dnd: "Do not disturb",
user_idle: "Idle",
user_wc: "Away",
user_borked: "Borked",
iota_offline: "Iota offline",
iota_online: "Iota online",
iota_borked: "Iota borked",
};
function accountPreference(status: User["OnlineStatus"]): OnlineStatus {
return userPresencePreferenceSchema.safeParse(status).success
? (status as OnlineStatus)
: "user_online";
}
function StatusDialog({
user,
@ -68,7 +63,7 @@ function StatusDialog({
saveSucceeded,
setSaveSucceeded,
}: {
user: User;
user: Pick<User, "UserId" | "OnlineStatus" | "Status">;
open: boolean;
onOpenChange: (open: boolean) => void;
send: ReturnType<typeof useMTP>["send"];
@ -81,13 +76,15 @@ function StatusDialog({
saveSucceeded: boolean;
setSaveSucceeded: (value: boolean) => void;
}) {
const { updateProfile, updateState } = useUser();
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setDraftOnlineStatus(accountPreference(user.OnlineStatus));
setErrorMessage("");
setSaveSucceeded(false);
}
@ -126,10 +123,11 @@ function StatusDialog({
</SelectValue>
</SelectTrigger>
<SelectContent className="p-1">
<SelectItem value="user_online">Online</SelectItem>
<SelectItem value="user_offline">Offline</SelectItem>
<SelectItem value="user_idle">Idle</SelectItem>
<SelectItem value="user_dnd">Do not disturb</SelectItem>
{onlineStatusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -140,12 +138,10 @@ function StatusDialog({
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
<Button
onClick={async () => {
const payload = {
...(draftStatus && { status: draftStatus }),
const validation = mtp.ChangeUserData.request.safeParse({
OnlineStatus: draftOnlineStatus,
};
const validation = mtp.ChangeUserData.request.safeParse(payload);
Status: draftStatus,
});
if (!validation.success) {
setSaveSucceeded(false);
@ -157,6 +153,8 @@ function StatusDialog({
try {
await send("ChangeUserData", validation.data);
updateState(user.UserId, draftOnlineStatus);
await updateProfile(user.UserId, { Status: draftStatus });
setSaveSucceeded(true);
setErrorMessage("");
} catch (err) {
@ -195,16 +193,20 @@ export default function Sidebar() {
const content = (
<>
<SidebarContent
className={
isTauri() && isMobile ? "pt-[env(safe-area-inset-top)]" : "pt-2"
}
>
<SidebarContent className="pt-2">
<div className="h-full w-full flex flex-col gap-3 p-2 pt-0!">
<div>
<Wrapper
loading={<Loading />}
userId={"own"}
fields={[
"UserId",
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => (
<>
<Basic
@ -227,7 +229,9 @@ export default function Sidebar() {
<DropdownMenuItem
onClick={() => {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setDraftOnlineStatus(
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
setDialogOpen(true);
@ -246,7 +250,9 @@ export default function Sidebar() {
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setDraftStatus(user.Status ?? "");
setDraftOnlineStatus(user.OnlineStatus);
setDraftOnlineStatus(
accountPreference(user.OnlineStatus),
);
setStatusErrorMessage("");
setStatusSaveSucceeded(false);
}
@ -298,7 +304,7 @@ export default function Sidebar() {
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar pt-[var(--ui-safe-area-top)] pr-[var(--ui-safe-area-right)] pl-[var(--ui-safe-area-left)] text-sidebar-foreground"
style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0,

View file

@ -0,0 +1,13 @@
export const onlineStatusOptions = [
{ label: "Online", value: "user_online" },
{ label: "Idle", value: "user_idle" },
{ label: "Do not disturb", value: "user_dnd" },
{ label: "On the toilet", value: "user_wc" },
{ label: "Offline", value: "user_invisible" },
] as const;
export type OnlineStatus = (typeof onlineStatusOptions)[number]["value"];
export const onlineStatusLabels = Object.fromEntries(
onlineStatusOptions.map((option) => [option.value, option.label]),
) as Record<OnlineStatus, string>;

View file

@ -1,4 +1,4 @@
import * as React from "react";
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import Switch from "./switch";
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
import { useSession } from "@tensamin/storage/session";
export default function List() {
const [category, setCategory] = React.useState<
"conversations" | "communities"
>("conversations");
const [category, setCategory] = useState<"conversations" | "communities">(
"conversations",
);
const { contacts, communities } = useSession();
const items = category === "conversations" ? contacts : communities;
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
// eslint-disable-next-line react-hooks/incompatible-library
@ -31,7 +31,7 @@ export default function List() {
<div
ref={scrollRef}
id="conversation-list"
className="overflow-y-auto flex-1 h-full"
className="overflow-y-auto flex-1 h-full p-px"
>
{items === null ? (
<div className="flex items-center justify-center pt-5">

View file

@ -6,9 +6,9 @@ import {
ContextMenuGroup,
ContextMenuItem,
ContextMenuTrigger,
} from "@tensamin/ui";
} from "@methanium/ui";
import { useNavigate } from "@tanstack/react-router";
import { useSidebar } from "@tensamin/ui";
import { useSidebar } from "@methanium/ui";
export default function ConversationModal({ userId }: { userId: number }) {
const navigate = useNavigate();
@ -28,6 +28,13 @@ export default function ConversationModal({ userId }: { userId: number }) {
<Wrapper
loading={<Loading />}
userId={userId}
fields={[
"Display",
"Username",
"Avatar",
"OnlineStatus",
"Status",
]}
component={(user) => <Basic user={user} />}
/>
</div>

View file

@ -1,247 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useStorage } from "@tensamin/storage/context";
import { Button } from "@tensamin/ui";
import { Checkbox } from "@tensamin/ui";
import { z } from "zod";
import { ErrorScreen } from "@tensamin/ui";
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
import { log } from "@tensamin/shared/log";
import { Link } from "@tensamin/ui";
import { Label } from "@tensamin/ui";
import { CreateScreen } from "@tensamin/ui";
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
export default function Screen(props: { children: React.ReactNode }) {
const { load, save } = useStorage();
const [error, setError] = useState("");
const [errorDescription, setErrorDescription] = useState("");
const [remoteDocs, setRemoteDocs] = useState<
z.infer<typeof legalDocsSchema> | undefined
>(undefined);
const [localDocs, setLocalDocs] = useState<
z.infer<typeof legalDocsSchema> | undefined
>(undefined);
const [loading, setLoading] = useState(true);
const [acceptedPP, acceptPP] = useState(false);
const [acceptedTOS, acceptTOS] = useState(false);
const [hasContinued, setHasContinued] = useState(false);
const [userId, setUserId] = useState<number | undefined>(undefined);
// Saves
const handleContinueLegal = useCallback((): void => {
const currentDocs = remoteDocs;
if (!currentDocs) {
return;
}
save("accepted_privacy_policy", true);
save("accepted_terms_of_service", true);
save("legal_docs", currentDocs);
setHasContinued(true);
}, [remoteDocs, save]);
useEffect(() => {
let active = true;
void (async () => {
try {
const id = await load("user_id");
if (!active) {
return;
}
setUserId(id);
if (id === 0) {
return;
}
const current = await fetch("https://legal.tensamin.net/api/current")
.then((res) => res.json())
.catch((err) => {
if (!active) {
return undefined;
}
setError("Failed to load legal documents");
setErrorDescription(
"An error occurred while fetching the legal documents from the server. Please try again later.",
);
log(0, "Legal", "red", "Failed to fetch legal documents", err);
return undefined;
});
if (!active || current === undefined) {
return;
}
const safeCurrent = legalDocsSchema.safeParse(current);
if (!safeCurrent.success) {
setError("Failed to load legal documents");
setErrorDescription(
"The legal documents data received from the server is invalid. Please try again later.",
);
log(
0,
"Legal",
"red",
"Invalid legal documents data",
safeCurrent.error,
);
return;
}
setRemoteDocs(safeCurrent.data);
const currentLocalDocs = await load("legal_docs");
setLocalDocs(currentLocalDocs);
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
load("accepted_privacy_policy"),
load("accepted_terms_of_service"),
]);
if (!active) {
return;
}
acceptPP(
loadedAcceptedPP &&
!!currentLocalDocs &&
currentLocalDocs.pp.hash === safeCurrent.data.pp.hash,
);
acceptTOS(
loadedAcceptedTOS &&
!!currentLocalDocs &&
currentLocalDocs.tos.hash === safeCurrent.data.tos.hash,
);
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
}, [load]);
if (error !== "" && errorDescription !== "") {
return <ErrorScreen error={error} description={errorDescription} />;
}
if (loading || userId === undefined) {
return null;
}
const docsMatch =
localDocs !== undefined &&
remoteDocs !== undefined &&
localDocs.pp.hash === remoteDocs.pp.hash &&
localDocs.tos.hash === remoteDocs.tos.hash;
if (
(acceptedPP && acceptedTOS && (docsMatch || hasContinued)) ||
userId === 0
) {
return <>{props.children}</>;
}
return (
<CreateScreen>
<div className="h-full flex flex-col gap-15 p-10 py-20 md:p-40 w-full lg:w-2/3">
<h1 className="text-3xl md:text-4xl font-bold">
Privacy Policy & ToS
<p className="text-muted-foreground text-[20px] font-normal pt-3">
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
</p>
</h1>
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
<div className="justify-start items-start flex flex-col gap-2">
<BigCheckbox
id="acceptPP"
checked={acceptedPP}
onChange={acceptPP}
label="I agree to the Privacy Policy"
/>
<BigCheckbox
id="acceptTOS"
checked={acceptedTOS}
onChange={acceptTOS}
label="I agree to the Terms of Service"
/>
<div className="w-full border-t-2" />
<Link
label="Privacy Policy"
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
/>
<Link
label="Terms of Service"
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
/>
</div>
</div>
<ContinueButton
disabled={!acceptedPP || !acceptedTOS}
onClick={handleContinueLegal}
/>
</div>
</CreateScreen>
);
}
// Components
function ContinueButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<div className="w-full flex justify-end">
<Button
size="lg"
className="text-md w-full md:w-auto"
onClick={onClick}
disabled={disabled}
>
Continue
</Button>
</div>
);
}
function BigCheckbox({
id,
label,
checked,
onChange,
}: {
id: string;
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center space-x-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={onChange}
className="size-5.5 rounded-md flex items-center justify-center"
/>
<Label htmlFor={id} className="text-lg">
{label}
</Label>
</div>
);
}

View file

@ -9,30 +9,31 @@ import {
} from "@tanstack/react-router";
import "./index.css";
import "@tensamin/ui/index.css";
import "@methanium/ui/index.css";
import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings";
import OnboardingGate from "@tensamin/onboarding";
import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen";
import Login from "@/routes/screens/login";
import CallPopout from "@tensamin/call/popout";
import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState";
import { Provider as MTPProvider } from "@tensamin/mtp";
import UserProvider from "@tensamin/user/context";
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler";
import NotificationsProvider from "@tensamin/notifications/context";
import PwaRuntime from "@tensamin/pwa/runtime";
import TAuthWrapper from "@tensamin/tauth/context";
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui";
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react";
@ -43,13 +44,12 @@ import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log";
import LegalWrapper from "@/features/legal/screen";
import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { HotkeysProvider } from "@tensamin/hotkeys";
const wrapper = document.getElementById("root");
@ -131,6 +131,11 @@ function ThemeStorageBridge() {
setThemeBorderRadius,
themeCustomCss,
setThemeCustomCss,
parentThemeId,
setParentThemeId,
applyThemePreset,
themeDesign,
setThemeDesign,
} = useTheme();
const loadedRef = useRef(false);
@ -145,6 +150,8 @@ function ThemeStorageBridge() {
load("theme_tint"),
load("theme_border_radius"),
load("theme_custom_css"),
load("theme_parent"),
load("theme_design"),
]).then(
([
color,
@ -154,18 +161,23 @@ function ThemeStorageBridge() {
tint,
borderRadius,
customCss,
parent,
design,
]) => {
if (!active) {
return;
}
if (customCss === "" && parent) applyThemePreset(parent);
else setParentThemeId(parent || null);
setThemeColor(color);
setThemePalette(palette);
setThemePrimaryColor(primaryColor);
setThemePolarity(polarity);
setThemeTint(tint);
setThemeBorderRadius(borderRadius);
setThemeCustomCss(customCss);
if (customCss !== "") setThemeCustomCss(customCss);
setThemeDesign(design);
loadedRef.current = true;
},
);
@ -175,9 +187,12 @@ function ThemeStorageBridge() {
};
}, [
load,
applyThemePreset,
setThemeBorderRadius,
setThemeColor,
setThemeCustomCss,
setParentThemeId,
setThemeDesign,
setThemePalette,
setThemePolarity,
setThemePrimaryColor,
@ -212,6 +227,14 @@ function ThemeStorageBridge() {
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
}, [save, themeCustomCss]);
useEffect(() => {
if (loadedRef.current) save("theme_parent", parentThemeId ?? "");
}, [parentThemeId, save]);
useEffect(() => {
if (loadedRef.current) save("theme_design", themeDesign);
}, [save, themeDesign]);
return null;
}
@ -228,30 +251,23 @@ function RootShell() {
tintStorageKey={null}
borderRadiusStorageKey={null}
customCssStorageKey={null}
parentThemeStorageKey={null}
designStorageKey={null}
>
<div className="w-screen h-dvh overflow-hidden">
<Toaster
position={isMobile ? "top-center" : "bottom-right"}
{...(isTauri() && isMobile
? {
mobileOffset: {
top: "env(safe-area-inset-top)",
},
}
: {})}
/>
<div
data-pwa-root
className="box-border w-screen h-dvh overflow-hidden"
>
<Toaster position={isMobile ? "top-center" : "bottom-right"} />
<TooltipProvider>
<Storage>
<ThemeStorageBridge />
<LoginWrapper>
<LegalWrapper>
<Crypto>
<DesktopMediaProvider>
<Outlet />
</DesktopMediaProvider>
</Crypto>
</LegalWrapper>
</LoginWrapper>
<PwaRuntime />
<HotkeysProvider>
<ThemeStorageBridge />
<LoginWrapper>
<Outlet />
</LoginWrapper>
</HotkeysProvider>
</Storage>
</TooltipProvider>
</div>
@ -262,27 +278,63 @@ function RootShell() {
function AppShell() {
return (
<MTPProvider>
<CacheSync />
<Session>
<UserProvider>
<CallInit />
<CallPopout />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserProvider>
</Session>
</MTPProvider>
<OnboardingGate>
<Crypto>
<DesktopMediaProvider>
<MTPProvider>
<CacheSync />
<DeeplinkNavigator />
<Session>
<UserProvider>
<CallInit />
<TAuthWrapper>
<AppLayout>
<ChatContext>
<NotificationsProvider>
<Outlet />
</NotificationsProvider>
</ChatContext>
</AppLayout>
</TAuthWrapper>
</UserProvider>
</Session>
</MTPProvider>
</DesktopMediaProvider>
</Crypto>
</OnboardingGate>
);
}
function DeeplinkNavigator() {
const { deeplinks } = useDeeplinks();
const navigate = useNavigate();
const handledCount = useRef(0);
useEffect(() => {
const links = deeplinks.slice(handledCount.current);
handledCount.current = deeplinks.length;
for (const link of links) {
try {
const url = new URL(link);
const id = Number(url.searchParams.get("id"));
if (
url.protocol === "tensamin:" &&
url.hostname === "chat" &&
Number.isSafeInteger(id) &&
id > 0
) {
void navigate({ to: "/chat", search: { id } });
}
} catch {
// Ignore malformed URLs delivered by the platform.
}
}
}, [deeplinks, navigate]);
return null;
}
function createCallTrayIcon(color: string, speaking: boolean) {
const canvas = document.createElement("canvas");
canvas.width = 32;
@ -307,7 +359,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
}
function CallInit() {
useInitializeCall();
const callInvitePopup = useInitializeCall();
const { load } = useStorage();
const {
@ -370,7 +422,7 @@ function CallInit() {
});
}, [inCall, primaryColor, speaking]);
return null;
return callInvitePopup;
}
const rootRoute = createRootRoute({

View file

@ -9,7 +9,7 @@ import {
Input,
Button,
useIsMobile,
} from "@tensamin/ui";
} from "@methanium/ui";
import z from "zod";
import { useMTP } from "@tensamin/mtp";
import { useState } from "react";
@ -79,7 +79,7 @@ function AddConversationButton() {
Username: result.data,
})
.then((data) => {
if (data.data.UserId === 0) {
if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
throw new Error();
}
@ -101,7 +101,7 @@ function AddConversationButton() {
const timeout = setTimeout(() => setLoading(true), 500);
send("AddConversation", {
ChatPartnerName: result.data,
ChatPartnerId: user.data.UserId,
})
.then(() => {
insertContact(user.data.UserId);
@ -131,8 +131,12 @@ function AddConversationButton() {
setOpen(value);
}}
>
<DialogTrigger render={<Button>Add Conversation</Button>} />
<DialogContent>
<DialogTrigger
render={({ onClick }) => (
<Button onClick={onClick}>Add Conversation</Button>
)}
/>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
@ -160,7 +164,13 @@ function AddConversationButton() {
{error && (
<p className="text-sm text-destructive w-full">{error}</p>
)}
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<DialogClose
render={({ onClick }) => (
<Button onClick={onClick} variant="outline">
Cancel
</Button>
)}
/>
<Button disabled={loading} type="submit">
{loading && <Loader2 className="animate-spin" />} Continue
</Button>

View file

@ -3,8 +3,9 @@ import { type ReactNode } from "react";
import Sidebar from "@/components/sidebar";
import Navbar, { MobileNavbar } from "@/components/navbar";
import { useShowMobileNavbar } from "./useShowMobileNavbar";
import CallPopout from "@tensamin/call/popout";
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core";
@ -16,7 +17,9 @@ export default function Layout({ children }: { children: ReactNode }) {
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
<SidebarProvider className="h-full min-h-0 overflow-hidden">
<Sidebar />
<CallPopout />
<div
data-app-layout
// Background of ui that is overlapping with the system ui
className={cn(
"w-full h-full min-h-0 flex flex-col overflow-hidden",

View file

@ -1,5 +1,5 @@
import Form from "@/components/screens/login/form";
import { CreateScreen } from "@tensamin/ui";
import { CreateScreen } from "@methanium/ui";
/**
* Executes Page.

View file

@ -1,4 +1,4 @@
import { createReadStream, realpathSync, statSync } from "node:fs";
import { createReadStream, statSync } from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -7,14 +7,11 @@ import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite";
import { tensaminPwa } from "@tensamin/pwa/vite";
const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url));
const markdownPackageDir = resolve(appDir, "../../packages/markdown");
function resolveMarkdownDependency(packageName: string): string {
return realpathSync(resolve(markdownPackageDir, "node_modules", packageName));
}
function deepFilterAssetHeaders(rootDir: string): Plugin {
const serveModel = (
@ -56,24 +53,6 @@ export default defineConfig({
clearScreen: false,
resolve: {
tsconfigPaths: true,
alias: [
{
find: "@codemirror/commands",
replacement: resolveMarkdownDependency("@codemirror/commands"),
},
{
find: "@codemirror/lang-markdown",
replacement: resolveMarkdownDependency("@codemirror/lang-markdown"),
},
{
find: "@codemirror/state",
replacement: resolveMarkdownDependency("@codemirror/state"),
},
{
find: "@codemirror/view",
replacement: resolveMarkdownDependency("@codemirror/view"),
},
],
dedupe: [
"react",
"react-dom",
@ -81,9 +60,7 @@ export default defineConfig({
"use-sync-external-store",
"@tanstack/history",
"@tanstack/react-router",
"@tanstack/react-store",
"@tanstack/router-core",
"@tanstack/store",
"@tensamin/crypto",
"@tensamin/settings",
"@tensamin/storage",
@ -91,21 +68,17 @@ export default defineConfig({
"@tensamin/user",
"@tensamin/tauri",
"@tensamin/chat",
"@codemirror/commands",
"@codemirror/lang-markdown",
"@codemirror/state",
"@codemirror/view",
],
},
server: {
port: 5173,
port: 3000,
strictPort: true,
host: host || "0.0.0.0",
host: "0.0.0.0",
hmr: host
? {
protocol: "ws",
host,
port: 1421,
clientPort: 3000,
}
: undefined,
watch: {
@ -133,9 +106,10 @@ export default defineConfig({
"@tensamin/chat",
"@tensamin/crypto",
"@tensamin/crypto/context",
"@tensamin/markdown",
"@tensamin/hotkeys",
"@tensamin/mtp",
"@tensamin/notifications",
"@tensamin/onboarding",
"@tensamin/shared",
"@tensamin/shared/data",
"@tensamin/shared/log",
@ -152,8 +126,10 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
plugins: [
...tensaminPwa(),
methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
{
name: "workspace-realpath-resolution",
enforce: "post",

View file

@ -5,6 +5,11 @@ import reactHooks from "eslint-plugin-react-hooks";
import * as tsParser from "@typescript-eslint/parser";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import {
inlineSingleUseDeclarations,
noReactNamespaceImport,
noWindowLocationReload,
} from "./utils/eslint-rules/index.js";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
@ -28,10 +33,20 @@ export default [
},
plugins: {
"react-hooks": reactHooks,
tensamin: {
rules: {
"inline-single-use-declarations": inlineSingleUseDeclarations,
"no-react-namespace-import": noReactNamespaceImport,
"no-window-location-reload": noWindowLocationReload,
},
},
},
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"tensamin/inline-single-use-declarations": "error",
"tensamin/no-react-namespace-import": "error",
"tensamin/no-window-location-reload": "error",
},
},
];

720
flake.nix
View file

@ -6,340 +6,436 @@
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = {
self,
nixpkgs,
rust-overlay,
...
}: let
systems = ["x86_64-linux"];
forAllSystems = nixpkgs.lib.genAttrs systems;
version = "0.0.10";
x86_64DebHash = "sha256-VVKkZ9yQ7BoLnYnCf5OSHcrakoBNfUx/uWxBmm3gBZE=";
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
in {
packages = forAllSystems (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
debArtifact = "Tensamin-${version}-linux-amd64.deb";
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
packageDeb = src:
pkgs.stdenv.mkDerivation {
pname = "tensamin";
inherit version src;
nativeBuildInputs = with pkgs; [
autoPatchelfHook
dpkg
makeWrapper
outputs =
{
self,
nixpkgs,
rust-overlay,
...
}:
let
systems = [ "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
version = "0.0.11";
in
{
packages = forAllSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libsecret
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
buildInputs = electronRuntimeLibs;
electron = pkgs.electron;
pnpm = pkgs.pnpm;
mtpTypeMaps = pkgs.fetchgit {
url = "https://git.methanium.net/tensamin/mtp-type-maps";
rev = "6e5122fe44f793c0e0d3229b3d34145ce17c2d31";
hash = "sha256-/4n8F0YLJaLncefKL907P5l+en1CTjpgdFmLBF3gbiQ=";
};
mtpSource = pkgs.fetchzip {
url = "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz";
hash = "sha256-XLTa8DxP93Q4hBHRCLUzCPOqkbdb4V3aZxE5Iuq+kW0=";
};
mtpCargoDeps = pkgs.rustPlatform.fetchCargoVendor {
src = mtpSource;
hash = "sha256-8MZ65N/EtWPAggal0JkGDx3WSn+LxWQORinkqVbsrys=";
};
wasmBindgenCliSource = pkgs.fetchCrate {
pname = "wasm-bindgen-cli";
version = "0.2.127";
hash = "sha256-di+qBAdd7pENLiIB9CoZoab+W5xeDoByMREcCGTSzWo=";
};
wasmBindgenCli = pkgs.buildWasmBindgenCli {
src = wasmBindgenCliSource;
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
src = wasmBindgenCliSource;
hash = "sha256-FTv2GZIAQs0ePdIZXIXil7JbZ6kIT05VG6vqC1qNFxQ=";
};
};
desktopItem = pkgs.makeDesktopItem {
name = "tensamin";
desktopName = "Tensamin";
exec = "tensamin";
icon = "tensamin";
startupWMClass = "Tensamin";
categories = [ "Network" ];
};
defaultPackage = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "tensamin";
inherit version;
src = self;
dontConfigure = true;
dontBuild = true;
pnpmDeps = pkgs.fetchPnpmDeps {
inherit (finalAttrs) pname version src;
inherit pnpm;
fetcherVersion = 4;
hash = "sha256-imP3MTr1YLc28Z9n617m0Wt/6vPirFznzoriKzojlEg=";
};
unpackPhase = ''
runHook preUnpack
dpkg-deb -x "$src" .
runHook postUnpack
'';
nativeBuildInputs = with pkgs; [
copyDesktopItems
makeWrapper
nodejs_22
pnpm
pnpmConfigHook
cargo
lld
rustc
wasm-pack
wasmBindgenCli
binaryen
];
installPhase = ''
runHook preInstall
env.ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
mkdir -p "$out"
cp -r opt "$out/"
cp -r usr/* "$out/"
postPatch = ''
rm -rf mtp-type-maps
ln -s ${mtpTypeMaps} mtp-type-maps
mkdir -p "$out/bin"
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin"
node -e '
const fs = require("fs");
const path = "apps/electron/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = "${version}";
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
'';
substituteInPlace "$out/share/applications/tensamin.desktop" \
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
buildPhase = ''
runHook preBuild
runHook postInstall
'';
};
defaultPackage = packageDeb (pkgs.fetchurl {
url = "${forgejoBaseUrl}/${debArtifact}";
hash = x86_64DebHash;
});
localDebPath = builtins.getEnv "TENSAMIN_DEB";
localPathPackage =
if localDebPath == ""
then
pkgs.writeShellScriptBin "tensamin" ''
echo "Set TENSAMIN_DEB to a local .deb path and run with --impure." >&2
exit 1
''
else
packageDeb (builtins.path {
path = localDebPath;
name = debArtifact;
mkdir -p "$HOME/.cargo"
substitute ${mtpCargoDeps}/.cargo/config.toml "$HOME/.cargo/config.toml" \
--replace-fail @vendor@ ${mtpCargoDeps}
pnpm run copy-licenses
pnpm run build:packages
pnpm run build:web
pnpm --dir apps/tauri run gen-icons
pnpm --dir apps/electron run build
pnpm --dir apps/electron exec electron-builder --dir --linux --publish never \
--config.electronDist=${electron.dist} \
--config.electronVersion=${electron.version}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/lib/tensamin" "$out/bin"
cp -r apps/electron/release/linux-unpacked/. "$out/lib/tensamin/"
makeWrapper "$out/lib/tensamin/tensamin" "$out/bin/tensamin" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
install -Dm644 apps/electron/build/icons/icon.png \
"$out/share/icons/hicolor/512x512/apps/tensamin.png"
runHook postInstall
'';
desktopItems = [ desktopItem ];
meta = {
description = "Tensamin desktop client";
homepage = "https://git.methanium.net/tensamin/client";
mainProgram = "tensamin";
platforms = pkgs.lib.platforms.linux;
};
});
in {
default = defaultPackage;
tensamin = defaultPackage;
electron = defaultPackage;
localPathForDev = localPathPackage;
});
in
{
default = defaultPackage;
tensamin = defaultPackage;
electron = defaultPackage;
}
);
devShells = forAllSystems (system: let
pkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
devShells = forAllSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
};
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
electronRuntimeLibs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
atk
cairo
cups
dbus
expat
fontconfig
freetype
gdk-pixbuf
glib
gtk3
libdrm
libgbm
libglvnd
libnotify
libpulseaudio
libsecret
libuuid
libxkbcommon
mesa
nspr
nss
pango
pipewire
systemd
wayland
libX11
libXScrnSaver
libXcomposite
libXcursor
libXdamage
libXext
libXfixes
libXi
libXrandr
libXtst
libxcb
];
buildToolsVersion = "35.0.0";
ndkVersion = "29.0.14206865";
android = pkgs.androidenv.composeAndroidPackages {
cmdLineToolsVersion = "8.0";
toolsVersion = "26.1.1";
platformToolsVersion = "35.0.2";
buildToolsVersions = [buildToolsVersion];
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"
];
};
buildToolsVersion = "35.0.0";
ndkVersion = "29.0.14206865";
android = pkgs.androidenv.composeAndroidPackages {
cmdLineToolsVersion = "8.0";
toolsVersion = "26.1.1";
platformToolsVersion = "35.0.2";
buildToolsVersions = [ buildToolsVersion ];
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"
];
};
commonDeps = [rustToolchain pkgs.wasm-pack pkgs.lld];
appImageToolsArchive = pkgs.fetchurl {
url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage@1.0.3/appimage-tools-runtime-20251108.tar.gz";
hash = "sha256-hAIaeO4hSub9M6LWKpK6JVQt0QvIa/EXqbLQu6ROdmU=";
};
appImageTools = pkgs.runCommand "electron-builder-appimage-tools-nix" {nativeBuildInputs = [pkgs.gnutar pkgs.gzip];} ''
mkdir -p "$out"
tar -xzf ${appImageToolsArchive} --strip-components=1 -C "$out"
rm -f "$out/mksquashfs" "$out/desktop-file-validate"
ln -s ${pkgs.squashfsTools}/bin/mksquashfs "$out/mksquashfs"
ln -s ${pkgs.desktop-file-utils}/bin/desktop-file-validate "$out/desktop-file-validate"
'';
in rec {
default = electron;
commonDeps = [
rustToolchain
pkgs.wasm-pack
pkgs.lld
];
appImageToolsArchive = pkgs.fetchurl {
url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage@1.0.3/appimage-tools-runtime-20251108.tar.gz";
hash = "sha256-hAIaeO4hSub9M6LWKpK6JVQt0QvIa/EXqbLQu6ROdmU=";
};
appImageTools =
pkgs.runCommand "electron-builder-appimage-tools-nix"
{
nativeBuildInputs = [
pkgs.gnutar
pkgs.gzip
];
}
''
mkdir -p "$out"
tar -xzf ${appImageToolsArchive} --strip-components=1 -C "$out"
rm -f "$out/mksquashfs" "$out/desktop-file-validate"
ln -s ${pkgs.squashfsTools}/bin/mksquashfs "$out/mksquashfs"
ln -s ${pkgs.desktop-file-utils}/bin/desktop-file-validate "$out/desktop-file-validate"
'';
in
rec {
default = electron;
electron = pkgs.mkShell {
packages = with pkgs;
[
nodejs_22
corepack_22
coreutils
pnpm
pkgs.electron
pkg-config
python3
gcc
gnumake
git
jq
patchelf
dpkg
rpm
fpm
curl
fakeroot
rsync
xz
p7zip
]
++ electronRuntimeLibs ++ commonDeps;
electron = pkgs.mkShell {
packages =
with pkgs;
[
nodejs_22
corepack_22
coreutils
pnpm
pkgs.electron
pkg-config
python3
gcc
gnumake
git
jq
patchelf
dpkg
rpm
fpm
curl
fakeroot
rsync
xz
p7zip
]
++ electronRuntimeLibs
++ commonDeps;
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
export npm_config_build_from_source=true
export USE_SYSTEM_FPM=true
export ELECTRON_BUILDER_7ZIP_PATH="${pkgs.p7zip}/bin/7za"
export APPIMAGE_TOOLS_PATH="${appImageTools}"
shellHook = ''
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
export npm_config_build_from_source=true
export USE_SYSTEM_FPM=true
export ELECTRON_BUILDER_7ZIP_PATH="${pkgs.p7zip}/bin/7za"
export APPIMAGE_TOOLS_PATH="${appImageTools}"
alias electron-install='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm install'
alias electron-build-web='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm run build:web'
alias electron-dev='pnpm run dev'
alias electron-package='pnpm run package:linux'
alias electron-validate='pnpm run validate'
'';
};
alias electron-install='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm install'
alias electron-build-web='cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && pnpm run build:web'
alias electron-dev='pnpm run dev'
alias electron-package='pnpm run package:linux'
alias electron-validate='pnpm run validate'
'';
};
tauri = pkgs.mkShell {
buildInputs = with pkgs;
[
jdk17
gradle
pnpm
bun
nodejs
corepack_22
coreutils
pkg-config
git
jq
curl
]
++ [
android.androidsdk
android-studio-tools
]
++ commonDeps;
tauri = pkgs.mkShell {
buildInputs =
with pkgs;
[
jdk17
gradle
pnpm
bun
nodejs
corepack_22
coreutils
pkg-config
git
jq
curl
]
++ [
android.androidsdk
android-studio-tools
]
++ commonDeps;
shellHook = ''
sdkSource="${android.androidsdk}/libexec/android-sdk"
if [ -d apps/tauri/src-tauri ]; then
projectRoot="$PWD/apps/tauri"
else
projectRoot="$PWD"
fi
androidHome="$projectRoot/.android"
sdkRoot="$androidHome/sdk"
shellHook = ''
sdkSource="${android.androidsdk}/libexec/android-sdk"
if [ -d apps/tauri/src-tauri ]; then
projectRoot="$PWD/apps/tauri"
else
projectRoot="$PWD"
fi
androidHome="$projectRoot/.android"
sdkRoot="$androidHome/sdk"
mkdir -p "$androidHome"
if [ -L "$sdkRoot" ]; then
rm -f "$sdkRoot"
fi
mkdir -p "$sdkRoot"
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"
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"
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}"
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 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}"
aapt2Path="''${sdkRootAbs}/build-tools/${buildToolsVersion}/aapt2"
if [ -x "$aapt2Path" ]; then
mkdir -p "$HOME/.gradle"
gradleProperties="$HOME/.gradle/gradle.properties"
touch "$gradleProperties"
if grep -q '^android\.aapt2FromMavenOverride=' "$gradleProperties"; then
sed -i "s|^android\.aapt2FromMavenOverride=.*|android.aapt2FromMavenOverride=$aapt2Path|" "$gradleProperties"
else
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
fi
fi
'';
};
});
};
aapt2Path="''${sdkRootAbs}/build-tools/${buildToolsVersion}/aapt2"
if [ -x "$aapt2Path" ]; then
mkdir -p "$HOME/.gradle"
gradleProperties="$HOME/.gradle/gradle.properties"
touch "$gradleProperties"
if grep -q '^android\.aapt2FromMavenOverride=' "$gradleProperties"; then
sed -i "s|^android\.aapt2FromMavenOverride=.*|android.aapt2FromMavenOverride=$aapt2Path|" "$gradleProperties"
else
printf '\nandroid.aapt2FromMavenOverride=%s\n' "$aapt2Path" >> "$gradleProperties"
fi
fi
adb devices
'';
};
}
);
};
}

View file

@ -1,22 +0,0 @@
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Material-UI SAS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2019 Material-UI SAS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,20 +0,0 @@
MIT License
Copyright (c) 2021-present Floating UI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,20 +0,0 @@
MIT License
Copyright (c) 2021-present Floating UI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,20 +0,0 @@
MIT License
Copyright (c) 2021-present Floating UI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

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