Compare commits
348 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c54166a4 | |||
| f438018400 | |||
| 89003f71a4 | |||
| b364c53c61 | |||
| 9c5456b492 | |||
| b057795735 | |||
| c81a036c4e | |||
| bdc017e609 | |||
| 27160145f9 | |||
| 8ea2cb48e9 | |||
| c3ef995404 | |||
| b4ed3aced0 | |||
| 30d00194be | |||
| 31b394a6e0 | |||
| 271d6fff5c | |||
| 8b8d71bc4d | |||
| 40f36cb182 | |||
| 2372731504 | |||
| 43a99a8d6d | |||
| f73abbd860 | |||
| e822f6f58f | |||
| 255dbdc712 | |||
| 51a8630114 | |||
| d539656535 | |||
| 5bc30c950c | |||
| 588b843904 | |||
| dbf8030e93 | |||
| 615770722e | |||
| f1cba99b9e | |||
| f60c5c676a | |||
| 8e6be3256d | |||
| 508c53b451 | |||
| e2f86bc377 | |||
| 92a6e01a26 | |||
| 3d959aaadf | |||
| 787437c3f1 | |||
| be5647281d | |||
| fc8fc275cb | |||
| e19a71e892 | |||
| 11869be443 | |||
| 3fa8b6dbc1 | |||
| f9f8e3fdb8 | |||
| 4ed3f04300 | |||
| a7ffcbff83 | |||
| 82600915f1 | |||
| 93098a74ca | |||
| c8f0e8efd5 | |||
| fd9b8a88d6 | |||
| ab2f7130fe | |||
| b9a3dde1aa | |||
| 65d2446804 | |||
| faa12a4ebb | |||
| 37dd1b4f23 | |||
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 | |||
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 | |||
| 92baa626d6 | |||
| cd7ef8dccc | |||
| 58efc66ca7 | |||
| 1e139fb86e | |||
| eeb713f03d | |||
| 837b5a326e | |||
| b1f37752d6 | |||
| 854c4b91a8 | |||
| d3b708636f | |||
| c449943b52 | |||
| db59e3f658 | |||
| 6c6828006b | |||
| 21376daf39 | |||
| 6d0e4fb1f0 | |||
| 36ab7eca8a | |||
| ddd696a790 | |||
| 53b3b5e1fc | |||
| a4573b315d | |||
| c271e95100 | |||
| 888ed1b217 | |||
| a974e5b8ab | |||
| 7ebc5f6c9d | |||
| b8b451ef4f | |||
| d7c0c3d0a2 | |||
| 70ce824120 | |||
| d10840e0b2 | |||
| cd59ee30d6 | |||
| e56e22631b | |||
| dbf90504b4 | |||
| 256a613134 | |||
| e1423dba32 | |||
| 7730828403 | |||
| ecbd11e369 | |||
| 7d5f3b37cd | |||
| 7ce6b1c1d4 | |||
| 4399d39f08 | |||
| ad239ec549 | |||
| d1193142ae | |||
| 997252c4cb | |||
| 890d5dc2b7 | |||
| 759113b9ce | |||
| f981904e7f | |||
| 1fe196b839 | |||
| 1b4cf63e07 | |||
| 18197a9f95 | |||
| 383245ec90 | |||
| 5e59ee22f6 | |||
| be6f5b9c6f | |||
| 2243c646ac | |||
| 3df7cc01ea | |||
| f95858c703 | |||
| d29e2c1174 | |||
| 11a9c8b173 | |||
| d623a59c87 | |||
| 2a2e334f51 | |||
| 9228cc635c | |||
| 7056bfdd1c | |||
| e8a5d6dc5e | |||
| c5851195c7 | |||
| 258ad3511d | |||
| 2583613d7f | |||
| 04d9909e92 | |||
| e05f5445e7 | |||
| a4f7a16c90 | |||
| f6e0e4dd09 | |||
| d449e128a8 | |||
| 5b0aa24a8d | |||
| 540b506f91 | |||
| ede0d00f5b | |||
| ef1f9f45d8 | |||
| 95156a65eb | |||
| 915569db39 | |||
| 526f9d7bcc | |||
| cd79b77e6f | |||
| 7726498218 | |||
| f1d9602a76 | |||
| 45bcbb449c | |||
| ef98efb938 | |||
| 7355b343c8 | |||
| 1a39c1bb33 | |||
| 70209be1de | |||
| 3dbeabd268 | |||
| 6bff163f09 | |||
| ddeedeb71d | |||
| 7e85ffe548 | |||
| 0d30a462b3 | |||
| 745b8cd69d | |||
| 5ace6735d8 | |||
| ffaa6ceb70 | |||
| ccac822cb9 | |||
| 74a0ef6c32 | |||
| 9de2c368bf | |||
| 88345420e0 | |||
| 0354676d2f | |||
| cb7bd99fb4 | |||
| 3b55fcaf2c | |||
| 7ae1d5ba8c | |||
| 50bfb5b137 | |||
| 41359816f1 | |||
| 8ad212291a | |||
| 18a6365586 | |||
| 898b7469bb | |||
| a5eadef663 | |||
| e8074e7da0 | |||
| 88409aff5d | |||
| b7d7a85253 | |||
| 502c6af1b8 | |||
| adc9686035 | |||
| 472665b980 | |||
| c52c65faa9 | |||
| 79f1786ac6 | |||
| ca77214e2d | |||
| 8c27e8eafa | |||
| 69733168ed | |||
| bf87add18d | |||
| c871687fd2 | |||
| 61a1eb37ea | |||
| 9b9802ff6e | |||
| 2463081949 | |||
| 1eea80c529 | |||
| 830dac4cdd | |||
| cb46483f5d | |||
| 87fed820dc | |||
| fca5211008 | |||
| c430a590fc | |||
| 3b884b0415 | |||
| affff0b433 | |||
| a5c0889b0a | |||
| 90fa0a7e95 | |||
| 767db72847 | |||
| 0288d7a476 | |||
| 34b972ec2a | |||
| c55173800b | |||
| fc9f1ec143 | |||
| 12e4b597a0 | |||
| 5c05afb009 | |||
| a636a3c1c1 | |||
| c9fe4879e0 | |||
| 61462516d2 | |||
| d39a0fb6dc | |||
| 9207f473cd | |||
| 5367544b59 | |||
| e6b698bf14 | |||
| 6caa674c19 | |||
| d9377fc52a | |||
| 1c18078b1f | |||
| 0e05a2cd85 | |||
| b55ccf899f | |||
| b2eb214d9f | |||
| 010a810485 | |||
| 6a9a0bb804 | |||
| f7c60945d0 | |||
| 15ef9ece66 | |||
| b789f4b10d | |||
| 02e1af4517 | |||
| e292df82f0 | |||
| b3804d805b | |||
| 02c4bb1c9b | |||
| 20216b37c6 | |||
| 2c586351fc | |||
| b89a7e2617 | |||
| 8d69329763 | |||
| 2fdbe9ee3a | |||
| 0d1d4496c3 | |||
| baf9c2e054 | |||
| b54fe0b56d | |||
| 89eb8d97c6 | |||
| 32bff77fd0 | |||
| d10b0fb5c3 | |||
| 1370f8794b | |||
| eef884c782 | |||
| d9f3c6c562 | |||
| b61f929cf7 | |||
| b0967dd2c5 | |||
| f8859b4601 | |||
| 4068e8c7b6 | |||
| cb8e729624 | |||
| 1e9395fdc2 | |||
| 9b3ef8c24f | |||
| ba8ba75490 | |||
| 122191276e | |||
| 5a3dc15704 | |||
| 10d3e91bbb | |||
| 04da0f211d | |||
| 4d787b68b4 | |||
| c2df741055 | |||
| b81c63a2d3 | |||
| 098e4f4c1d | |||
| e83c23d698 | |||
| 68cd4108dd | |||
| ed7cb72ebe | |||
| 177a4c059f | |||
| 1440b11740 | |||
| 286836f539 | |||
| 3b62fbc243 | |||
| 8d7ed592b8 | |||
| 153e40bdcb | |||
| 415fa10ed0 | |||
| 8b7b6a3b0f | |||
| 05a17b7c1c | |||
| dd6ae63491 | |||
| b1cdc4d184 | |||
| 263d2455a8 | |||
| 3755a927c3 | |||
| 0b3a6280fc | |||
| 3313dd84f3 | |||
| 2a5f836b86 | |||
| bf02380c19 | |||
| 375d28efa5 | |||
| 1e88941796 | |||
| 7ac93a95e6 | |||
| a275703ba6 | |||
| 035b5f078d | |||
| 13c6ad68f0 | |||
| 84a9f35ed8 | |||
| 7eb8224bb0 | |||
| 574436ab71 | |||
| 5e483368ec | |||
| b4457b74f5 | |||
| bb7aeec345 | |||
| 1bf4f4bb83 | |||
| 3049a0e39d | |||
| 2003a26c23 | |||
| 1e0cf67335 | |||
| 5f753412a6 | |||
| a560f24c45 | |||
| 87c2e45bb4 | |||
| b65a3994f3 | |||
| aa609389fa | |||
| cb3cbd8827 | |||
| d1f38ce313 | |||
| 0d65a134fd | |||
| 12c66d676a | |||
| 0dde1dd1a3 | |||
| 81d3587a91 | |||
| 8be1105333 | |||
| f9e1d2f073 | |||
| 341f5f227d | |||
| 8baac2fd1e | |||
| 05870ef8fa | |||
| f9e340dbec | |||
| 91eedc0e8e | |||
| e56533918e | |||
| c87d4e82d3 | |||
| 187f8dc95a | |||
| f612c1bb50 | |||
| a9d5e2d430 | |||
| a065cc0a2c | |||
| 005aebd60b | |||
| 68bc1f76f6 | |||
| f1c7501807 | |||
| e16b248366 | |||
| 9b764053c4 | |||
| 950ef5b706 | |||
| cc84bb7ff6 | |||
| 825160ee46 | |||
| 72d02e385f | |||
| c9b885b91c | |||
| adbdfaa2aa | |||
| 6f1e1a5f9a | |||
| f9bbcdee47 | |||
| ccbef6d959 | |||
| 73ddeecfca | |||
| 727aced411 | |||
| 3d605c09bc | |||
| 74074115d2 | |||
| 12bb585081 | |||
| 665f450878 | |||
| e2e8217b86 | |||
| 6c6a23e672 | |||
| 16d179f8e8 | |||
| 12e91c0bbe | |||
| 8b9a40f059 | |||
| eac19823ea | |||
| a5e930ac17 | |||
| c3e0c47d32 | |||
| 8f9b823d69 | |||
| 7ad8ba82b6 | |||
| b44a785d20 | |||
| 331b1298f8 | |||
| 02ca3e3581 | |||
| eb8f702576 | |||
| bc8a7c5a32 | |||
| 1c67a5c97f | |||
| 6301ebb392 | |||
| 31d21dd2c2 | |||
| 9add0a4d61 | |||
| 500f1c4bc2 | |||
| a38e2f96c0 |
@@ -0,0 +1,24 @@
|
|||||||
|
# Copy to .env.release (gitignored) and fill in.
|
||||||
|
# Consumed by scripts/release.mjs.
|
||||||
|
#
|
||||||
|
# electron-updater hash-verifies via SHA-512 embedded in latest.yml, so no
|
||||||
|
# minisign / Tauri signing key is needed any more — the legacy
|
||||||
|
# TAURI_SIGNING_* vars from the Tauri build can be removed once you've
|
||||||
|
# stopped publishing Tauri releases to this host.
|
||||||
|
|
||||||
|
# Host serving latest.yml + installer artifacts over HTTPS.
|
||||||
|
# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same
|
||||||
|
# artifacts under update.netralax.cloud (point its DNS at the new IP) so that
|
||||||
|
# already-installed clients — which have update.netralax.cloud baked in — can
|
||||||
|
# still pull the release that switches them over to .de.
|
||||||
|
UPDATE_HOST=update.netralax.de
|
||||||
|
|
||||||
|
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
|
||||||
|
UPDATE_SSH_USER=chatapp-deploy
|
||||||
|
|
||||||
|
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
|
||||||
|
# default id_rsa.
|
||||||
|
UPDATE_SSH_KEY=
|
||||||
|
|
||||||
|
# Absolute path on the server where windows/ artifacts + latest.json live.
|
||||||
|
UPDATE_REMOTE_PATH=/var/www/updates/windows
|
||||||
@@ -1,35 +1,27 @@
|
|||||||
name: Release desktop app
|
name: Release desktop app (manual backup)
|
||||||
|
|
||||||
# Tag a version to trigger a release:
|
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
|
||||||
# git tag v0.1.0 && git push --tags
|
# This workflow is kept as a manual backup — trigger it from the Actions tab
|
||||||
#
|
# if the local build host is unavailable.
|
||||||
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
|
|
||||||
# uploads them to a GitHub Release, and publishes `latest.json` for the
|
|
||||||
# updater plugin to discover.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
tags:
|
inputs:
|
||||||
- "v*"
|
tag:
|
||||||
|
description: "Tag to build (e.g. v0.10.2) — must already exist"
|
||||||
|
required: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
strategy:
|
runs-on: windows-latest
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
|
|
||||||
args: "--target universal-apple-darwin --bundles app,updater"
|
|
||||||
- platform: windows-latest
|
|
||||||
args: ""
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -42,8 +34,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
|
||||||
|
|
||||||
- name: Install JS deps
|
- name: Install JS deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -54,18 +44,16 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
# Client-side env vars baked into the bundle — paste your prod values
|
|
||||||
# into the repo's Actions → Secrets so releases point at prod.
|
|
||||||
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
|
||||||
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||||
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
|
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
|
||||||
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
|
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
|
||||||
with:
|
with:
|
||||||
projectPath: apps/desktop
|
projectPath: apps/desktop
|
||||||
tagName: ${{ github.ref_name }}
|
tagName: ${{ inputs.tag }}
|
||||||
releaseName: "ChatApp ${{ github.ref_name }}"
|
releaseName: "ChatApp ${{ inputs.tag }}"
|
||||||
releaseBody: "See the assets below to download this version."
|
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
tauriScript: pnpm exec tauri
|
tauriScript: pnpm exec tauri
|
||||||
args: ${{ matrix.args }}
|
args: "--bundles nsis"
|
||||||
|
|||||||
+11
-7
@@ -15,7 +15,9 @@ out/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.env.release
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.release.example
|
||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
@@ -27,14 +29,13 @@ web-build/
|
|||||||
*.key
|
*.key
|
||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
|
|
||||||
# Tauri updater signing key (private — NEVER commit)
|
# Electron desktop build artifacts
|
||||||
.tauri-updater.key
|
apps/desktop/out/
|
||||||
# .tauri-updater.key.pub is public, may be committed
|
apps/desktop/release/
|
||||||
|
|
||||||
# Tauri
|
# Bundle visualizer reports
|
||||||
apps/desktop/src-tauri/target/
|
apps/desktop/stats.html
|
||||||
apps/desktop/src-tauri/gen/
|
apps/desktop/stats.json
|
||||||
apps/desktop/src-tauri/WixTools/
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
@@ -56,6 +57,9 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Claude Code per-project local settings
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Coverage
|
# Coverage
|
||||||
coverage/
|
coverage/
|
||||||
*.lcov
|
*.lcov
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# React Native / Expo expects a flat (npm/yarn-style) node_modules layout
|
||||||
|
# where ALL transitive deps are reachable from any package. pnpm's strict
|
||||||
|
# isolation breaks that — Metro hits a fresh missing-module error for every
|
||||||
|
# transitive dep (invariant, nullthrows, @babel/runtime/helpers/*, ...).
|
||||||
|
#
|
||||||
|
# shamefully-hoist=true hoists everything to the workspace-root node_modules
|
||||||
|
# (like npm/yarn) so all transitive deps are reachable via standard Node
|
||||||
|
# resolution. This is the official Expo monorepo workaround and is the
|
||||||
|
# only sustainable answer for Expo + RN + pnpm. Desktop (Electron+Vite)
|
||||||
|
# bundles via Vite, so the flatter layout doesn't affect it.
|
||||||
|
shamefully-hoist=true
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// electron-vite config. Three targets:
|
||||||
|
// - main: the Electron main-process entry (electron/main.ts)
|
||||||
|
// - preload: the contextBridge script (electron/preload.ts)
|
||||||
|
// - renderer: the existing React app at apps/desktop/ (unchanged root)
|
||||||
|
//
|
||||||
|
// Manual chunks + optimizeDeps config is ported from the pre-migration
|
||||||
|
// vite.config.ts so LiveKit/libsodium/supabase/react vendor bundles keep
|
||||||
|
// caching independently across app updates.
|
||||||
|
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer';
|
||||||
|
|
||||||
|
const ANALYZE = process.env.ANALYZE === 'true';
|
||||||
|
|
||||||
|
const rendererAliases = {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@shared': path.resolve(__dirname, '../../packages/shared/src'),
|
||||||
|
'@db-types': path.resolve(__dirname, '../../packages/db-types/src'),
|
||||||
|
'@ui-web': path.resolve(__dirname, '../../packages/ui-web/src'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/main',
|
||||||
|
rollupOptions: {
|
||||||
|
input: path.resolve(__dirname, 'electron/main.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
input: path.resolve(__dirname, 'electron/preload.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: '.',
|
||||||
|
// file:// loading from inside app.asar can't resolve root-absolute
|
||||||
|
// asset URLs (`/assets/...`) — they hit the OS root instead of the
|
||||||
|
// bundle. Relative base produces `./assets/...` which works in both
|
||||||
|
// dev (served from /) and packaged builds.
|
||||||
|
base: './',
|
||||||
|
// Visualizer plugins are gated behind ANALYZE=true so the production
|
||||||
|
// build never pays the analysis cost. Re-enable with:
|
||||||
|
// ANALYZE=true pnpm --filter @chat-app/desktop build
|
||||||
|
// which writes apps/desktop/stats.html (treemap) + stats.json (raw).
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
...(ANALYZE
|
||||||
|
? [
|
||||||
|
visualizer({
|
||||||
|
filename: 'stats.html',
|
||||||
|
template: 'treemap',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
open: false,
|
||||||
|
}),
|
||||||
|
visualizer({
|
||||||
|
filename: 'stats.json',
|
||||||
|
template: 'raw-data',
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: rendererAliases,
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// libsodium-wrappers-sumo (and the compact variant) ship broken
|
||||||
|
// "import" conditions in package exports — the ESM bundle
|
||||||
|
// references a sibling ./libsodium.mjs that isn't in the
|
||||||
|
// published artefact. Force esbuild to pick the "require"
|
||||||
|
// condition so the self-contained CJS build is used.
|
||||||
|
include: ['libsodium-wrappers-sumo'],
|
||||||
|
esbuildOptions: {
|
||||||
|
conditions: ['require', 'node', 'default'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'out/renderer',
|
||||||
|
rollupOptions: {
|
||||||
|
input: path.resolve(__dirname, 'index.html'),
|
||||||
|
output: {
|
||||||
|
manualChunks: (id: string): string | undefined => {
|
||||||
|
if (id.includes('node_modules/livekit-client')) return 'vendor-livekit';
|
||||||
|
if (id.includes('node_modules/libsodium-wrappers-sumo'))
|
||||||
|
return 'vendor-sodium';
|
||||||
|
if (id.includes('node_modules/@supabase')) return 'vendor-supabase';
|
||||||
|
if (
|
||||||
|
id.includes('node_modules/react-router') ||
|
||||||
|
id.includes('node_modules/react-dom') ||
|
||||||
|
id.includes('node_modules/react/')
|
||||||
|
) {
|
||||||
|
return 'vendor-react';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Single source of truth for the Electron IPC surface. Main process
|
||||||
|
// registers handlers keyed by the CHANNEL constants; preload exposes a
|
||||||
|
// typed `window.electronAPI` that mirrors the same shape. Renderer
|
||||||
|
// imports the type-only declarations via a d.ts published from preload.
|
||||||
|
//
|
||||||
|
// Rule: anything that the Rust side of Tauri used to do goes through
|
||||||
|
// here. If a call is not in this file it should not exist in the
|
||||||
|
// renderer.
|
||||||
|
|
||||||
|
export const CHANNELS = {
|
||||||
|
// Screen-share — replaces Rust screen_sources + browser getDisplayMedia
|
||||||
|
// coordination. Video still flows through Chromium's native pipeline
|
||||||
|
// (LiveKit JS SDK calls getDisplayMedia); we only feed it source ids.
|
||||||
|
SCREEN_GET_SOURCES: 'screen:get-sources',
|
||||||
|
SCREEN_GET_THUMBNAIL: 'screen:get-thumbnail',
|
||||||
|
/** Renderer-driven source selection: the picker modal calls this with
|
||||||
|
* the chosen sourceId BEFORE invoking getDisplayMedia. The main-process
|
||||||
|
* display-media handler then routes that source into the LiveKit track
|
||||||
|
* (instead of silently granting request.frame). Pass null to clear. */
|
||||||
|
SCREEN_SET_PENDING_SOURCE: 'screen:set-pending-source',
|
||||||
|
|
||||||
|
// System audio loopback — Implementation is renderer-driven under Electron.
|
||||||
|
// Main provides the screen source id (AUDIO_LOOPBACK_RESOLVE_SOURCE); the
|
||||||
|
// renderer then calls navigator.mediaDevices.getUserMedia with a
|
||||||
|
// chromeMediaSourceId constraint to obtain the loopback MediaStream
|
||||||
|
// directly. The legacy START/STOP channels are kept as named constants
|
||||||
|
// for now to avoid breaking type imports but have no handlers — see
|
||||||
|
// the renderer's lib/screenAudio.ts for the new flow.
|
||||||
|
AUDIO_LOOPBACK_RESOLVE_SOURCE: 'audio:loopback:resolve-source',
|
||||||
|
|
||||||
|
// Native WASAPI process-loopback path (Windows only). Implemented by
|
||||||
|
// the @chatapp/audio-loopback-native napi-rs addon; main owns the
|
||||||
|
// session and forwards PCM chunks to the renderer via the CHUNK event.
|
||||||
|
// Captures every render session on the box *except* our own PID tree,
|
||||||
|
// so peers in a video call don't hear themselves echoed back when the
|
||||||
|
// sharer ticks "include system audio". Falls back to the renderer-
|
||||||
|
// driven getUserMedia path on macOS/Linux or when the addon isn't
|
||||||
|
// available (dev builds without `pnpm build:native`, packaged builds
|
||||||
|
// missing the .node binary, etc.).
|
||||||
|
AUDIO_LOOPBACK_START: 'audio:loopback:start',
|
||||||
|
/** Window-share variant: capture audio of ONLY the picked window's
|
||||||
|
* process tree (INCLUDE_TARGET_PROCESS_TREE). Renderer derives the
|
||||||
|
* HWND from desktopCapturer's `window:<HWND>:0` source ids. */
|
||||||
|
AUDIO_LOOPBACK_START_FOR_WINDOW: 'audio:loopback:start-for-window',
|
||||||
|
AUDIO_LOOPBACK_STOP: 'audio:loopback:stop',
|
||||||
|
AUDIO_LOOPBACK_CHUNK: 'audio:loopback:chunk',
|
||||||
|
|
||||||
|
// Global hotkeys — replaces @tauri-apps/plugin-global-shortcut. Main
|
||||||
|
// owns the registration; renderer listens to `shortcut:fired` events
|
||||||
|
// for press and `shortcut:released` for PTT-style release.
|
||||||
|
SHORTCUT_REGISTER: 'shortcut:register',
|
||||||
|
SHORTCUT_UNREGISTER: 'shortcut:unregister',
|
||||||
|
SHORTCUT_IS_REGISTERED: 'shortcut:is-registered',
|
||||||
|
SHORTCUT_EVT_FIRED: 'shortcut:fired',
|
||||||
|
SHORTCUT_EVT_RELEASED: 'shortcut:released',
|
||||||
|
|
||||||
|
// OS notifications — replaces @tauri-apps/plugin-notification. Permission
|
||||||
|
// is always granted on desktop Electron; we keep the surface symmetric
|
||||||
|
// with the Tauri version so renderer code doesn't need to branch.
|
||||||
|
NOTIFY_SHOW: 'notify:show',
|
||||||
|
NOTIFY_PERMISSION: 'notify:permission',
|
||||||
|
|
||||||
|
// Tray badge — replaces tauri emit('tray-unread-update'). Main owns the
|
||||||
|
// Tray instance and overlays an unread count; renderer just pushes
|
||||||
|
// the number.
|
||||||
|
TRAY_UNREAD: 'tray:unread',
|
||||||
|
|
||||||
|
// Secure store — replaces plugin-stronghold + custom file vault. Uses
|
||||||
|
// Electron safeStorage (DPAPI/Keychain/libsecret) to seal per-user
|
||||||
|
// blobs on disk. `secure-store:open` establishes a per-user handle;
|
||||||
|
// subsequent calls use the stringified handle.
|
||||||
|
SECURE_STORE_OPEN: 'secure-store:open',
|
||||||
|
SECURE_STORE_GET: 'secure-store:get',
|
||||||
|
SECURE_STORE_SET: 'secure-store:set',
|
||||||
|
SECURE_STORE_REMOVE: 'secure-store:remove',
|
||||||
|
SECURE_STORE_CLOSE: 'secure-store:close',
|
||||||
|
|
||||||
|
// Filesystem (scoped to app local data dir) — replaces plugin-fs.
|
||||||
|
// All paths are relative to appLocalDataDir; main rejects traversal.
|
||||||
|
FS_READ: 'fs:read',
|
||||||
|
FS_WRITE: 'fs:write',
|
||||||
|
FS_EXISTS: 'fs:exists',
|
||||||
|
FS_MKDIR: 'fs:mkdir',
|
||||||
|
FS_RENAME: 'fs:rename',
|
||||||
|
FS_REMOVE: 'fs:remove',
|
||||||
|
FS_APP_LOCAL_DATA_DIR: 'fs:app-local-data-dir',
|
||||||
|
|
||||||
|
// SQLite — replaces plugin-sql. The renderer gets a handle per DB file.
|
||||||
|
SQL_LOAD: 'sql:load',
|
||||||
|
SQL_EXECUTE: 'sql:execute',
|
||||||
|
SQL_SELECT: 'sql:select',
|
||||||
|
SQL_CLOSE: 'sql:close',
|
||||||
|
|
||||||
|
// Auto-updater — replaces plugin-updater. electron-updater feed.
|
||||||
|
UPDATER_CHECK: 'updater:check',
|
||||||
|
UPDATER_DOWNLOAD_INSTALL: 'updater:download-install',
|
||||||
|
UPDATER_EVT_PROGRESS: 'updater:progress',
|
||||||
|
|
||||||
|
// Autostart — replaces @tauri-apps/plugin-autostart. Uses Electron's
|
||||||
|
// built-in app.setLoginItemSettings() to manage OS login items (Windows
|
||||||
|
// registry Run key, macOS LaunchAgent, Linux .desktop entry).
|
||||||
|
AUTOSTART_IS_ENABLED: 'autostart:is-enabled',
|
||||||
|
AUTOSTART_SET: 'autostart:set',
|
||||||
|
|
||||||
|
// Window fullscreen — replaces Tauri's `appWindow.setFullscreen(...)`.
|
||||||
|
// Used by the call cinema mode to flip the host BrowserWindow into real
|
||||||
|
// OS fullscreen so the Windows taskbar / macOS menubar gets covered.
|
||||||
|
WINDOW_SET_FULLSCREEN: 'window:set-fullscreen',
|
||||||
|
|
||||||
|
// Content-protection toggle. Enables/disables OS-level screenshot/screen-
|
||||||
|
// recording block (WDA_MONITOR on Windows, NSWindowSharingNone on macOS)
|
||||||
|
// while a view-once image is being revealed. No-op on Linux X11.
|
||||||
|
WINDOW_SET_CONTENT_PROTECTION: 'window:set-content-protection',
|
||||||
|
|
||||||
|
// Wipe-on-close — main process pushes this to the renderer right before
|
||||||
|
// exiting if the user has enabled the Settings → Sicherheit toggle. The
|
||||||
|
// renderer clears its sensitive caches (memoryWipe.ts) and acks via
|
||||||
|
// `app:wipe-before-quit:done`; main quits after the ack (or a 2s safety
|
||||||
|
// timeout, whichever comes first).
|
||||||
|
/** Main → renderer: about to quit. Renderer wipes, then resolves. */
|
||||||
|
APP_WIPE_BEFORE_QUIT: 'app:wipe-before-quit',
|
||||||
|
|
||||||
|
// OS hostname — returns the machine hostname via Node's `os.hostname()`.
|
||||||
|
// Used by AuthContext on first launch to populate the `devices` row with
|
||||||
|
// a human-readable default device name. Returns null on failure.
|
||||||
|
APP_HOSTNAME: 'app:hostname',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- Screen sources ------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ScreenSource {
|
||||||
|
/** Opaque source id. Feed unchanged into getDisplayMedia via the
|
||||||
|
* chromeMediaSourceId constraint when we want Chromium to capture it. */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'screen' | 'window';
|
||||||
|
/** Display index for monitors (0-based). null for windows. */
|
||||||
|
displayId: number | null;
|
||||||
|
/** Pre-fetched thumbnail (data URL). Cheap to get from desktopCapturer
|
||||||
|
* so we return it inline with the listing — avoids a second round-trip
|
||||||
|
* per source. */
|
||||||
|
thumbnailDataUrl: string | null;
|
||||||
|
/** App icon for windows (data URL); null when unavailable. */
|
||||||
|
iconDataUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Audio loopback ------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AudioFrame {
|
||||||
|
captureId: number;
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
/** Base64 of interleaved f32 LE stereo. Matches the old Tauri
|
||||||
|
* payload shape so the existing AudioWorklet doesn't change. */
|
||||||
|
samplesBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload shape of `AUDIO_LOOPBACK_CHUNK` events delivered from main
|
||||||
|
* to the renderer. `samples` is a Float32Array of interleaved f32
|
||||||
|
* stereo PCM at 48kHz — Electron's structured-clone marshals
|
||||||
|
* TypedArrays directly, so no base64 hop is needed (unlike the legacy
|
||||||
|
* Tauri channel). */
|
||||||
|
export interface AudioLoopbackChunk {
|
||||||
|
captureId: number;
|
||||||
|
samples: Float32Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of `AUDIO_LOOPBACK_START`. */
|
||||||
|
export interface AudioLoopbackStartResult {
|
||||||
|
captureId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Shortcuts -----------------------------------------------------------
|
||||||
|
|
||||||
|
export type ShortcutKind = 'press' | 'ptt';
|
||||||
|
|
||||||
|
export interface ShortcutRegisterArgs {
|
||||||
|
/** Accelerator in Electron syntax (e.g. `CommandOrControl+Shift+M`,
|
||||||
|
* or a bare key code like `F13`). PTT uses a single raw key. */
|
||||||
|
accelerator: string;
|
||||||
|
/** Opaque id the renderer picked. Used in `shortcut:fired` events and
|
||||||
|
* for unregister. Scoping to a logical id (not the accelerator)
|
||||||
|
* means the same key can be re-bound without double-register. */
|
||||||
|
id: string;
|
||||||
|
kind: ShortcutKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutEvent {
|
||||||
|
id: string;
|
||||||
|
/** Monotonic timestamp (ms since epoch) — useful for PTT to detect
|
||||||
|
* held-key repeats at the OS level. */
|
||||||
|
ts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Notifications -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface NotifyArgs {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** Silent = no system sound. Renderer already owns its own ringtone
|
||||||
|
* layer, so most notifications are silent. */
|
||||||
|
silent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Secure store --------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SecureStoreOpenArgs {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecureStoreHandle {
|
||||||
|
handle: string;
|
||||||
|
/** True if the platform's safeStorage is available (encrypted). False
|
||||||
|
* means the store falls back to plaintext-on-disk — the renderer
|
||||||
|
* should warn the user and avoid storing long-lived secrets. */
|
||||||
|
encrypted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Filesystem ----------------------------------------------------------
|
||||||
|
|
||||||
|
export type FsPath = string;
|
||||||
|
|
||||||
|
export interface FsWriteArgs {
|
||||||
|
path: FsPath;
|
||||||
|
/** Base64 of raw bytes. JSON IPC can't carry binary cleanly. */
|
||||||
|
dataBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FsRenameArgs {
|
||||||
|
from: FsPath;
|
||||||
|
to: FsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SQL -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SqlLoadArgs {
|
||||||
|
/** DB filename (resolved under appLocalDataDir). Tauri's plugin-sql
|
||||||
|
* calls these `sqlite:<name>`; we strip the prefix on the main side. */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlExecuteArgs {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlExecuteResult {
|
||||||
|
rowsAffected: number;
|
||||||
|
lastInsertId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SqlSelectArgs {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SqlSelectResult = Record<string, unknown>[];
|
||||||
|
|
||||||
|
// ---- Updater -------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
version: string;
|
||||||
|
releaseNotes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
available: boolean;
|
||||||
|
info: UpdateInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgress {
|
||||||
|
percent: number;
|
||||||
|
transferred: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Window content protection -------------------------------------------
|
||||||
|
|
||||||
|
export interface WindowSetContentProtectionArgs {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Runtime marker ------------------------------------------------------
|
||||||
|
|
||||||
|
/** Value exposed on `window.electronAPI.platform`. Used by the renderer
|
||||||
|
* to keep the existing `isTauriRuntime()`-style branches but pointed at
|
||||||
|
* the new runtime. */
|
||||||
|
export const ELECTRON_RUNTIME_MARKER = 'electron-chatapp-v1' as const;
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// Electron entry point. Creates the single BrowserWindow, wires all IPC
|
||||||
|
// module registrars, and keeps a single running instance (second launch
|
||||||
|
// focuses the existing window instead of opening a new one).
|
||||||
|
//
|
||||||
|
// Loads the renderer from the Vite dev server in dev (port 1420, matches
|
||||||
|
// the old Tauri devUrl so nothing in the renderer code needs to change)
|
||||||
|
// and from the built dist in packaged mode.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, desktopCapturer, ipcMain, Menu, session } from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { CHANNELS } from './ipc-types';
|
||||||
|
import { register as registerAudioLoopback } from './modules/audio-loopback';
|
||||||
|
import { register as registerAppHostname } from './modules/app-hostname';
|
||||||
|
import { register as registerAutostart } from './modules/autostart';
|
||||||
|
import { register as registerFsScoped } from './modules/fs-scoped';
|
||||||
|
import { register as registerNotifications } from './modules/notifications';
|
||||||
|
import { register as registerScreenAudio } from './modules/screen-audio';
|
||||||
|
import {
|
||||||
|
consumePendingShareSourceId,
|
||||||
|
register as registerScreenSources,
|
||||||
|
} from './modules/screen-sources';
|
||||||
|
import { register as registerSecureStore } from './modules/secure-store';
|
||||||
|
import { register as registerShortcuts } from './modules/shortcuts';
|
||||||
|
import { register as registerSql } from './modules/sql';
|
||||||
|
import { register as registerTray } from './modules/tray';
|
||||||
|
import { register as registerUpdater } from './modules/updater';
|
||||||
|
import { register as registerWindowContentProtection } from './modules/window-content-protection';
|
||||||
|
import { register as registerWindowFullscreen } from './modules/window-fullscreen';
|
||||||
|
import { attach as attachWindowState, loadState } from './window-state';
|
||||||
|
|
||||||
|
// __dirname in an ESM main-process bundle resolves to the out/main dir
|
||||||
|
// after electron-vite builds. Use a safe helper that works in both CJS
|
||||||
|
// (electron-vite main target defaults to CJS) and ESM contexts.
|
||||||
|
const __filenameSafe =
|
||||||
|
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
|
||||||
|
const __dirnameSafe = path.dirname(__filenameSafe);
|
||||||
|
|
||||||
|
const DEV_URL = 'http://localhost:1420';
|
||||||
|
const WINDOW_STATE_FILE = 'window-state.json';
|
||||||
|
|
||||||
|
// Pin userData FIRST — before any other Electron call that might cache a
|
||||||
|
// productName-derived path. The 0.17.0 release saw users get logged out
|
||||||
|
// after upgrading from 0.16.x: the most likely culprit was an internal
|
||||||
|
// path resolution kicking off the moment `setName('Netralax')` ran, so
|
||||||
|
// 0.17.1 swaps the order so the explicit override wins regardless of
|
||||||
|
// what setName triggers internally. The literal 'ChatApp' here is the
|
||||||
|
// pre-rename product folder — installed users' SQLite, secrets, sounds,
|
||||||
|
// IndexedDB all live there and we never want to leave them stranded by
|
||||||
|
// a future rebrand.
|
||||||
|
app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp'));
|
||||||
|
|
||||||
|
// App branding. productName in package.json drives the packaged exe name
|
||||||
|
// (Netralax.exe) and electron-builder installer title. setName + the
|
||||||
|
// AppUserModelId cover the live process: window title fallback, Windows
|
||||||
|
// taskbar grouping, notification source attribution.
|
||||||
|
app.setName('Netralax');
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
app.setAppUserModelId('cloud.netralax.desktop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run dev side-by-side with the installed packaged build by isolating the
|
||||||
|
// renderer profile / secret-store / SQLite / IndexedDB / localStorage in
|
||||||
|
// a separate userData dir. Without this both share `%APPDATA%\ChatApp`,
|
||||||
|
// the single-instance lock fires, and `pnpm dev` exits immediately while
|
||||||
|
// the installed prod app holds the lock. Must run BEFORE the lock check
|
||||||
|
// below + before any other module reads `app.getPath('userData')`.
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
app.setPath('userData', app.getPath('userData') + '-Dev');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup diagnostics — the 0.17.0 logout regression was hard to debug
|
||||||
|
// because we had no record of the actual resolved paths. With this log
|
||||||
|
// any future user can paste their main-process output and we can tell
|
||||||
|
// at a glance whether userData ended up where we intended.
|
||||||
|
console.log(
|
||||||
|
'[main] resolved paths',
|
||||||
|
JSON.stringify({
|
||||||
|
appName: app.getName(),
|
||||||
|
appData: app.getPath('appData'),
|
||||||
|
userData: app.getPath('userData'),
|
||||||
|
isPackaged: app.isPackaged,
|
||||||
|
platform: process.platform,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function resolvePreloadPath(): string {
|
||||||
|
// electron-vite names the preload bundle after the entry filename, so
|
||||||
|
// electron/preload.ts → out/preload/preload.mjs. ESM preload works in
|
||||||
|
// modern Electron when sandbox: false (which we have — preload needs
|
||||||
|
// Node APIs for libsodium init etc.). The previous `index.cjs` lookup
|
||||||
|
// was a stale leftover that silently never loaded — every IPC call
|
||||||
|
// (secret store, autostart, native notifications, screen sources,
|
||||||
|
// pending-share-source) returned undefined and any .x access threw.
|
||||||
|
return path.join(__dirnameSafe, '..', 'preload', 'preload.mjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRendererIndex(): string {
|
||||||
|
return path.join(__dirnameSafe, '..', 'renderer', 'index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIconPath(): string {
|
||||||
|
const packaged = path.join(process.resourcesPath || '', 'icon.ico');
|
||||||
|
const dev = path.join(app.getAppPath(), 'resources', 'icon.ico');
|
||||||
|
return app.isPackaged ? packaged : dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindow(): Promise<BrowserWindow> {
|
||||||
|
const state = await loadState(WINDOW_STATE_FILE);
|
||||||
|
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)',
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
...(state.x !== undefined ? { x: state.x } : {}),
|
||||||
|
...(state.y !== undefined ? { y: state.y } : {}),
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
resizable: true,
|
||||||
|
icon: resolveIconPath(),
|
||||||
|
show: false,
|
||||||
|
// Discord-style: no menu bar at all (no File/Edit/View/Window/Help).
|
||||||
|
// Belt: autoHideMenuBar hides it visually; suspenders: the global
|
||||||
|
// Menu.setApplicationMenu(null) below removes it entirely so Alt
|
||||||
|
// can't reveal it either.
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// sandbox: false — preload needs Node APIs (libsodium init etc.)
|
||||||
|
sandbox: false,
|
||||||
|
preload: resolvePreloadPath(),
|
||||||
|
// Strip DevTools from release builds. Disables openDevTools(),
|
||||||
|
// F12, Ctrl+Shift+I, and the right-click Inspect entry for end
|
||||||
|
// users. Packaged = release; unpackaged = `electron-vite dev`,
|
||||||
|
// where we still want the inspector for local debugging.
|
||||||
|
devTools: !app.isPackaged,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.maximized) win.maximize();
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => win.show());
|
||||||
|
|
||||||
|
attachWindowState(win, WINDOW_STATE_FILE);
|
||||||
|
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
// Auto-open DevTools in dev — the menu bar is stripped (Discord-style)
|
||||||
|
// so F12 / Ctrl+Shift+I have no chord; opening detached gives a
|
||||||
|
// separate inspector window for easy debugging.
|
||||||
|
win.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
// Forward renderer console messages to the main-process stdout so
|
||||||
|
// errors during local dev are visible in the terminal too (helps when
|
||||||
|
// the inspector isn't focused).
|
||||||
|
win.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||||
|
const tag = level === 3 ? 'error' : level === 2 ? 'warn' : level === 1 ? 'log' : 'info';
|
||||||
|
console.log('[renderer ' + tag + ']', message, '(' + sourceId + ':' + line + ')');
|
||||||
|
});
|
||||||
|
await win.loadURL(DEV_URL);
|
||||||
|
} else {
|
||||||
|
await win.loadFile(resolveRendererIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (mainWindow === win) mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureDisplayMediaHandler(): void {
|
||||||
|
// Our custom picker runs in the renderer; when LiveKit (or anything
|
||||||
|
// else) calls navigator.mediaDevices.getDisplayMedia with a specific
|
||||||
|
// chromeMediaSourceId constraint, Chromium defers to the handler we
|
||||||
|
// install here.
|
||||||
|
//
|
||||||
|
// Two paths:
|
||||||
|
// 1. Discord-style picker case — the renderer set a pending source id
|
||||||
|
// via SCREEN_SET_PENDING_SOURCE before calling getDisplayMedia. We
|
||||||
|
// resolve that id back to a desktopCapturer Source and pass it as
|
||||||
|
// the video grant. This is what makes the user's tile choice
|
||||||
|
// actually take effect.
|
||||||
|
// 2. Fallback — no pending id (e.g. a stray getDisplayMedia from
|
||||||
|
// another code path). Grant whatever the renderer's constraint
|
||||||
|
// selected by passing the frame back, matching the legacy v0.11.x
|
||||||
|
// behavior so we don't break anything off the picker code path.
|
||||||
|
//
|
||||||
|
// Audio: 'loopback' captures all system audio. We tried
|
||||||
|
// 'loopbackWithMute' (which excludes this app's own audio output from
|
||||||
|
// the capture) but it ALSO mutes the renderer's audio playback
|
||||||
|
// locally — so the user couldn't hear remote peers during a share
|
||||||
|
// and the stream had no audio either when the only thing playing was
|
||||||
|
// the muted call audio. There is no clean Electron equivalent of
|
||||||
|
// Tauri's EXCLUDE_TARGET_PROCESS_TREE that affects only the capture
|
||||||
|
// and not local playback. Trade-off chosen here: keep local audio
|
||||||
|
// working ('loopback'), let the user opt into the JS-level auto-duck
|
||||||
|
// (`screenShareSettings.duckRemoteAudioWhileSharing`) when they
|
||||||
|
// actually need the echo prevention. Default: off.
|
||||||
|
// https://www.electronjs.org/docs/latest/api/session#sessetdisplaymediarequesthandlerhandler-opts
|
||||||
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
|
(request, callback) => {
|
||||||
|
const pendingId = consumePendingShareSourceId();
|
||||||
|
if (pendingId) {
|
||||||
|
void desktopCapturer
|
||||||
|
.getSources({ types: ['screen', 'window'] })
|
||||||
|
.then((sources) => {
|
||||||
|
const source = sources.find((s) => s.id === pendingId);
|
||||||
|
if (source) {
|
||||||
|
callback({ video: source, audio: 'loopback' });
|
||||||
|
} else {
|
||||||
|
// Source vanished between picker confirm and grant (window
|
||||||
|
// closed, monitor unplugged). Fall back to the frame grant
|
||||||
|
// so getDisplayMedia doesn't hang the renderer.
|
||||||
|
callback({
|
||||||
|
video: request.frame as unknown as Electron.WebFrameMain,
|
||||||
|
audio: 'loopback',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
callback({
|
||||||
|
video: request.frame as unknown as Electron.WebFrameMain,
|
||||||
|
audio: 'loopback',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback({
|
||||||
|
video: request.frame as unknown as Electron.WebFrameMain,
|
||||||
|
audio: 'loopback',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ useSystemPicker: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-instance lock — second launch focuses existing window instead
|
||||||
|
// of spawning a duplicate process.
|
||||||
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotLock) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
void app.whenReady().then(async () => {
|
||||||
|
// Strip the default application menu globally — we don't ship any
|
||||||
|
// File/Edit/View/Window/Help entries (Discord-style chrome). Must run
|
||||||
|
// BEFORE the first BrowserWindow is created; setting it later causes
|
||||||
|
// the menu to flash for a frame on Windows.
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
configureDisplayMediaHandler();
|
||||||
|
mainWindow = await createWindow();
|
||||||
|
|
||||||
|
// Stateless registrars first.
|
||||||
|
registerScreenSources();
|
||||||
|
registerScreenAudio();
|
||||||
|
registerNotifications();
|
||||||
|
registerSecureStore();
|
||||||
|
registerFsScoped();
|
||||||
|
registerSql();
|
||||||
|
registerAutostart();
|
||||||
|
registerAppHostname();
|
||||||
|
|
||||||
|
// Window-dependent registrars — only call once mainWindow exists so
|
||||||
|
// event emitters have somewhere to send.
|
||||||
|
registerShortcuts(mainWindow);
|
||||||
|
registerTray(mainWindow);
|
||||||
|
registerUpdater(mainWindow);
|
||||||
|
registerWindowFullscreen(mainWindow);
|
||||||
|
registerWindowContentProtection(mainWindow);
|
||||||
|
registerAudioLoopback(mainWindow);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wipe-on-close: when the user enables it in Settings, the renderer is given
|
||||||
|
// a chance to clear all sensitive caches before the app process exits. If
|
||||||
|
// the renderer doesn't ack within 2 seconds we force-quit anyway — better
|
||||||
|
// to lose the wipe than to hang the app shutdown.
|
||||||
|
//
|
||||||
|
// Note: there's a separate `before-quit` listener in modules/tray.ts that
|
||||||
|
// tears down the Tray instance. Electron fires both; the tray listener is
|
||||||
|
// synchronous and doesn't touch event.preventDefault, so it doesn't fight
|
||||||
|
// our deferred-quit dance here. The `wipeRequested` flag guards re-entry
|
||||||
|
// when our own `app.quit()` below fires `before-quit` a second time.
|
||||||
|
let wipeRequested = false;
|
||||||
|
app.on('before-quit', (event) => {
|
||||||
|
if (wipeRequested) return;
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
wipeRequested = true;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
mainWindow.webContents.send(CHANNELS.APP_WIPE_BEFORE_QUIT);
|
||||||
|
const done = new Promise<void>((resolve) => {
|
||||||
|
ipcMain.once(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done', () => resolve());
|
||||||
|
});
|
||||||
|
void Promise.race([done, new Promise<void>((r) => setTimeout(r, 2000))]).finally(() => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (!mainWindow) {
|
||||||
|
void createWindow().then((w) => {
|
||||||
|
mainWindow = w;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// app:hostname adapter — returns the OS hostname via Node's `os.hostname()`.
|
||||||
|
// The renderer has no Node access (contextIsolation is ON), so it must ask
|
||||||
|
// main for this value. Used by AuthContext on first launch to populate the
|
||||||
|
// `devices` row with a human-readable default device name.
|
||||||
|
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.APP_HOSTNAME, (): string | null => {
|
||||||
|
try {
|
||||||
|
const h = os.hostname();
|
||||||
|
return typeof h === 'string' && h.length > 0 ? h : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Native WASAPI process-loopback bridge. Wraps the
|
||||||
|
// @chatapp/audio-loopback-native napi-rs addon and forwards PCM chunks
|
||||||
|
// to the renderer over IPC.
|
||||||
|
//
|
||||||
|
// Why the addon over Chromium's built-in 'loopback' source: the OS
|
||||||
|
// process-loopback API supports EXCLUDE_TARGET_PROCESS_TREE, which lets
|
||||||
|
// us capture every render session *except* our own PID tree. That keeps
|
||||||
|
// LiveKit's call playback out of the outgoing share so peers don't hear
|
||||||
|
// themselves echoed back. Chromium's 'loopback' has no such filter.
|
||||||
|
//
|
||||||
|
// Windows-only. The addon's start_capture call rejects on macOS/Linux
|
||||||
|
// with a clear error string and the renderer falls through to the
|
||||||
|
// existing Chromium getUserMedia path (lib/screenAudio.ts).
|
||||||
|
|
||||||
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
type AudioLoopbackChunk,
|
||||||
|
type AudioLoopbackStartResult,
|
||||||
|
} from '../ipc-types';
|
||||||
|
|
||||||
|
interface NativeAudioLoopback {
|
||||||
|
startCapture: (callback: (samples: Float32Array) => void) => number;
|
||||||
|
/** INCLUDE_TARGET_PROCESS_TREE variant — capture only `pid`'s tree.
|
||||||
|
* Used for window-shares so we get just the picked app's audio. */
|
||||||
|
startCaptureForPid?: (
|
||||||
|
pid: number,
|
||||||
|
callback: (samples: Float32Array) => void,
|
||||||
|
) => number;
|
||||||
|
/** Look up the owning process id of a top-level window handle. */
|
||||||
|
resolveWindowPid?: (hwnd: number) => number;
|
||||||
|
stopCapture: (captureId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// __dirname in an ESM main bundle resolves to out/main after
|
||||||
|
// electron-vite builds. We need a CJS-style require to load the native
|
||||||
|
// addon — `import` would trigger ESM resolution which doesn't handle
|
||||||
|
// .node files cleanly across electron-vite's transform.
|
||||||
|
const __filenameSafe =
|
||||||
|
typeof __filename === 'string' ? __filename : fileURLToPath(import.meta.url);
|
||||||
|
const __dirnameSafe = path.dirname(__filenameSafe);
|
||||||
|
const requireCjs = createRequire(__filenameSafe);
|
||||||
|
|
||||||
|
let cachedAddon: NativeAudioLoopback | null | undefined;
|
||||||
|
|
||||||
|
function resolveAddon(): NativeAudioLoopback | null {
|
||||||
|
if (cachedAddon !== undefined) return cachedAddon;
|
||||||
|
|
||||||
|
// Production: electron-builder copies the .node into
|
||||||
|
// resources/native/audio-loopback.node (see extraResources in
|
||||||
|
// package.json).
|
||||||
|
// Dev: napi build emits the binary alongside the addon's package.json
|
||||||
|
// at apps/desktop/native/audio-loopback/audio-loopback.<triple>.node,
|
||||||
|
// and writes an index.js shim that auto-selects the right triple.
|
||||||
|
// Loading the shim works in both layouts when the binary sits
|
||||||
|
// adjacent to it; for the packaged single-file layout we require the
|
||||||
|
// .node directly.
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (app.isPackaged && process.resourcesPath) {
|
||||||
|
candidates.push(path.join(process.resourcesPath, 'native', 'audio-loopback.node'));
|
||||||
|
}
|
||||||
|
// Dev workspace layout — main bundle lives at out/main/main.js,
|
||||||
|
// addon lives at native/audio-loopback/. Walk up two levels from the
|
||||||
|
// bundle dir (out/main → out → apps/desktop) and into native/.
|
||||||
|
candidates.push(
|
||||||
|
path.join(__dirnameSafe, '..', '..', 'native', 'audio-loopback', 'index.js'),
|
||||||
|
// Direct .node fallback in case the JS shim is missing (e.g. user
|
||||||
|
// ran `cargo build` manually instead of `napi build`).
|
||||||
|
path.join(
|
||||||
|
__dirnameSafe,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'native',
|
||||||
|
'audio-loopback',
|
||||||
|
`audio-loopback.${process.platform}-${process.arch}-msvc.node`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const mod = requireCjs(candidate) as NativeAudioLoopback;
|
||||||
|
if (
|
||||||
|
typeof mod.startCapture === 'function' &&
|
||||||
|
typeof mod.stopCapture === 'function'
|
||||||
|
) {
|
||||||
|
cachedAddon = mod;
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* try next candidate */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAddon = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track active capture ids per BrowserWindow so we can tear them down
|
||||||
|
* if the renderer is destroyed mid-capture (renderer crash, window
|
||||||
|
* close during share). Without this the WASAPI thread would leak. */
|
||||||
|
const activeCaptures = new Map<number, Set<number>>();
|
||||||
|
|
||||||
|
function registerWindowCleanup(win: BrowserWindow, addon: NativeAudioLoopback): void {
|
||||||
|
const wcId = win.webContents.id;
|
||||||
|
if (activeCaptures.has(wcId)) return;
|
||||||
|
activeCaptures.set(wcId, new Set());
|
||||||
|
const cleanup = (): void => {
|
||||||
|
const ids = activeCaptures.get(wcId);
|
||||||
|
if (!ids) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
addon.stopCapture(id);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeCaptures.delete(wcId);
|
||||||
|
};
|
||||||
|
win.webContents.once('destroyed', cleanup);
|
||||||
|
win.once('closed', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.AUDIO_LOOPBACK_START,
|
||||||
|
async (event): Promise<AudioLoopbackStartResult> => {
|
||||||
|
const addon = resolveAddon();
|
||||||
|
if (!addon) {
|
||||||
|
throw new Error('audio-loopback native addon unavailable on this platform');
|
||||||
|
}
|
||||||
|
const wc = event.sender;
|
||||||
|
// captureId is assigned synchronously by addon.startCapture below,
|
||||||
|
// but the chunk callback needs to reference it — we forward-declare
|
||||||
|
// via a closure-shared holder. The first chunk can only fire after
|
||||||
|
// startCapture returns (the worker thread spawn happens inside it).
|
||||||
|
let assignedId = 0;
|
||||||
|
const cb = makeChunkCallback(wc, () => assignedId);
|
||||||
|
assignedId = addon.startCapture(cb);
|
||||||
|
registerWindowCleanup(mainWindow, addon);
|
||||||
|
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||||
|
if (set) set.add(assignedId);
|
||||||
|
return { captureId: assignedId };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW,
|
||||||
|
async (
|
||||||
|
event,
|
||||||
|
args: { hwnd: number },
|
||||||
|
): Promise<AudioLoopbackStartResult> => {
|
||||||
|
const addon = resolveAddon();
|
||||||
|
if (!addon) {
|
||||||
|
throw new Error('audio-loopback native addon unavailable on this platform');
|
||||||
|
}
|
||||||
|
if (!addon.startCaptureForPid || !addon.resolveWindowPid) {
|
||||||
|
throw new Error(
|
||||||
|
'audio-loopback native addon is too old: missing startCaptureForPid / resolveWindowPid (rebuild with `pnpm build:native`)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!args || typeof args.hwnd !== 'number' || !Number.isFinite(args.hwnd)) {
|
||||||
|
throw new Error('AUDIO_LOOPBACK_START_FOR_WINDOW: hwnd must be a finite number');
|
||||||
|
}
|
||||||
|
const pid = addon.resolveWindowPid(args.hwnd);
|
||||||
|
const wc = event.sender;
|
||||||
|
let assignedId = 0;
|
||||||
|
const cb = makeChunkCallback(wc, () => assignedId);
|
||||||
|
assignedId = addon.startCaptureForPid(pid, cb);
|
||||||
|
registerWindowCleanup(mainWindow, addon);
|
||||||
|
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||||
|
if (set) set.add(assignedId);
|
||||||
|
return { captureId: assignedId };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.AUDIO_LOOPBACK_STOP,
|
||||||
|
async (_event, captureId: number): Promise<void> => {
|
||||||
|
const addon = resolveAddon();
|
||||||
|
if (!addon) return;
|
||||||
|
try {
|
||||||
|
addon.stopCapture(captureId);
|
||||||
|
} finally {
|
||||||
|
const set = activeCaptures.get(mainWindow.webContents.id);
|
||||||
|
if (set) set.delete(captureId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared chunk-forwarding callback. Both start variants produce the
|
||||||
|
* same wire format (interleaved f32 stereo @ 48kHz) so the renderer
|
||||||
|
* doesn't need to know which start path was used — the captureId
|
||||||
|
* routes the chunks. */
|
||||||
|
function makeChunkCallback(
|
||||||
|
wc: Electron.WebContents,
|
||||||
|
getCaptureId: () => number,
|
||||||
|
): (samples: Float32Array) => void {
|
||||||
|
return (samples: Float32Array): void => {
|
||||||
|
// The addon delivers each chunk on its WASAPI capture thread —
|
||||||
|
// marshal to the renderer's webContents from the main loop. If the
|
||||||
|
// webContents has been destroyed (window closed during a share)
|
||||||
|
// silently drop; the cleanup hook will stop the capture.
|
||||||
|
if (wc.isDestroyed()) return;
|
||||||
|
const captureId = getCaptureId();
|
||||||
|
const payload: AudioLoopbackChunk = { captureId, samples };
|
||||||
|
wc.send(CHANNELS.AUDIO_LOOPBACK_CHUNK, payload);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Autostart adapter — replaces Tauri's `tauri-plugin-autostart`. Uses
|
||||||
|
// Electron's built-in `app.setLoginItemSettings()` / `app.getLoginItemSettings()`,
|
||||||
|
// which manages the OS login-items mechanism on Windows (HKCU registry
|
||||||
|
// Run key), macOS (LaunchAgent), and Linux (.desktop entry).
|
||||||
|
|
||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.AUTOSTART_IS_ENABLED, () => {
|
||||||
|
try {
|
||||||
|
const settings = app.getLoginItemSettings();
|
||||||
|
return settings.openAtLogin;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('autostart get failed', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.AUTOSTART_SET, (_evt, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({ openAtLogin: !!enabled });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('autostart set failed', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Scoped filesystem. Every renderer-supplied path is resolved under
|
||||||
|
// `app.getPath('userData')`. Post-normalisation we re-check the resolved
|
||||||
|
// absolute path is still contained in the root; anything that breaks out
|
||||||
|
// (via .., symlink, absolute path) is rejected. Binary payloads are
|
||||||
|
// base64 on the wire because JSON IPC can't carry raw bytes cleanly.
|
||||||
|
|
||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { CHANNELS, type FsPath, type FsRenameArgs, type FsWriteArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
function rootDir(): string {
|
||||||
|
return app.getPath('userData');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScoped(rel: FsPath): string {
|
||||||
|
const root = rootDir();
|
||||||
|
if (path.isAbsolute(rel)) {
|
||||||
|
throw new Error('fs-scoped: absolute path rejected');
|
||||||
|
}
|
||||||
|
const normalised = path.normalize(rel);
|
||||||
|
if (normalised.split(/[\\/]/).includes('..')) {
|
||||||
|
throw new Error('fs-scoped: path traversal rejected');
|
||||||
|
}
|
||||||
|
const abs = path.resolve(root, normalised);
|
||||||
|
const withSep = root.endsWith(path.sep) ? root : root + path.sep;
|
||||||
|
if (abs !== root && !abs.startsWith(withSep)) {
|
||||||
|
throw new Error('fs-scoped: escaped scope');
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.FS_APP_LOCAL_DATA_DIR, async (): Promise<string> => rootDir());
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_READ, async (_evt, rel: FsPath): Promise<string | null> => {
|
||||||
|
const abs = resolveScoped(rel);
|
||||||
|
try {
|
||||||
|
const buf = await fs.readFile(abs);
|
||||||
|
return buf.toString('base64');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_WRITE, async (_evt, args: FsWriteArgs): Promise<void> => {
|
||||||
|
const abs = resolveScoped(args.path);
|
||||||
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||||
|
const buf = Buffer.from(args.dataBase64, 'base64');
|
||||||
|
await fs.writeFile(abs, buf);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_EXISTS, async (_evt, rel: FsPath): Promise<boolean> => {
|
||||||
|
const abs = resolveScoped(rel);
|
||||||
|
try {
|
||||||
|
await fs.access(abs);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_MKDIR, async (_evt, rel: FsPath): Promise<void> => {
|
||||||
|
const abs = resolveScoped(rel);
|
||||||
|
await fs.mkdir(abs, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_RENAME, async (_evt, args: FsRenameArgs): Promise<void> => {
|
||||||
|
const from = resolveScoped(args.from);
|
||||||
|
const to = resolveScoped(args.to);
|
||||||
|
await fs.mkdir(path.dirname(to), { recursive: true });
|
||||||
|
await fs.rename(from, to);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.FS_REMOVE, async (_evt, rel: FsPath): Promise<void> => {
|
||||||
|
const abs = resolveScoped(rel);
|
||||||
|
await fs.rm(abs, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// OS notifications. Electron's Notification API doesn't have a separate
|
||||||
|
// permission prompt on desktop — permission is implicit and always
|
||||||
|
// granted — so `notify:permission` is a compatibility shim that keeps
|
||||||
|
// the renderer's existing plugin-notification call-sites working
|
||||||
|
// without branching.
|
||||||
|
|
||||||
|
import { ipcMain, Notification } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type NotifyArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.NOTIFY_SHOW, async (_evt, args: NotifyArgs): Promise<void> => {
|
||||||
|
if (!Notification.isSupported()) return;
|
||||||
|
const n = new Notification({
|
||||||
|
title: args.title,
|
||||||
|
body: args.body,
|
||||||
|
silent: args.silent ?? true,
|
||||||
|
});
|
||||||
|
n.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.NOTIFY_PERMISSION,
|
||||||
|
async (): Promise<'granted' | 'denied' | 'default'> => 'granted',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// System-audio loopback — renderer-driven. Chromium's
|
||||||
|
// `chromeMediaSource: 'desktop'` constraint on getUserMedia accepts a
|
||||||
|
// source id and returns a MediaStream that contains the OS mixer output.
|
||||||
|
// Main's only job is to resolve which source id the renderer should feed
|
||||||
|
// to getUserMedia (typically the primary screen). See the display-media
|
||||||
|
// handler in main.ts which grants the request automatically.
|
||||||
|
|
||||||
|
import { desktopCapturer, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
export interface ResolveLoopbackSourceResult {
|
||||||
|
sourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE,
|
||||||
|
async (): Promise<ResolveLoopbackSourceResult | null> => {
|
||||||
|
// Enumerate only screens; windows don't expose audio loopback on
|
||||||
|
// Windows and there's no meaningful "system audio" tied to a
|
||||||
|
// single window anyway. Primary screen is the first entry — the
|
||||||
|
// id is stable across calls as long as the display config doesn't
|
||||||
|
// change mid-session.
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['screen'],
|
||||||
|
fetchWindowIcons: false,
|
||||||
|
});
|
||||||
|
const first = sources[0];
|
||||||
|
if (!first) return null;
|
||||||
|
return { sourceId: first.id };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The legacy start/stop channels are left unregistered on purpose —
|
||||||
|
// their constants still exist in ipc-types.ts for backwards compatible
|
||||||
|
// import paths, but there is no corresponding handler.
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Screen / window source enumeration + on-demand high-res thumbnail fetch.
|
||||||
|
// The picker in the renderer calls `SCREEN_GET_SOURCES` once to populate
|
||||||
|
// the grid (thumbnails come back inline as data URLs from desktopCapturer,
|
||||||
|
// so no second round-trip is needed for the initial paint). When the user
|
||||||
|
// hovers a tile we can optionally refresh the thumb at a higher resolution
|
||||||
|
// via `SCREEN_GET_THUMBNAIL` — same API but a single id and 640x360 size.
|
||||||
|
|
||||||
|
import { desktopCapturer, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type ScreenSource } from '../ipc-types';
|
||||||
|
|
||||||
|
// Renderer-driven pending source: the picker modal sets this BEFORE calling
|
||||||
|
// getDisplayMedia so our display-media handler can route the chosen source
|
||||||
|
// to LiveKit. Stored module-level (single capture in flight at a time —
|
||||||
|
// the renderer enforces this since only one picker can be open). Cleared
|
||||||
|
// on consume or on explicit null-set (cancel/error path).
|
||||||
|
let pendingShareSourceId: string | null = null;
|
||||||
|
|
||||||
|
/** Read-and-clear: returns the pending id and resets it to null in one
|
||||||
|
* step so the main-process display-media handler can't accidentally apply
|
||||||
|
* the same id twice (e.g. if a stray getDisplayMedia call fires while
|
||||||
|
* the picker is closed). */
|
||||||
|
export function consumePendingShareSourceId(): string | null {
|
||||||
|
const id = pendingShareSourceId;
|
||||||
|
pendingShareSourceId = null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-destructive read for callers that just want to know if a pending
|
||||||
|
* selection exists. */
|
||||||
|
export function getPendingShareSourceId(): string | null {
|
||||||
|
return pendingShareSourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDisplayId(raw: string): number | null {
|
||||||
|
// desktopCapturer ids for screens look like "screen:<display-id>:0". We
|
||||||
|
// index monitors 0-based in the UI so reduce the opaque id to a small
|
||||||
|
// integer per primary-display order. For windows there is no display
|
||||||
|
// association, return null.
|
||||||
|
if (!raw.startsWith('screen:')) return null;
|
||||||
|
const parts = raw.split(':');
|
||||||
|
const n = Number(parts[1]);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerate(thumbWidth: number, thumbHeight: number): Promise<ScreenSource[]> {
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['screen', 'window'],
|
||||||
|
thumbnailSize: { width: thumbWidth, height: thumbHeight },
|
||||||
|
fetchWindowIcons: true,
|
||||||
|
});
|
||||||
|
return sources.map((src): ScreenSource => {
|
||||||
|
const kind: 'screen' | 'window' = src.id.startsWith('screen:') ? 'screen' : 'window';
|
||||||
|
const thumbnailDataUrl =
|
||||||
|
src.thumbnail && !src.thumbnail.isEmpty() ? src.thumbnail.toDataURL() : null;
|
||||||
|
const iconDataUrl =
|
||||||
|
src.appIcon && !src.appIcon.isEmpty() ? src.appIcon.toDataURL() : null;
|
||||||
|
return {
|
||||||
|
id: src.id,
|
||||||
|
name: src.name,
|
||||||
|
kind,
|
||||||
|
displayId: kind === 'screen' ? parseDisplayId(src.id) : null,
|
||||||
|
thumbnailDataUrl,
|
||||||
|
iconDataUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.SCREEN_GET_SOURCES, async (): Promise<ScreenSource[]> => {
|
||||||
|
return enumerate(320, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SCREEN_GET_THUMBNAIL,
|
||||||
|
async (_evt, sourceId: string): Promise<string | null> => {
|
||||||
|
// Re-enumerate — desktopCapturer has no "fetch one by id" API. Done
|
||||||
|
// at 640x360 so the detail view looks crisp without paying the full
|
||||||
|
// enumeration cost more than once per hover-debounce.
|
||||||
|
const list = await enumerate(640, 360);
|
||||||
|
const found = list.find((s) => s.id === sourceId);
|
||||||
|
return found?.thumbnailDataUrl ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SCREEN_SET_PENDING_SOURCE,
|
||||||
|
(_evt, sourceId: string | null): void => {
|
||||||
|
// Renderer signals the chosen source id (or null to clear on cancel/
|
||||||
|
// error). Stored until the next getDisplayMedia request comes in via
|
||||||
|
// the display-media handler, which calls consumePendingShareSourceId
|
||||||
|
// to read-and-clear it.
|
||||||
|
pendingShareSourceId = sourceId;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Per-user secure key/value store. Replaces Tauri's plugin-stronghold +
|
||||||
|
// custom file vault with Electron's safeStorage (DPAPI on Windows,
|
||||||
|
// Keychain on macOS, libsecret on Linux). Encryption is at the file
|
||||||
|
// level — the whole entries map is a single encrypted blob — so there's
|
||||||
|
// no per-set ciphertext rotation to track.
|
||||||
|
//
|
||||||
|
// Pre-encrypt JSON: {version:1, entries: { <key>: <utf8-string-value> }}.
|
||||||
|
// When safeStorage is unavailable we degrade to a `.plaintext` JSON file
|
||||||
|
// and flag `encrypted: false` back to the renderer so the renderer can
|
||||||
|
// warn the user and avoid long-lived secrets.
|
||||||
|
|
||||||
|
import { app, ipcMain, safeStorage } from 'electron';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
type SecureStoreHandle,
|
||||||
|
type SecureStoreOpenArgs,
|
||||||
|
} from '../ipc-types';
|
||||||
|
|
||||||
|
interface HandleState {
|
||||||
|
filePath: string;
|
||||||
|
encrypted: boolean;
|
||||||
|
entries: Map<string, string>;
|
||||||
|
saveTimer: NodeJS.Timeout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = new Map<string, HandleState>();
|
||||||
|
|
||||||
|
function hashUserId(userId: string): string {
|
||||||
|
return createHash('sha256').update(userId).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePathFor(userId: string, encrypted: boolean): string {
|
||||||
|
const suffix = hashUserId(userId);
|
||||||
|
const ext = encrypted ? 'bin' : 'plaintext';
|
||||||
|
return path.join(app.getPath('userData'), `chatapp-secure-${suffix}.${ext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(filePath: string, encrypted: boolean): Promise<Map<string, string>> {
|
||||||
|
// Read step. Distinguish "no file yet" (genuinely new user — empty Map
|
||||||
|
// is correct) from "file exists but unreadable" (corruption / DPAPI
|
||||||
|
// breakage — we MUST NOT let the next write overwrite those bytes,
|
||||||
|
// because the original ciphertext is the only path back to the user's
|
||||||
|
// device keys if a future build can fix the read path).
|
||||||
|
let rawBuf: Buffer | null = null;
|
||||||
|
let rawStr: string | null = null;
|
||||||
|
try {
|
||||||
|
if (encrypted) {
|
||||||
|
rawBuf = await fs.readFile(filePath);
|
||||||
|
} else {
|
||||||
|
rawStr = await fs.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||||
|
if (code === 'ENOENT') return new Map();
|
||||||
|
console.warn('[secure-store] read failed (non-ENOENT)', filePath, err);
|
||||||
|
// For non-ENOENT read failures (EACCES, EBUSY, …) don't quarantine —
|
||||||
|
// the file might be transiently locked. Empty map + future writes
|
||||||
|
// will attempt to overwrite, matching the pre-0.17.1 behaviour for
|
||||||
|
// these rarer cases.
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse / decrypt step.
|
||||||
|
try {
|
||||||
|
if (encrypted && rawBuf) {
|
||||||
|
const json = safeStorage.decryptString(rawBuf);
|
||||||
|
const parsed = JSON.parse(json) as { version?: number; entries?: Record<string, string> };
|
||||||
|
return new Map(Object.entries(parsed.entries ?? {}));
|
||||||
|
}
|
||||||
|
if (rawStr) {
|
||||||
|
const parsed = JSON.parse(rawStr) as { version?: number; entries?: Record<string, string> };
|
||||||
|
return new Map(Object.entries(parsed.entries ?? {}));
|
||||||
|
}
|
||||||
|
return new Map();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// CRITICAL: file existed but we couldn't decrypt or parse it. In the
|
||||||
|
// pre-0.17.1 build we silently started fresh — the next set() then
|
||||||
|
// scheduledSave() over-wrote the original ciphertext, destroying the
|
||||||
|
// user's device keys forever. Now we rename the original to
|
||||||
|
// `<file>.broken-<iso-ts>` BEFORE returning the empty map so the next
|
||||||
|
// write goes to a new file and the original bytes survive for
|
||||||
|
// forensics or a future decrypt-recovery path.
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const brokenPath = `${filePath}.broken-${ts}`;
|
||||||
|
try {
|
||||||
|
await fs.rename(filePath, brokenPath);
|
||||||
|
console.error(
|
||||||
|
`[secure-store] DECRYPT/PARSE FAILED for ${filePath} — preserved original at ${brokenPath}. Original error:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
} catch (renameErr: unknown) {
|
||||||
|
// Even rename failed — fall back to the old behaviour (silent empty
|
||||||
|
// map) but log loudly so it's visible in the main-process output.
|
||||||
|
console.error(
|
||||||
|
'[secure-store] rename of broken file failed; original may be overwritten on next save',
|
||||||
|
renameErr,
|
||||||
|
'original decrypt error:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStateNow(state: HandleState): Promise<void> {
|
||||||
|
const obj: Record<string, string> = {};
|
||||||
|
for (const [k, v] of state.entries) obj[k] = v;
|
||||||
|
const serialised = JSON.stringify({ version: 1, entries: obj });
|
||||||
|
await fs.mkdir(path.dirname(state.filePath), { recursive: true });
|
||||||
|
if (state.encrypted) {
|
||||||
|
const buf = safeStorage.encryptString(serialised);
|
||||||
|
await fs.writeFile(state.filePath, buf);
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(state.filePath, serialised, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave(state: HandleState): void {
|
||||||
|
if (state.saveTimer) clearTimeout(state.saveTimer);
|
||||||
|
state.saveTimer = setTimeout(() => {
|
||||||
|
state.saveTimer = null;
|
||||||
|
void writeStateNow(state).catch((err: unknown) => {
|
||||||
|
console.warn('[secure-store] persist failed', err);
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireState(handle: string): HandleState {
|
||||||
|
const s = handles.get(handle);
|
||||||
|
if (!s) throw new Error('secure-store: unknown handle');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SECURE_STORE_OPEN,
|
||||||
|
async (_evt, args: SecureStoreOpenArgs): Promise<SecureStoreHandle> => {
|
||||||
|
const encrypted = safeStorage.isEncryptionAvailable();
|
||||||
|
const filePath = filePathFor(args.userId, encrypted);
|
||||||
|
const entries = await loadState(filePath, encrypted);
|
||||||
|
const handle = hashUserId(args.userId);
|
||||||
|
handles.set(handle, { filePath, encrypted, entries, saveTimer: null });
|
||||||
|
return { handle, encrypted };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SECURE_STORE_GET,
|
||||||
|
async (_evt, handle: string, key: string): Promise<string | null> => {
|
||||||
|
const state = requireState(handle);
|
||||||
|
return state.entries.get(key) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SECURE_STORE_SET,
|
||||||
|
async (_evt, handle: string, key: string, value: string): Promise<void> => {
|
||||||
|
const state = requireState(handle);
|
||||||
|
state.entries.set(key, value);
|
||||||
|
scheduleSave(state);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SECURE_STORE_REMOVE,
|
||||||
|
async (_evt, handle: string, key: string): Promise<void> => {
|
||||||
|
const state = requireState(handle);
|
||||||
|
state.entries.delete(key);
|
||||||
|
scheduleSave(state);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.SECURE_STORE_CLOSE, async (_evt, handle: string): Promise<void> => {
|
||||||
|
const state = handles.get(handle);
|
||||||
|
if (!state) return;
|
||||||
|
if (state.saveTimer) {
|
||||||
|
clearTimeout(state.saveTimer);
|
||||||
|
state.saveTimer = null;
|
||||||
|
try {
|
||||||
|
await writeStateNow(state);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[secure-store] close-flush failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handles.delete(handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Global shortcuts — wraps Electron's globalShortcut, tracks registrations
|
||||||
|
// by an opaque renderer-supplied id so the same accelerator can be
|
||||||
|
// re-bound without the caller juggling state.
|
||||||
|
//
|
||||||
|
// PTT semantics are simulated: Electron's globalShortcut API only delivers
|
||||||
|
// a "pressed" callback — it has no keyup / release event. We fire
|
||||||
|
// SHORTCUT_EVT_FIRED on press, then after a 200ms timer fire
|
||||||
|
// SHORTCUT_EVT_RELEASED. Known limitation; TODO: revisit with
|
||||||
|
// `uiohook-napi` or `node-global-key-listener` if the press-and-release
|
||||||
|
// UX is too loose.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, globalShortcut, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
type ShortcutEvent,
|
||||||
|
type ShortcutKind,
|
||||||
|
type ShortcutRegisterArgs,
|
||||||
|
} from '../ipc-types';
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
accelerator: string;
|
||||||
|
kind: ShortcutKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map<string, Entry>();
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
const send = (channel: string, payload: ShortcutEvent): void => {
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.webContents.send(channel, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SHORTCUT_REGISTER,
|
||||||
|
async (_evt, args: ShortcutRegisterArgs): Promise<boolean> => {
|
||||||
|
const { id, accelerator, kind } = args;
|
||||||
|
const prev = registry.get(id);
|
||||||
|
if (prev) {
|
||||||
|
try {
|
||||||
|
globalShortcut.unregister(prev.accelerator);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
registry.delete(id);
|
||||||
|
}
|
||||||
|
if (globalShortcut.isRegistered(accelerator)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ok = globalShortcut.register(accelerator, () => {
|
||||||
|
const ts = Date.now();
|
||||||
|
send(CHANNELS.SHORTCUT_EVT_FIRED, { id, ts });
|
||||||
|
if (kind === 'ptt') {
|
||||||
|
setTimeout(() => {
|
||||||
|
send(CHANNELS.SHORTCUT_EVT_RELEASED, { id, ts: Date.now() });
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
registry.set(id, { accelerator, kind });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.SHORTCUT_UNREGISTER, async (_evt, id: string): Promise<void> => {
|
||||||
|
const entry = registry.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
try {
|
||||||
|
globalShortcut.unregister(entry.accelerator);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
registry.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.SHORTCUT_IS_REGISTERED, async (_evt, id: string): Promise<boolean> => {
|
||||||
|
const entry = registry.get(id);
|
||||||
|
if (!entry) return false;
|
||||||
|
return globalShortcut.isRegistered(entry.accelerator);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
registry.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// SQLite via better-sqlite3. One Database instance per renderer-tracked
|
||||||
|
// handle; handles are keyed by the normalised db name (Tauri's plugin-sql
|
||||||
|
// uses `sqlite:<name>` — we strip the prefix). Sync API is fine here
|
||||||
|
// because the main process has its own event loop; better-sqlite3's
|
||||||
|
// prepare/run/all are blocking but fast for typical chat-cache queries
|
||||||
|
// (<1ms per op for the current workload).
|
||||||
|
//
|
||||||
|
// Binding param style: Tauri's plugin-sql used $1, $2... positionals with
|
||||||
|
// bindings as an array. SQLite parses `$NAME` as a NAMED parameter
|
||||||
|
// (NAME = `1`, `2`, …), not as positional, so better-sqlite3 wants the
|
||||||
|
// bindings as `{ '1': v1, '2': v2 }` not `[v1, v2]`. We accept the old
|
||||||
|
// array-shape from callers and convert to the named-object on the way in.
|
||||||
|
|
||||||
|
import { app, ipcMain } from 'electron';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
type SqlExecuteArgs,
|
||||||
|
type SqlExecuteResult,
|
||||||
|
type SqlLoadArgs,
|
||||||
|
type SqlSelectArgs,
|
||||||
|
type SqlSelectResult,
|
||||||
|
} from '../ipc-types';
|
||||||
|
|
||||||
|
interface Handle {
|
||||||
|
db: Database.Database;
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = new Map<string, Handle>();
|
||||||
|
|
||||||
|
function stripPrefix(name: string): string {
|
||||||
|
return name.startsWith('sqlite:') ? name.slice('sqlite:'.length) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHandle(h: string): Handle {
|
||||||
|
const entry = handles.get(h);
|
||||||
|
if (!entry) throw new Error(`sql: unknown handle ${h}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a positional bindings array `[v1, v2]` to the named-params object
|
||||||
|
// `{ '1': v1, '2': v2 }` that better-sqlite3 needs when the SQL uses
|
||||||
|
// `$1`/`$2` named placeholders. Returns the original array (spread later)
|
||||||
|
// when it's empty.
|
||||||
|
function bindParams(bindings: unknown[] | undefined): Record<string, unknown> | [] {
|
||||||
|
const arr = bindings ?? [];
|
||||||
|
if (arr.length === 0) return [];
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (let i = 0; i < arr.length; i++) {
|
||||||
|
obj[String(i + 1)] = arr[i];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(): void {
|
||||||
|
ipcMain.handle(CHANNELS.SQL_LOAD, async (_evt, args: SqlLoadArgs): Promise<string> => {
|
||||||
|
const rawName = stripPrefix(args.name);
|
||||||
|
const fileName = rawName.endsWith('.db') ? rawName : rawName + '.db';
|
||||||
|
const filePath = path.join(app.getPath('userData'), fileName);
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
const existing = handles.get(rawName);
|
||||||
|
if (existing) return rawName;
|
||||||
|
const db = new Database(filePath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
handles.set(rawName, { db, filePath });
|
||||||
|
return rawName;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SQL_EXECUTE,
|
||||||
|
async (_evt, args: SqlExecuteArgs): Promise<SqlExecuteResult> => {
|
||||||
|
const entry = requireHandle(args.handle);
|
||||||
|
const stmt = entry.db.prepare(args.query);
|
||||||
|
const params = bindParams(args.bindings);
|
||||||
|
const info = Array.isArray(params) ? stmt.run() : stmt.run(params);
|
||||||
|
return {
|
||||||
|
rowsAffected: info.changes,
|
||||||
|
lastInsertId:
|
||||||
|
typeof info.lastInsertRowid === 'bigint'
|
||||||
|
? Number(info.lastInsertRowid)
|
||||||
|
: (info.lastInsertRowid ?? null),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.SQL_SELECT,
|
||||||
|
async (_evt, args: SqlSelectArgs): Promise<SqlSelectResult> => {
|
||||||
|
const entry = requireHandle(args.handle);
|
||||||
|
const stmt = entry.db.prepare(args.query);
|
||||||
|
const params = bindParams(args.bindings);
|
||||||
|
const rows = (Array.isArray(params) ? stmt.all() : stmt.all(params)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>[];
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.SQL_CLOSE, async (_evt, handle: string): Promise<void> => {
|
||||||
|
const entry = handles.get(handle);
|
||||||
|
if (!entry) return;
|
||||||
|
try {
|
||||||
|
entry.db.close();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[sql] close failed', err);
|
||||||
|
}
|
||||||
|
handles.delete(handle);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// System tray + Windows taskbar overlay badge. Renderer pushes the
|
||||||
|
// current aggregate unread count via CHANNELS.TRAY_UNREAD; we update the
|
||||||
|
// tooltip and (on Windows) set an overlay icon on the main window's
|
||||||
|
// taskbar button.
|
||||||
|
//
|
||||||
|
// Overlay image is a pre-rendered 16x16 red dot embedded as base64 so
|
||||||
|
// the module is self-contained — no runtime canvas dependency.
|
||||||
|
|
||||||
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
ipcMain,
|
||||||
|
Menu,
|
||||||
|
nativeImage,
|
||||||
|
type NativeImage,
|
||||||
|
Tray,
|
||||||
|
} from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
let trayRef: Tray | null = null;
|
||||||
|
let overlayImage: NativeImage | null = null;
|
||||||
|
|
||||||
|
function resolveIconPath(): string {
|
||||||
|
return path.join(process.resourcesPath || app.getAppPath(), 'icon.ico');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIconPathDev(): string {
|
||||||
|
return path.join(app.getAppPath(), 'resources', 'icon.ico');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16×16 grey square — last-ditch fallback when neither the packaged
|
||||||
|
// nor dev icon file resolves. Tray constructor on Windows throws when
|
||||||
|
// handed an empty NativeImage, which would tear down the whole
|
||||||
|
// registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the
|
||||||
|
// taskbar overlay badge wired but the renderer's invoke rejecting
|
||||||
|
// with "No handler registered". A non-empty placeholder keeps the
|
||||||
|
// constructor happy so the IPC handler always gets registered.
|
||||||
|
const FALLBACK_TRAY_PNG_BASE64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' +
|
||||||
|
'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC';
|
||||||
|
|
||||||
|
function loadTrayIcon(): NativeImage {
|
||||||
|
for (const p of [resolveIconPath(), resolveIconPathDev()]) {
|
||||||
|
try {
|
||||||
|
const img = nativeImage.createFromPath(p);
|
||||||
|
if (!img.isEmpty()) return img;
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlay(): NativeImage {
|
||||||
|
if (overlayImage) return overlayImage;
|
||||||
|
// 16x16 PNG, solid red circle. Inline base64 so no external file lookup.
|
||||||
|
const base64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPElEQVR42mNkYGD4z0AGYBxVSF2F//' +
|
||||||
|
'//Z2BgYGD4/58kBYz4FDAxMDAwMDIwMDD8//+foArGUYWjCoc1AABTgwUBf3lZtAAAAABJRU5ErkJggg==';
|
||||||
|
overlayImage = nativeImage.createFromBuffer(Buffer.from(base64, 'base64'));
|
||||||
|
return overlayImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
// Tray icon is best-effort: if neither the packaged resource nor the
|
||||||
|
// dev path resolves (e.g. icon.ico isn't shipped under resourcesPath
|
||||||
|
// in packaged builds — only the app icon goes into the .exe metadata),
|
||||||
|
// we still want the taskbar overlay badge to work. setOverlayIcon is
|
||||||
|
// a BrowserWindow method, so it functions even when the systray icon
|
||||||
|
// creation fails.
|
||||||
|
try {
|
||||||
|
const icon = loadTrayIcon();
|
||||||
|
if (!icon.isEmpty()) {
|
||||||
|
trayRef = new Tray(icon);
|
||||||
|
trayRef.setToolTip('Netralax');
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Open',
|
||||||
|
click: (): void => {
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: (): void => {
|
||||||
|
app.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
trayRef.setContextMenu(menu);
|
||||||
|
trayRef.on('click', (): void => {
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
if (mainWindow.isVisible()) mainWindow.focus();
|
||||||
|
else mainWindow.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Swallow: the overlay badge below is the user-visible bit. A missing
|
||||||
|
// systray icon is cosmetic and shouldn't take the unread handler with it.
|
||||||
|
console.warn('[tray] systray init failed, overlay badge still active', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.TRAY_UNREAD,
|
||||||
|
async (_evt, count: number, badgeDataUrl?: string | null): Promise<void> => {
|
||||||
|
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||||
|
if (trayRef && !trayRef.isDestroyed()) {
|
||||||
|
trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax');
|
||||||
|
}
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
if (process.platform !== 'win32') return;
|
||||||
|
if (n <= 0) {
|
||||||
|
mainWindow.setOverlayIcon(null, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Discord-style: prefer the renderer-painted badge (red circle with
|
||||||
|
// the actual unread number). Fall back to the static red dot only if
|
||||||
|
// the renderer didn't supply one or decoding failed — keeps the
|
||||||
|
// visual indicator alive even when the canvas pipeline is unavailable.
|
||||||
|
let overlay: NativeImage | null = null;
|
||||||
|
if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) {
|
||||||
|
const decoded = nativeImage.createFromDataURL(badgeDataUrl);
|
||||||
|
if (!decoded.isEmpty()) overlay = decoded;
|
||||||
|
}
|
||||||
|
mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
try {
|
||||||
|
trayRef?.destroy();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
trayRef = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Auto-updater. Wraps electron-updater; configuration (feed URL) lives in
|
||||||
|
// package.json's `build.publish`. In dev (`app.isPackaged === false`) we
|
||||||
|
// short-circuit everything — the feed server is production-only and
|
||||||
|
// hitting it every launch from a dev machine just adds noise.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
|
import electronUpdater, {
|
||||||
|
type ProgressInfo,
|
||||||
|
type UpdateInfo as BuilderUpdateInfo,
|
||||||
|
} from 'electron-updater';
|
||||||
|
|
||||||
|
// electron-updater is a CJS module; named ESM imports don't work. Pull
|
||||||
|
// autoUpdater off the default export instead.
|
||||||
|
const { autoUpdater } = electronUpdater;
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
type UpdateCheckResult,
|
||||||
|
type UpdateInfo,
|
||||||
|
type UpdateProgress,
|
||||||
|
} from '../ipc-types';
|
||||||
|
|
||||||
|
let cached: BuilderUpdateInfo | null = null;
|
||||||
|
|
||||||
|
function toPublicInfo(info: BuilderUpdateInfo | null): UpdateInfo | null {
|
||||||
|
if (!info) return null;
|
||||||
|
const notes = info.releaseNotes;
|
||||||
|
let releaseNotes: string | null = null;
|
||||||
|
if (typeof notes === 'string') releaseNotes = notes;
|
||||||
|
else if (Array.isArray(notes)) releaseNotes = notes.map((r) => r.note).join('\n\n');
|
||||||
|
return {
|
||||||
|
version: info.version ?? '',
|
||||||
|
releaseNotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
autoUpdater.autoDownload = false;
|
||||||
|
autoUpdater.autoInstallOnAppQuit = false;
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (p: ProgressInfo) => {
|
||||||
|
if (mainWindow.isDestroyed()) return;
|
||||||
|
const payload: UpdateProgress = {
|
||||||
|
percent: p.percent ?? 0,
|
||||||
|
transferred: p.transferred ?? 0,
|
||||||
|
total: p.total ?? 0,
|
||||||
|
};
|
||||||
|
mainWindow.webContents.send(CHANNELS.UPDATER_EVT_PROGRESS, payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.UPDATER_CHECK, async (): Promise<UpdateCheckResult> => {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return { available: false, info: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await autoUpdater.checkForUpdates();
|
||||||
|
if (!result || !result.updateInfo) {
|
||||||
|
cached = null;
|
||||||
|
return { available: false, info: null };
|
||||||
|
}
|
||||||
|
const current = app.getVersion();
|
||||||
|
const remote = result.updateInfo.version;
|
||||||
|
if (!remote || remote === current) {
|
||||||
|
cached = null;
|
||||||
|
return { available: false, info: null };
|
||||||
|
}
|
||||||
|
cached = result.updateInfo;
|
||||||
|
return { available: true, info: toPublicInfo(cached) };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (!/ENOTFOUND|ETIMEDOUT|ECONNRESET|404/i.test(msg)) {
|
||||||
|
console.warn('[updater] check failed', err);
|
||||||
|
}
|
||||||
|
return { available: false, info: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.UPDATER_DOWNLOAD_INSTALL, async (): Promise<void> => {
|
||||||
|
if (!app.isPackaged) return;
|
||||||
|
if (!cached) throw new Error('no pending update — call check first');
|
||||||
|
await autoUpdater.downloadUpdate();
|
||||||
|
autoUpdater.quitAndInstall();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Window content-protection adapter. Enables / disables OS-level
|
||||||
|
// screenshot and screen-recording blocking on the host BrowserWindow.
|
||||||
|
//
|
||||||
|
// Windows: WDA_MONITOR (SetWindowDisplayAffinity) — the window surface
|
||||||
|
// appears black in any screen capture tool (OBS, Snipping Tool,
|
||||||
|
// Win+PrtScr, etc.) while protection is enabled.
|
||||||
|
// macOS: NSWindowSharingNone — equivalent coverage for QuickTime,
|
||||||
|
// Cmd+Shift+3/4, and external recorders.
|
||||||
|
// Linux: No-op. Electron exposes the API on all platforms but the
|
||||||
|
// X11/Wayland compositors don't honour it in Electron 33.
|
||||||
|
//
|
||||||
|
// Called by the renderer during view-once image reveals so the image
|
||||||
|
// cannot be captured by an OS-level screenshot while it is on screen.
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS, type WindowSetContentProtectionArgs } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
ipcMain.handle(
|
||||||
|
CHANNELS.WINDOW_SET_CONTENT_PROTECTION,
|
||||||
|
(_evt, args: WindowSetContentProtectionArgs) => {
|
||||||
|
// Electron's setContentProtection covers Windows (WDA_MONITOR) and
|
||||||
|
// macOS (NSWindowSharingNone) in one call. No-op on Linux X11.
|
||||||
|
// Wrapped in try/catch because the window can already be destroyed
|
||||||
|
// by the time this fires during a teardown.
|
||||||
|
try {
|
||||||
|
const win = BrowserWindow.fromWebContents(_evt.sender) ?? mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.setContentProtection(args.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('setContentProtection failed', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Window fullscreen adapter — replaces Tauri's
|
||||||
|
// `getCurrentWindow().setFullscreen(...)` from `@tauri-apps/api/window`.
|
||||||
|
// The renderer asks main to flip the OS-level fullscreen flag on the host
|
||||||
|
// BrowserWindow so cinema mode covers the Windows taskbar / macOS menubar
|
||||||
|
// the way Tauri's appWindow.setFullscreen used to.
|
||||||
|
|
||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { CHANNELS } from '../ipc-types';
|
||||||
|
|
||||||
|
export function register(mainWindow: BrowserWindow): void {
|
||||||
|
// Per-window maximize-before-fullscreen memo. We have to drop the
|
||||||
|
// maximized flag on Windows before setFullScreen so DWM recomposes
|
||||||
|
// cleanly (taskbar quirk), but Electron doesn't remember that the
|
||||||
|
// window WAS maximized — exiting fullscreen would leave it as a small
|
||||||
|
// floating window. Track it ourselves keyed by window-id so a future
|
||||||
|
// multi-window setup doesn't cross-pollute state.
|
||||||
|
const wasMaximized = new Map<number, boolean>();
|
||||||
|
|
||||||
|
ipcMain.handle(CHANNELS.WINDOW_SET_FULLSCREEN, (evt, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
// Prefer the BrowserWindow that issued the IPC so multi-window setups
|
||||||
|
// affect the right host; fall back to the main window we were
|
||||||
|
// registered against (matches autostart.ts's app-singleton shape).
|
||||||
|
const win = BrowserWindow.fromWebContents(evt.sender) ?? mainWindow;
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
const id = win.id;
|
||||||
|
if (enabled) {
|
||||||
|
// Windows DWM quirk: maximized → fullscreen sometimes leaves the
|
||||||
|
// taskbar drawn on top of the window because DWM keeps the
|
||||||
|
// maximized work-area constraints. Drop the maximize flag first
|
||||||
|
// so setFullScreen covers the whole monitor cleanly. Remember the
|
||||||
|
// pre-fullscreen state so the exit path can restore it.
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const was = win.isMaximized();
|
||||||
|
wasMaximized.set(id, was);
|
||||||
|
if (was) win.unmaximize();
|
||||||
|
}
|
||||||
|
win.setFullScreen(true);
|
||||||
|
} else {
|
||||||
|
win.setFullScreen(false);
|
||||||
|
// Restore maximize if we dropped it on entry. setFullScreen(false)
|
||||||
|
// emits 'leave-full-screen' asynchronously; maximize() needs to
|
||||||
|
// wait until the window is back in normal mode or it silently
|
||||||
|
// no-ops. The event fires same-tick in Electron 33, but we listen
|
||||||
|
// for it once just to be safe across versions.
|
||||||
|
if (process.platform === 'win32' && wasMaximized.get(id)) {
|
||||||
|
wasMaximized.delete(id);
|
||||||
|
const restore = (): void => {
|
||||||
|
if (!win.isDestroyed()) win.maximize();
|
||||||
|
};
|
||||||
|
if (win.isFullScreen()) {
|
||||||
|
win.once('leave-full-screen', restore);
|
||||||
|
} else {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('window setFullscreen failed', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
// Renderer-visible typing for the preload bridge. Referenced via tsconfig
|
||||||
|
// `include` so TS sees `window.electronAPI` without needing to import
|
||||||
|
// from `electron` or drag Node types into the renderer build. The
|
||||||
|
// ElectronAPI type mirrors the `api` object exported from preload.ts —
|
||||||
|
// keep them in sync when adding new methods.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AudioLoopbackChunk,
|
||||||
|
AudioLoopbackStartResult,
|
||||||
|
FsPath,
|
||||||
|
FsRenameArgs,
|
||||||
|
FsWriteArgs,
|
||||||
|
NotifyArgs,
|
||||||
|
ScreenSource,
|
||||||
|
SecureStoreHandle,
|
||||||
|
SecureStoreOpenArgs,
|
||||||
|
ShortcutEvent,
|
||||||
|
ShortcutRegisterArgs,
|
||||||
|
SqlExecuteResult,
|
||||||
|
SqlSelectResult,
|
||||||
|
UpdateCheckResult,
|
||||||
|
UpdateProgress,
|
||||||
|
} from './ipc-types';
|
||||||
|
|
||||||
|
type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
export interface ElectronAPI {
|
||||||
|
/** Opaque runtime marker (value: 'electron-chatapp-v1'). */
|
||||||
|
platform: string;
|
||||||
|
osPlatform: NodeJS.Platform;
|
||||||
|
appVersion: string;
|
||||||
|
|
||||||
|
getScreenSources: () => Promise<ScreenSource[]>;
|
||||||
|
getScreenThumbnail: (sourceId: string) => Promise<string | null>;
|
||||||
|
setPendingShareSource: (sourceId: string | null) => Promise<void>;
|
||||||
|
|
||||||
|
resolveLoopbackSource: () => Promise<{ sourceId: string } | null>;
|
||||||
|
|
||||||
|
/** Native WASAPI process-loopback bridge — Windows only. `start`
|
||||||
|
* rejects on macOS/Linux or when the .node binary isn't shipped, so
|
||||||
|
* callers should catch and fall back to the getUserMedia path. */
|
||||||
|
audioLoopback: {
|
||||||
|
start: () => Promise<AudioLoopbackStartResult>;
|
||||||
|
/** Window-share variant: capture only the picked window's process
|
||||||
|
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
|
||||||
|
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
|
||||||
|
startForWindow: (hwnd: number) => Promise<AudioLoopbackStartResult>;
|
||||||
|
stop: (captureId: number) => Promise<void>;
|
||||||
|
onChunk: (cb: (payload: AudioLoopbackChunk) => void) => Unsubscribe;
|
||||||
|
};
|
||||||
|
|
||||||
|
registerShortcut: (args: ShortcutRegisterArgs) => Promise<boolean>;
|
||||||
|
unregisterShortcut: (id: string) => Promise<void>;
|
||||||
|
isShortcutRegistered: (id: string) => Promise<boolean>;
|
||||||
|
onShortcutFired: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
|
||||||
|
onShortcutReleased: (cb: (evt: ShortcutEvent) => void) => Unsubscribe;
|
||||||
|
|
||||||
|
notify: (args: NotifyArgs) => Promise<void>;
|
||||||
|
getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>;
|
||||||
|
|
||||||
|
setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise<void>;
|
||||||
|
|
||||||
|
secureStoreOpen: (args: SecureStoreOpenArgs) => Promise<SecureStoreHandle>;
|
||||||
|
secureStoreGet: (handle: string, key: string) => Promise<string | null>;
|
||||||
|
secureStoreSet: (handle: string, key: string, value: string) => Promise<void>;
|
||||||
|
secureStoreRemove: (handle: string, key: string) => Promise<void>;
|
||||||
|
secureStoreClose: (handle: string) => Promise<void>;
|
||||||
|
|
||||||
|
fsRead: (p: FsPath) => Promise<string | null>;
|
||||||
|
fsWrite: (args: FsWriteArgs) => Promise<void>;
|
||||||
|
fsExists: (p: FsPath) => Promise<boolean>;
|
||||||
|
fsMkdir: (p: FsPath) => Promise<void>;
|
||||||
|
fsRename: (args: FsRenameArgs) => Promise<void>;
|
||||||
|
fsRemove: (p: FsPath) => Promise<void>;
|
||||||
|
fsAppLocalDataDir: () => Promise<string>;
|
||||||
|
|
||||||
|
sqlLoad: (args: { name: string }) => Promise<string>;
|
||||||
|
sqlExecute: (args: {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}) => Promise<SqlExecuteResult>;
|
||||||
|
sqlSelect: (args: {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}) => Promise<SqlSelectResult>;
|
||||||
|
sqlClose: (handle: string) => Promise<void>;
|
||||||
|
|
||||||
|
checkForUpdate: () => Promise<UpdateCheckResult>;
|
||||||
|
downloadInstallUpdate: () => Promise<void>;
|
||||||
|
onUpdaterProgress: (cb: (p: UpdateProgress) => void) => Unsubscribe;
|
||||||
|
|
||||||
|
isAutoStartEnabled: () => Promise<boolean>;
|
||||||
|
setAutoStart: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
setFullscreen: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
/** Block OS-level screen capture (Win+PrtScr, OBS, etc.) while a
|
||||||
|
* view-once image is being revealed. Covers Windows (WDA_MONITOR) and
|
||||||
|
* macOS (NSWindowSharingNone). No-op on Linux X11. Optional: always
|
||||||
|
* feature-check because the web build has no preload bridge. */
|
||||||
|
setContentProtection?: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
/** Subscribe to the main-process pre-quit notification. Used by the
|
||||||
|
* "Cache beim Schließen leeren" Settings toggle. */
|
||||||
|
onWipeBeforeQuit: (cb: () => Promise<void>) => () => void;
|
||||||
|
|
||||||
|
/** Returns the OS hostname (via Node's `os.hostname()`). Used by
|
||||||
|
* AuthContext on first launch to populate the `devices` row with a
|
||||||
|
* human-readable default device name. Optional: always feature-check
|
||||||
|
* via `typeof window.electronAPI?.getHostname === 'function'` because
|
||||||
|
* the web build has no preload bridge. */
|
||||||
|
getHostname?: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Preload bridge. Exposes a single `window.electronAPI` object to the
|
||||||
|
// renderer that mirrors the CHANNELS surface from ipc-types.ts. Each
|
||||||
|
// CHANNEL becomes a typed method that calls `ipcRenderer.invoke`; the
|
||||||
|
// three event channels (shortcut fired/released + updater progress) get
|
||||||
|
// `on*` subscribers that return an unsubscribe function.
|
||||||
|
//
|
||||||
|
// contextIsolation is ON so the renderer never sees `ipcRenderer` or
|
||||||
|
// Node directly — only this curated surface.
|
||||||
|
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CHANNELS,
|
||||||
|
ELECTRON_RUNTIME_MARKER,
|
||||||
|
type AudioLoopbackChunk,
|
||||||
|
type AudioLoopbackStartResult,
|
||||||
|
type FsPath,
|
||||||
|
type FsRenameArgs,
|
||||||
|
type FsWriteArgs,
|
||||||
|
type NotifyArgs,
|
||||||
|
type ScreenSource,
|
||||||
|
type SecureStoreHandle,
|
||||||
|
type SecureStoreOpenArgs,
|
||||||
|
type ShortcutEvent,
|
||||||
|
type ShortcutRegisterArgs,
|
||||||
|
type SqlExecuteResult,
|
||||||
|
type SqlSelectResult,
|
||||||
|
type UpdateCheckResult,
|
||||||
|
type UpdateProgress,
|
||||||
|
} from './ipc-types';
|
||||||
|
// Static import of the desktop package.json so the bundler inlines the
|
||||||
|
// version string at build time. The previous `process.env.npm_package_-
|
||||||
|
// version` approach worked in dev (pnpm sets it as a script env var) but
|
||||||
|
// fell back to '0.0.0' in packaged builds — every installed user got
|
||||||
|
// flagged as "Update verfügbar" against their own actually-current
|
||||||
|
// version. resolveJsonModule + esModuleInterop are on in tsconfig.node.
|
||||||
|
import pkg from '../package.json';
|
||||||
|
|
||||||
|
type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
function on<T>(channel: string, cb: (payload: T) => void): Unsubscribe {
|
||||||
|
const handler = (_evt: Electron.IpcRendererEvent, payload: T): void => cb(payload);
|
||||||
|
ipcRenderer.on(channel, handler);
|
||||||
|
return () => ipcRenderer.removeListener(channel, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
platform: ELECTRON_RUNTIME_MARKER,
|
||||||
|
osPlatform: process.platform as NodeJS.Platform,
|
||||||
|
appVersion: pkg.version,
|
||||||
|
|
||||||
|
// Screen sources ---------------------------------------------------------
|
||||||
|
getScreenSources: (): Promise<ScreenSource[]> => ipcRenderer.invoke(CHANNELS.SCREEN_GET_SOURCES),
|
||||||
|
getScreenThumbnail: (sourceId: string): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SCREEN_GET_THUMBNAIL, sourceId),
|
||||||
|
/** Tell main which source the user picked in our Discord-style modal,
|
||||||
|
* BEFORE calling getDisplayMedia. The display-media handler reads this
|
||||||
|
* and routes the matching desktopCapturer Source into LiveKit. Pass
|
||||||
|
* null on cancel/error to clear the pending state. */
|
||||||
|
setPendingShareSource: (sourceId: string | null): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SCREEN_SET_PENDING_SOURCE, sourceId),
|
||||||
|
|
||||||
|
// Audio loopback ---------------------------------------------------------
|
||||||
|
resolveLoopbackSource: (): Promise<{ sourceId: string } | null> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_RESOLVE_SOURCE),
|
||||||
|
|
||||||
|
/** Native WASAPI process-loopback (Windows only). Captures all system
|
||||||
|
* audio EXCEPT our own PID tree so peers don't hear themselves echoed
|
||||||
|
* back. Throws on platforms where the addon isn't available — caller
|
||||||
|
* is expected to fall back to the renderer-driven getUserMedia path
|
||||||
|
* (see lib/screenAudio.ts). */
|
||||||
|
audioLoopback: {
|
||||||
|
start: (): Promise<AudioLoopbackStartResult> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START),
|
||||||
|
/** Window-share variant: capture only the picked window's process
|
||||||
|
* tree (INCLUDE_TARGET_PROCESS_TREE). hwnd is the decimal HWND
|
||||||
|
* parsed from desktopCapturer's `window:<HWND>:0` source id. */
|
||||||
|
startForWindow: (hwnd: number): Promise<AudioLoopbackStartResult> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_START_FOR_WINDOW, { hwnd }),
|
||||||
|
stop: (captureId: number): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.AUDIO_LOOPBACK_STOP, captureId),
|
||||||
|
onChunk: (cb: (payload: AudioLoopbackChunk) => void): Unsubscribe =>
|
||||||
|
on<AudioLoopbackChunk>(CHANNELS.AUDIO_LOOPBACK_CHUNK, cb),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Shortcuts --------------------------------------------------------------
|
||||||
|
registerShortcut: (args: ShortcutRegisterArgs): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SHORTCUT_REGISTER, args),
|
||||||
|
unregisterShortcut: (id: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SHORTCUT_UNREGISTER, id),
|
||||||
|
isShortcutRegistered: (id: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SHORTCUT_IS_REGISTERED, id),
|
||||||
|
onShortcutFired: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||||||
|
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_FIRED, cb),
|
||||||
|
onShortcutReleased: (cb: (evt: ShortcutEvent) => void): Unsubscribe =>
|
||||||
|
on<ShortcutEvent>(CHANNELS.SHORTCUT_EVT_RELEASED, cb),
|
||||||
|
|
||||||
|
// Notifications ----------------------------------------------------------
|
||||||
|
notify: (args: NotifyArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.NOTIFY_SHOW, args),
|
||||||
|
getNotificationPermission: (): Promise<'granted' | 'denied' | 'default'> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION),
|
||||||
|
|
||||||
|
// Tray -------------------------------------------------------------------
|
||||||
|
// `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64)
|
||||||
|
// that main applies as the Windows taskbar overlay icon. We render in the
|
||||||
|
// renderer because main has no Canvas2D; passing a finished image avoids
|
||||||
|
// bundling a native canvas backend just for a 32×32 badge.
|
||||||
|
setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null),
|
||||||
|
|
||||||
|
// Secure store -----------------------------------------------------------
|
||||||
|
secureStoreOpen: (args: SecureStoreOpenArgs): Promise<SecureStoreHandle> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SECURE_STORE_OPEN, args),
|
||||||
|
secureStoreGet: (handle: string, key: string): Promise<string | null> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SECURE_STORE_GET, handle, key),
|
||||||
|
secureStoreSet: (handle: string, key: string, value: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SECURE_STORE_SET, handle, key, value),
|
||||||
|
secureStoreRemove: (handle: string, key: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SECURE_STORE_REMOVE, handle, key),
|
||||||
|
secureStoreClose: (handle: string): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SECURE_STORE_CLOSE, handle),
|
||||||
|
|
||||||
|
// Filesystem -------------------------------------------------------------
|
||||||
|
fsRead: (p: FsPath): Promise<string | null> => ipcRenderer.invoke(CHANNELS.FS_READ, p),
|
||||||
|
fsWrite: (args: FsWriteArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_WRITE, args),
|
||||||
|
fsExists: (p: FsPath): Promise<boolean> => ipcRenderer.invoke(CHANNELS.FS_EXISTS, p),
|
||||||
|
fsMkdir: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_MKDIR, p),
|
||||||
|
fsRename: (args: FsRenameArgs): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_RENAME, args),
|
||||||
|
fsRemove: (p: FsPath): Promise<void> => ipcRenderer.invoke(CHANNELS.FS_REMOVE, p),
|
||||||
|
fsAppLocalDataDir: (): Promise<string> => ipcRenderer.invoke(CHANNELS.FS_APP_LOCAL_DATA_DIR),
|
||||||
|
|
||||||
|
// SQL --------------------------------------------------------------------
|
||||||
|
sqlLoad: (args: { name: string }): Promise<string> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.SQL_LOAD, args),
|
||||||
|
sqlExecute: (args: {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}): Promise<SqlExecuteResult> => ipcRenderer.invoke(CHANNELS.SQL_EXECUTE, args),
|
||||||
|
sqlSelect: (args: {
|
||||||
|
handle: string;
|
||||||
|
query: string;
|
||||||
|
bindings?: unknown[];
|
||||||
|
}): Promise<SqlSelectResult> => ipcRenderer.invoke(CHANNELS.SQL_SELECT, args),
|
||||||
|
sqlClose: (handle: string): Promise<void> => ipcRenderer.invoke(CHANNELS.SQL_CLOSE, handle),
|
||||||
|
|
||||||
|
// Updater ----------------------------------------------------------------
|
||||||
|
checkForUpdate: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(CHANNELS.UPDATER_CHECK),
|
||||||
|
downloadInstallUpdate: (): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.UPDATER_DOWNLOAD_INSTALL),
|
||||||
|
onUpdaterProgress: (cb: (p: UpdateProgress) => void): Unsubscribe =>
|
||||||
|
on<UpdateProgress>(CHANNELS.UPDATER_EVT_PROGRESS, cb),
|
||||||
|
|
||||||
|
// Autostart --------------------------------------------------------------
|
||||||
|
isAutoStartEnabled: (): Promise<boolean> => ipcRenderer.invoke(CHANNELS.AUTOSTART_IS_ENABLED),
|
||||||
|
setAutoStart: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.AUTOSTART_SET, enabled),
|
||||||
|
|
||||||
|
// Window fullscreen ------------------------------------------------------
|
||||||
|
setFullscreen: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_FULLSCREEN, enabled),
|
||||||
|
|
||||||
|
// Window content protection ----------------------------------------------
|
||||||
|
setContentProtection: (enabled: boolean): Promise<void> =>
|
||||||
|
ipcRenderer.invoke(CHANNELS.WINDOW_SET_CONTENT_PROTECTION, { enabled }),
|
||||||
|
|
||||||
|
// OS hostname ------------------------------------------------------------
|
||||||
|
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.APP_HOSTNAME),
|
||||||
|
|
||||||
|
// Wipe-on-close ----------------------------------------------------------
|
||||||
|
// Subscribe to the main-process pre-quit notification. The renderer's
|
||||||
|
// callback does the actual wipe (memoryWipe.ts) and resolves; we ack
|
||||||
|
// unconditionally so main can finish quitting — better to lose the wipe
|
||||||
|
// than to hang the app shutdown if the callback throws.
|
||||||
|
onWipeBeforeQuit: (cb: () => Promise<void>): Unsubscribe => {
|
||||||
|
const handler = async (_evt: Electron.IpcRendererEvent): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await cb();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[wipe] renderer cb failed', err);
|
||||||
|
}
|
||||||
|
ipcRenderer.send(CHANNELS.APP_WIPE_BEFORE_QUIT + ':done');
|
||||||
|
};
|
||||||
|
ipcRenderer.on(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||||
|
return () => ipcRenderer.removeListener(CHANNELS.APP_WIPE_BEFORE_QUIT, handler);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ElectronAPI = typeof api;
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', api);
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Persists BrowserWindow bounds + maximized state to userData/<filename>.
|
||||||
|
// Saves are debounced 500ms on move/resize, and fired synchronously on close.
|
||||||
|
// Validates loaded bounds against the current display layout to avoid
|
||||||
|
// restoring a window onto a display that no longer exists.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, screen } from 'electron';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maximized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT: State = { width: 1200, height: 800 };
|
||||||
|
|
||||||
|
function isOnAnyDisplay(bounds: { x: number; y: number; width: number; height: number }): boolean {
|
||||||
|
for (const d of screen.getAllDisplays()) {
|
||||||
|
const wa = d.workArea;
|
||||||
|
if (
|
||||||
|
bounds.x >= wa.x &&
|
||||||
|
bounds.y >= wa.y &&
|
||||||
|
bounds.x + bounds.width <= wa.x + wa.width + 8 &&
|
||||||
|
bounds.y + bounds.height <= wa.y + wa.height + 8
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadState(filename: string): Promise<State> {
|
||||||
|
const filePath = path.join(app.getPath('userData'), filename);
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Partial<State>;
|
||||||
|
const width = typeof parsed.width === 'number' ? parsed.width : DEFAULT.width;
|
||||||
|
const height = typeof parsed.height === 'number' ? parsed.height : DEFAULT.height;
|
||||||
|
const state: State = { width, height };
|
||||||
|
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
|
||||||
|
if (isOnAnyDisplay({ x: parsed.x, y: parsed.y, width, height })) {
|
||||||
|
state.x = parsed.x;
|
||||||
|
state.y = parsed.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed.maximized) state.maximized = true;
|
||||||
|
return state;
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attach(win: BrowserWindow, filename: string): void {
|
||||||
|
const filePath = path.join(app.getPath('userData'), filename);
|
||||||
|
let saveTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const writeNow = (): void => {
|
||||||
|
if (win.isDestroyed()) return;
|
||||||
|
const bounds = win.getNormalBounds();
|
||||||
|
const state: State = {
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
maximized: win.isMaximized(),
|
||||||
|
};
|
||||||
|
// Fire-and-forget; exceptions are logged but non-fatal.
|
||||||
|
void fs
|
||||||
|
.writeFile(filePath, JSON.stringify(state), 'utf8')
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('[window-state] write failed', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = (): void => {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(writeNow, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
win.on('move', schedule);
|
||||||
|
win.on('resize', schedule);
|
||||||
|
win.on('maximize', schedule);
|
||||||
|
win.on('unmaximize', schedule);
|
||||||
|
win.on('close', () => {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
writeNow();
|
||||||
|
});
|
||||||
|
}
|
||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audio-loopback"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"napi",
|
||||||
|
"napi-build",
|
||||||
|
"napi-derive",
|
||||||
|
"wasapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctor"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "napi"
|
||||||
|
version = "2.16.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"ctor",
|
||||||
|
"napi-derive",
|
||||||
|
"napi-sys",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "napi-build"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "napi-derive"
|
||||||
|
version = "2.16.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"convert_case",
|
||||||
|
"napi-derive-backend",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "napi-derive-backend"
|
||||||
|
version = "1.0.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case",
|
||||||
|
"once_cell",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"semver",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "napi-sys"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
||||||
|
dependencies = [
|
||||||
|
"libloading",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasapi"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f6b03b82e419f186fcdc06ac6068621bdadc88b89b2612067f1c021ad2c9449"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"num-integer",
|
||||||
|
"widestring",
|
||||||
|
"windows",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widestring"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-result",
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.57.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "audio-loopback"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
publish = false
|
||||||
|
description = "Native Windows process-loopback audio capture for ChatApp Electron"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
# Reduce binary size on release builds and avoid the default-features
|
||||||
|
# panic-handler that bloats node addons. We don't strip — keeping symbols
|
||||||
|
# helps debug crashes from a packaged build.
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
napi = { version = "2", default-features = false, features = ["napi6"] }
|
||||||
|
napi-derive = "2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# Match the Tauri reference exactly. Cargo.lock there pins 0.15.0; the
|
||||||
|
# 0.15 family exposes AudioClient::new_application_loopback_client(pid,
|
||||||
|
# include_tree) which is the load-bearing API for EXCLUDE_TARGET_PROCESS_TREE.
|
||||||
|
wasapi = "0.15"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
napi-build = "2"
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
// napi-rs build helper. Generates the platform-specific binding glue
|
||||||
|
// (e.g. typedefs, init symbols) that `napi build` consumes. Required for
|
||||||
|
// every napi-rs crate; the build will silently produce a half-wired
|
||||||
|
// binding without it.
|
||||||
|
|
||||||
|
extern crate napi_build;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
napi_build::setup();
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
/* auto-generated by NAPI-RS */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a process-loopback capture that excludes the current process
|
||||||
|
* tree. The supplied JS callback is invoked from a background thread
|
||||||
|
* with one argument: a Float32Array of interleaved f32 stereo samples
|
||||||
|
* at 48kHz. Returns a numeric capture id that must be passed to
|
||||||
|
* `stopCapture` when the share ends.
|
||||||
|
*
|
||||||
|
* Always excludes `std::process::id()` (whole-OS-mixer-minus-self).
|
||||||
|
* For "include only this app" use `start_capture_for_pid` instead.
|
||||||
|
*/
|
||||||
|
export declare function startCapture(callback: (...args: any[]) => any): number
|
||||||
|
/**
|
||||||
|
* Start a process-loopback capture that INCLUDES the target PID's
|
||||||
|
* process tree (and only that tree) — the WASAPI
|
||||||
|
* INCLUDE_TARGET_PROCESS_TREE mode. Used for window-shares where we
|
||||||
|
* want only the picked app's audio (Discord parity).
|
||||||
|
*/
|
||||||
|
export declare function startCaptureForPid(pid: number, callback: (...args: any[]) => any): number
|
||||||
|
/**
|
||||||
|
* Resolve the owning process id of a top-level window handle. The
|
||||||
|
* renderer derives `hwnd` from desktopCapturer's `window:<HWND>:0`
|
||||||
|
* source ids and we hand that to `start_capture_for_pid`.
|
||||||
|
*/
|
||||||
|
export declare function resolveWindowPid(hwnd: number): number
|
||||||
|
/**
|
||||||
|
* Tear down the capture for the given id. Safe to call on a missing id
|
||||||
|
* (no-op) so the JS side doesn't have to track whether the stop has
|
||||||
|
* already been issued by the screen-share teardown path.
|
||||||
|
*/
|
||||||
|
export declare function stopCapture(captureId: number): void
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
|
||||||
|
/* auto-generated by NAPI-RS */
|
||||||
|
|
||||||
|
const { existsSync, readFileSync } = require('fs')
|
||||||
|
const { join } = require('path')
|
||||||
|
|
||||||
|
const { platform, arch } = process
|
||||||
|
|
||||||
|
let nativeBinding = null
|
||||||
|
let localFileExisted = false
|
||||||
|
let loadError = null
|
||||||
|
|
||||||
|
function isMusl() {
|
||||||
|
// For Node 10
|
||||||
|
if (!process.report || typeof process.report.getReport !== 'function') {
|
||||||
|
try {
|
||||||
|
const lddPath = require('child_process').execSync('which ldd').toString().trim()
|
||||||
|
return readFileSync(lddPath, 'utf8').includes('musl')
|
||||||
|
} catch (e) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { glibcVersionRuntime } = process.report.getReport().header
|
||||||
|
return !glibcVersionRuntime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case 'android':
|
||||||
|
switch (arch) {
|
||||||
|
case 'arm64':
|
||||||
|
localFileExisted = existsSync(join(__dirname, 'audio-loopback.android-arm64.node'))
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.android-arm64.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-android-arm64')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'arm':
|
||||||
|
localFileExisted = existsSync(join(__dirname, 'audio-loopback.android-arm-eabi.node'))
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.android-arm-eabi.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-android-arm-eabi')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture on Android ${arch}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'win32':
|
||||||
|
switch (arch) {
|
||||||
|
case 'x64':
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.win32-x64-msvc.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.win32-x64-msvc.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-win32-x64-msvc')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ia32':
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.win32-ia32-msvc.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.win32-ia32-msvc.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-win32-ia32-msvc')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'arm64':
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.win32-arm64-msvc.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.win32-arm64-msvc.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-win32-arm64-msvc')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture on Windows: ${arch}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'darwin':
|
||||||
|
localFileExisted = existsSync(join(__dirname, 'audio-loopback.darwin-universal.node'))
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.darwin-universal.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-darwin-universal')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch {}
|
||||||
|
switch (arch) {
|
||||||
|
case 'x64':
|
||||||
|
localFileExisted = existsSync(join(__dirname, 'audio-loopback.darwin-x64.node'))
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.darwin-x64.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-darwin-x64')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'arm64':
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.darwin-arm64.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.darwin-arm64.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-darwin-arm64')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture on macOS: ${arch}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'freebsd':
|
||||||
|
if (arch !== 'x64') {
|
||||||
|
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
|
||||||
|
}
|
||||||
|
localFileExisted = existsSync(join(__dirname, 'audio-loopback.freebsd-x64.node'))
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.freebsd-x64.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-freebsd-x64')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'linux':
|
||||||
|
switch (arch) {
|
||||||
|
case 'x64':
|
||||||
|
if (isMusl()) {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-x64-musl.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-x64-musl.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-x64-musl')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-x64-gnu.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-x64-gnu.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-x64-gnu')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'arm64':
|
||||||
|
if (isMusl()) {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-arm64-musl.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-arm64-musl.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm64-musl')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-arm64-gnu.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-arm64-gnu.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm64-gnu')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'arm':
|
||||||
|
if (isMusl()) {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-arm-musleabihf.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-arm-musleabihf.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm-musleabihf')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-arm-gnueabihf.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-arm-gnueabihf.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-arm-gnueabihf')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'riscv64':
|
||||||
|
if (isMusl()) {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-riscv64-musl.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-riscv64-musl.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-riscv64-musl')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-riscv64-gnu.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-riscv64-gnu.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-riscv64-gnu')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 's390x':
|
||||||
|
localFileExisted = existsSync(
|
||||||
|
join(__dirname, 'audio-loopback.linux-s390x-gnu.node')
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (localFileExisted) {
|
||||||
|
nativeBinding = require('./audio-loopback.linux-s390x-gnu.node')
|
||||||
|
} else {
|
||||||
|
nativeBinding = require('@chatapp/audio-loopback-native-linux-s390x-gnu')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported architecture on Linux: ${arch}`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nativeBinding) {
|
||||||
|
if (loadError) {
|
||||||
|
throw loadError
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load native binding`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startCapture, startCaptureForPid, resolveWindowPid, stopCapture } = nativeBinding
|
||||||
|
|
||||||
|
module.exports.startCapture = startCapture
|
||||||
|
module.exports.startCaptureForPid = startCaptureForPid
|
||||||
|
module.exports.resolveWindowPid = resolveWindowPid
|
||||||
|
module.exports.stopCapture = stopCapture
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@chatapp/audio-loopback-native",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Native Windows process-loopback audio capture addon (excludes our own PID tree)",
|
||||||
|
"main": "index.js",
|
||||||
|
"types": "index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"index.d.ts",
|
||||||
|
"*.node"
|
||||||
|
],
|
||||||
|
"napi": {
|
||||||
|
"name": "audio-loopback",
|
||||||
|
"triples": {
|
||||||
|
"defaults": false,
|
||||||
|
"additional": [
|
||||||
|
"x86_64-pc-windows-msvc"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "napi build --platform --release",
|
||||||
|
"build:debug": "napi build --platform"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@napi-rs/cli": "^2.18.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Native system-audio capture addon for the ChatApp Electron desktop
|
||||||
|
// client. Mirrors the proven Tauri implementation
|
||||||
|
// (apps/desktop/src-tauri/src/screen_audio.rs in the legacy repo).
|
||||||
|
//
|
||||||
|
// Why a native addon at all when Electron already exposes a 'loopback'
|
||||||
|
// audio source through setDisplayMediaRequestHandler? Because Chromium's
|
||||||
|
// loopback captures the entire OS mixer including our own renderer's
|
||||||
|
// playback — peers in a video call hear themselves echoed back when the
|
||||||
|
// sharer ticks "system audio". Windows ships an EXCLUDE_TARGET_PROCESS_TREE
|
||||||
|
// process-loopback mode that captures every render session except the
|
||||||
|
// targeted PID's tree. We pass our own PID so the LiveKit playback never
|
||||||
|
// re-enters the outgoing share.
|
||||||
|
//
|
||||||
|
// Wire format is fixed at 48kHz interleaved f32 stereo. The renderer-
|
||||||
|
// side AudioWorklet (lib/loopbackAudio.ts) assumes that layout and feeds
|
||||||
|
// samples into a MediaStreamDestination so LiveKit publishes a plain
|
||||||
|
// ScreenShareAudio track.
|
||||||
|
//
|
||||||
|
// Windows-only for v1. macOS/Linux stubs return a clear napi::Error so
|
||||||
|
// the renderer can fall through to the existing getUserMedia path.
|
||||||
|
|
||||||
|
#![allow(clippy::needless_return)]
|
||||||
|
|
||||||
|
use napi::bindgen_prelude::{Float32Array, Result};
|
||||||
|
use napi::threadsafe_function::{
|
||||||
|
ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode,
|
||||||
|
};
|
||||||
|
use napi::JsFunction;
|
||||||
|
use napi_derive::napi;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
// Output format we always deliver to the frontend. Pinning a single fixed
|
||||||
|
// format means the AudioWorklet never has to renegotiate — it just
|
||||||
|
// assumes interleaved f32 stereo at 48kHz. WASAPI mix format is usually
|
||||||
|
// already this on Windows 10+, so the resample/upmix branch inside
|
||||||
|
// process-loopback's autoconvert is rarely hit.
|
||||||
|
const OUTPUT_SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const OUTPUT_CHANNELS: u16 = 2;
|
||||||
|
|
||||||
|
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||||
|
static SESSIONS: OnceLock<Mutex<HashMap<u32, Session>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn sessions() -> &'static Mutex<HashMap<u32, Session>> {
|
||||||
|
SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Session {
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
handle: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a process-loopback capture that excludes the current process
|
||||||
|
/// tree. The supplied JS callback is invoked from a background thread
|
||||||
|
/// with one argument: a Float32Array of interleaved f32 stereo samples
|
||||||
|
/// at 48kHz. Returns a numeric capture id that must be passed to
|
||||||
|
/// `stopCapture` when the share ends.
|
||||||
|
///
|
||||||
|
/// Always excludes `std::process::id()` (whole-OS-mixer-minus-self).
|
||||||
|
/// For "include only this app" use `start_capture_for_pid` instead.
|
||||||
|
#[napi]
|
||||||
|
pub fn start_capture(callback: JsFunction) -> Result<u32> {
|
||||||
|
spawn_capture_session(callback, std::process::id(), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a process-loopback capture that INCLUDES the target PID's
|
||||||
|
/// process tree (and only that tree) — the WASAPI
|
||||||
|
/// INCLUDE_TARGET_PROCESS_TREE mode. Used for window-shares where we
|
||||||
|
/// want only the picked app's audio (Discord parity).
|
||||||
|
#[napi]
|
||||||
|
pub fn start_capture_for_pid(pid: u32, callback: JsFunction) -> Result<u32> {
|
||||||
|
spawn_capture_session(callback, pid, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the owning process id of a top-level window handle. The
|
||||||
|
/// renderer derives `hwnd` from desktopCapturer's `window:<HWND>:0`
|
||||||
|
/// source ids and we hand that to `start_capture_for_pid`.
|
||||||
|
#[napi]
|
||||||
|
pub fn resolve_window_pid(hwnd: u32) -> Result<u32> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Minimal FFI to user32!GetWindowThreadProcessId — pulling in a
|
||||||
|
// full windows crate just for one call would balloon build
|
||||||
|
// times. The function returns the thread id (we ignore it) and
|
||||||
|
// writes the process id through the pointer.
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
extern "system" {
|
||||||
|
fn GetWindowThreadProcessId(hWnd: usize, lpdwProcessId: *mut u32) -> u32;
|
||||||
|
}
|
||||||
|
let mut pid: u32 = 0;
|
||||||
|
// SAFETY: GetWindowThreadProcessId tolerates an invalid HWND
|
||||||
|
// (returns 0 thread id and leaves *lpdwProcessId untouched). We
|
||||||
|
// detect the failure case by checking for pid == 0 below.
|
||||||
|
let thread_id = unsafe { GetWindowThreadProcessId(hwnd as usize, &mut pid) };
|
||||||
|
if thread_id == 0 || pid == 0 {
|
||||||
|
return Err(napi::Error::from_reason(format!(
|
||||||
|
"GetWindowThreadProcessId({hwnd}) failed — window may have closed"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let _ = hwnd;
|
||||||
|
Err(napi::Error::from_reason(
|
||||||
|
"resolve_window_pid only supported on Windows",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn spawn_capture_session(
|
||||||
|
callback: JsFunction,
|
||||||
|
pid: u32,
|
||||||
|
include_tree: bool,
|
||||||
|
) -> Result<u32> {
|
||||||
|
// ErrorStrategy::Fatal — the JS callback signature is `(samples)`
|
||||||
|
// not `(err, samples)`, so we don't want napi to inject an
|
||||||
|
// error slot. If anything goes wrong on the Rust side we tear
|
||||||
|
// the session down and stop calling the callback.
|
||||||
|
let tsfn: ThreadsafeFunction<Float32Array, ErrorStrategy::Fatal> =
|
||||||
|
callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?;
|
||||||
|
|
||||||
|
let capture_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let stop_clone = Arc::clone(&stop);
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name(format!("audio-loopback-{capture_id}"))
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(err) = windows_loopback::capture_loop(
|
||||||
|
capture_id,
|
||||||
|
tsfn,
|
||||||
|
stop_clone,
|
||||||
|
pid,
|
||||||
|
include_tree,
|
||||||
|
) {
|
||||||
|
eprintln!("audio-loopback {capture_id}: {err}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| {
|
||||||
|
napi::Error::from_reason(format!("failed to spawn audio thread: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sessions().lock().unwrap().insert(
|
||||||
|
capture_id,
|
||||||
|
Session {
|
||||||
|
stop,
|
||||||
|
handle: Some(handle),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(capture_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
fn spawn_capture_session(
|
||||||
|
callback: JsFunction,
|
||||||
|
_pid: u32,
|
||||||
|
_include_tree: bool,
|
||||||
|
) -> Result<u32> {
|
||||||
|
let _ = callback;
|
||||||
|
Err(napi::Error::from_reason(
|
||||||
|
"system audio capture only supported on Windows",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down the capture for the given id. Safe to call on a missing id
|
||||||
|
/// (no-op) so the JS side doesn't have to track whether the stop has
|
||||||
|
/// already been issued by the screen-share teardown path.
|
||||||
|
#[napi]
|
||||||
|
pub fn stop_capture(capture_id: u32) -> Result<()> {
|
||||||
|
let session = sessions().lock().unwrap().remove(&capture_id);
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
session.stop.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(handle) = session.handle.take() {
|
||||||
|
// Best-effort join — the capture loop polls `stop` every event
|
||||||
|
// cycle (≤100ms) so this usually returns promptly. If a WASAPI
|
||||||
|
// call is wedged we'd rather drop the handle than hang the JS
|
||||||
|
// teardown path.
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Windows loopback implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
mod windows_loopback {
|
||||||
|
use super::*;
|
||||||
|
use wasapi::{initialize_mta, AudioClient, Direction, SampleType, ShareMode, WaveFormat};
|
||||||
|
|
||||||
|
// 200ms request buffer in 100ns units. Process-loopback clients
|
||||||
|
// ignore the period for shared-mode but the API still requires a
|
||||||
|
// non-zero value — picking 200ms keeps wakeups infrequent enough
|
||||||
|
// that we don't spin the capture thread on idle audio.
|
||||||
|
const REQUESTED_BUFFER_HNS: i64 = 2_000_000;
|
||||||
|
|
||||||
|
pub fn capture_loop(
|
||||||
|
capture_id: u32,
|
||||||
|
tsfn: ThreadsafeFunction<Float32Array, ErrorStrategy::Fatal>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
pid: u32,
|
||||||
|
include_tree: bool,
|
||||||
|
) -> std::result::Result<(), String> {
|
||||||
|
// COM must be initialised on every thread that touches WASAPI.
|
||||||
|
// MTA is the right model for a background capture thread —
|
||||||
|
// STA would require message pumping we don't want to add.
|
||||||
|
initialize_mta()
|
||||||
|
.ok()
|
||||||
|
.map_err(|e| format!("initialize_mta: {e:?}"))?;
|
||||||
|
|
||||||
|
// Process-loopback. Two modes — selected by `include_tree`:
|
||||||
|
// false → EXCLUDE_TARGET_PROCESS_TREE: every render session on
|
||||||
|
// the box *except* the given PID's tree. Default for
|
||||||
|
// full-screen shares so LiveKit playback stays out of
|
||||||
|
// the outgoing audio (we pass our own PID).
|
||||||
|
// true → INCLUDE_TARGET_PROCESS_TREE: only the given PID's
|
||||||
|
// tree. Used for window-shares so the captured audio
|
||||||
|
// is exactly the picked app (Discord parity).
|
||||||
|
// The `include_tree` flag is the load-bearing arg to
|
||||||
|
// `new_application_loopback_client`; do not flip without
|
||||||
|
// re-reading the wasapi crate's docs.
|
||||||
|
let mut audio_client =
|
||||||
|
AudioClient::new_application_loopback_client(pid, include_tree)
|
||||||
|
.map_err(|e| format!("new_application_loopback_client: {e:?}"))?;
|
||||||
|
|
||||||
|
// Process-loopback only accepts caller-specified formats —
|
||||||
|
// `get_mixformat` is documented as broken on this client. We
|
||||||
|
// pin the wire format we already deliver downstream: 48kHz,
|
||||||
|
// 32-bit float, stereo. `autoconvert=true` (the trailing `true`
|
||||||
|
// arg to initialize_client) lets WASAPI mix arbitrary session
|
||||||
|
// formats into ours so games at 44.1k or mono notification
|
||||||
|
// sounds don't blow up the capture.
|
||||||
|
let wave_format = WaveFormat::new(
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
&SampleType::Float,
|
||||||
|
OUTPUT_SAMPLE_RATE as usize,
|
||||||
|
OUTPUT_CHANNELS as usize,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.initialize_client(
|
||||||
|
&wave_format,
|
||||||
|
REQUESTED_BUFFER_HNS,
|
||||||
|
&Direction::Capture,
|
||||||
|
&ShareMode::Shared,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("initialize_client (process-loopback): {e:?}"))?;
|
||||||
|
|
||||||
|
let h_event = audio_client
|
||||||
|
.set_get_eventhandle()
|
||||||
|
.map_err(|e| format!("set_get_eventhandle: {e:?}"))?;
|
||||||
|
let capture_client = audio_client
|
||||||
|
.get_audiocaptureclient()
|
||||||
|
.map_err(|e| format!("get_audiocaptureclient: {e:?}"))?;
|
||||||
|
|
||||||
|
audio_client
|
||||||
|
.start_stream()
|
||||||
|
.map_err(|e| format!("start_stream: {e:?}"))?;
|
||||||
|
|
||||||
|
let block_align = wave_format.get_blockalign() as usize;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
// 100ms timeout lets the loop check the stop flag even when
|
||||||
|
// every excluded session is silent — there's nothing to
|
||||||
|
// render so the event handle never fires.
|
||||||
|
if h_event.wait_for_event(100).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain all packets available since the last wake — there
|
||||||
|
// can be several queued if we were preempted.
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let frames_available = match capture_client.get_next_nbr_frames() {
|
||||||
|
Ok(Some(n)) if n > 0 => n,
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"audio-loopback {capture_id}: get_next_nbr_frames: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes_needed = frames_available as usize * block_align;
|
||||||
|
let mut raw = vec![0u8; bytes_needed];
|
||||||
|
if let Err(e) = capture_client.read_from_device(&mut raw) {
|
||||||
|
eprintln!(
|
||||||
|
"audio-loopback {capture_id}: read_from_device: {e:?}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinterpret bytes as f32 little-endian samples. The
|
||||||
|
// buffer is already 48kHz f32 stereo because process-
|
||||||
|
// loopback autoconverted to our requested format. We
|
||||||
|
// copy out into a Vec<f32> so napi can hand ownership
|
||||||
|
// of a JS-owned ArrayBuffer to the renderer.
|
||||||
|
let mut samples = Vec::<f32>::with_capacity(raw.len() / 4);
|
||||||
|
let mut idx = 0;
|
||||||
|
while idx + 4 <= raw.len() {
|
||||||
|
let bytes = [raw[idx], raw[idx + 1], raw[idx + 2], raw[idx + 3]];
|
||||||
|
samples.push(f32::from_le_bytes(bytes));
|
||||||
|
idx += 4;
|
||||||
|
}
|
||||||
|
let _ = (OUTPUT_SAMPLE_RATE, OUTPUT_CHANNELS);
|
||||||
|
|
||||||
|
let payload = Float32Array::new(samples);
|
||||||
|
// NonBlocking: never block the WASAPI capture thread on
|
||||||
|
// a slow JS event loop — at 48kHz stereo a stalled
|
||||||
|
// renderer would otherwise back-pressure the WASAPI
|
||||||
|
// event handle and underrun every other consumer.
|
||||||
|
let status = tsfn.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
|
||||||
|
if status != napi::Status::Ok {
|
||||||
|
// Renderer went away or the threadsafe function was
|
||||||
|
// released — stop cleanly.
|
||||||
|
stop.store(true, Ordering::Relaxed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = audio_client.stop_stream();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"rustc_fingerprint":2812790340253412174,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Users\\denni\\.rustup\\toolchains\\stable-x86_64-pc-windows-msvc\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: x86_64-pc-windows-msvc\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""}},"successes":{}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Signature: 8a477f597d28d172789f06886806bc55
|
||||||
|
# This file is a cache directory tag created by cargo.
|
||||||
|
# For information about cache directory tags see https://bford.info/cachedir/
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
e688a2af0c492af6
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[\"perf-literal\", \"std\"]","declared_features":"[\"default\", \"logging\", \"perf-literal\", \"std\"]","target":7534583537114156500,"profile":17257705230225558938,"path":12213129009505393428,"deps":[[1363051979936526615,"memchr",false,10675094612715872077]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\aho-corasick-9f0713aa5a0615e8\\dep-lib-aho_corasick","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
d4081d3eaff98543
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":5408242616063297496,"profile":17257705230225558938,"path":13767053534773805487,"deps":[[1035178698636953719,"napi_build",false,13969733624275323243]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\audio-loopback-12f5458902d8391e\\dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
be43ec00f8767e8d
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[2572457258515766174,"build_script_build",false,4865569503500372180]],"local":[{"RerunIfEnvChanged":{"var":"DEBUG_GENERATED_CODE","val":null}},{"RerunIfEnvChanged":{"var":"TYPE_DEF_TMP_PATH","val":"C:\\Users\\denni\\AppData\\Local\\Temp\\audio_loopback-a3c3b6f6.napi_type_def.tmp"}},{"RerunIfEnvChanged":{"var":"CARGO_CFG_NAPI_RS_CLI_VERSION","val":"2.18.4"}},{"RerunIfEnvChanged":{"var":"NAPI_DEBUG_GENERATED_CODE","val":null}},{"RerunIfEnvChanged":{"var":"NAPI_TYPE_DEF_TMP_FOLDER","val":null}},{"RerunIfEnvChanged":{"var":"NAPI_FORCE_BUILD_AUDIO_LOOPBACK","val":null}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
5516adc767f5abec
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":11760611160289367125,"profile":5353520049763563864,"path":10763286916239946207,"deps":[[2572457258515766174,"build_script_build",false,10195717413952832446],[13045677537521422049,"wasapi",false,17410940550348078650],[13423243174795060362,"napi_derive",false,2979073294737158417],[16099943211762415786,"napi",false,7346211637381484829]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\audio-loopback-6c6fd3fda356bdba\\dep-lib-audio_loopback","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
0ccbb31ddfd4265e
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":6962977057026645649,"profile":17257705230225558938,"path":18420280761196579094,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\autocfg-ef41a80ef2a4f8c4\\dep-lib-autocfg","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
3f3c2a22cbdb03dc
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":16503403049695105087,"path":12664969178542693245,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\bitflags-56bc72c2ee6a11d2\\dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
fc3ac7ee25a03df6
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":17257705230225558938,"path":7789178187138679712,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\cfg-if-2de0fde34c98fcea\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
c9711a2dd730566c
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"rand\", \"random\"]","target":13517390075341535229,"profile":17257705230225558938,"path":13073641468317742375,"deps":[[4341528441765018781,"unicode_segmentation",false,4009714942342982812]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\convert_case-cb245ff7b0d5094c\\dep-lib-convert_case","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
fcff169995187d99
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"used_linker\"]","target":16767752466166802488,"profile":17257705230225558938,"path":8223028202004655627,"deps":[[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\ctor-c44e8ff1492b4966\\dep-lib-ctor","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
48a82cf2ec9381fd
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":9378127968640496523,"profile":2301124911398833726,"path":15222717544052245301,"deps":[[6959378045035346538,"windows_link",false,12153921703720002459]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\libloading-c73fed9c19177dcc\\dep-lib-libloading","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ddda7dfc68960487
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[\"kv\", \"kv_serde\", \"kv_std\", \"kv_sval\", \"kv_unstable\", \"kv_unstable_serde\", \"kv_unstable_std\", \"kv_unstable_sval\", \"max_level_debug\", \"max_level_error\", \"max_level_info\", \"max_level_off\", \"max_level_trace\", \"max_level_warn\", \"release_max_level_debug\", \"release_max_level_error\", \"release_max_level_info\", \"release_max_level_off\", \"release_max_level_trace\", \"release_max_level_warn\", \"serde\", \"serde_core\", \"std\", \"sval\", \"sval_ref\", \"value-bag\"]","target":6550155848337067049,"profile":16503403049695105087,"path":2935339677018414979,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\log-503ba197615b4899\\dep-lib-log","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
4d9f9dbefd8d2594
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[\"alloc\", \"std\"]","declared_features":"[\"alloc\", \"core\", \"default\", \"libc\", \"logging\", \"rustc-dep-of-std\", \"std\", \"use_std\"]","target":11745930252914242013,"profile":17257705230225558938,"path":10267584396946875985,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\memchr-0b6ded161300fb6d\\dep-lib-memchr","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
6bc9c269b776dec1
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[]","declared_features":"[]","target":9388202626367339685,"profile":17257705230225558938,"path":6355682921637151155,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-build-547d402dbe5275e3\\dep-lib-napi_build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
1dd1f6f28ffcf265
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[\"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\"]","declared_features":"[\"anyhow\", \"async\", \"chrono\", \"chrono_date\", \"compat-mode\", \"default\", \"deferred_trace\", \"dyn-symbols\", \"encoding_rs\", \"error_anyhow\", \"experimental\", \"full\", \"indexmap\", \"latin1\", \"napi1\", \"napi2\", \"napi3\", \"napi4\", \"napi5\", \"napi6\", \"napi7\", \"napi8\", \"napi9\", \"noop\", \"object_indexmap\", \"serde\", \"serde-json\", \"serde-json-ordered\", \"serde_json\", \"tokio\", \"tokio_fs\", \"tokio_full\", \"tokio_io_std\", \"tokio_io_util\", \"tokio_macros\", \"tokio_net\", \"tokio_process\", \"tokio_rt\", \"tokio_signal\", \"tokio_sync\", \"tokio_test_util\", \"tokio_time\"]","target":6604924358859142166,"profile":16503403049695105087,"path":12768161688960962680,"deps":[[900613073546913600,"napi_sys",false,1702221090865446476],[2571033484697105782,"bitflags",false,15853756778772315199],[5855319743879205494,"once_cell",false,4803485267941743741],[6606131838865521726,"ctor",false,11060023290689421308]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-c71c5da813d1757f\\dep-lib-napi","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
1131a9b460cb5729
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[\"compat-mode\", \"default\", \"full\", \"strict\", \"type-def\"]","declared_features":"[\"compat-mode\", \"default\", \"full\", \"noop\", \"strict\", \"type-def\"]","target":2065430088197001673,"profile":17257705230225558938,"path":14139299789826547350,"deps":[[4289358735036141001,"proc_macro2",false,4386134274409224043],[5241157436998822951,"napi_derive_backend",false,14640003306665018262],[7667230146095136825,"cfg_if",false,17743514191689890556],[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813],[13475460906694513802,"convert_case",false,7806480704826143177]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-derive-8a06c7ca101ebb69\\dep-lib-napi_derive","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
960350fc70bd2bcb
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"rustc":8891013984288978370,"features":"[\"regex\", \"semver\", \"strict\", \"type-def\"]","declared_features":"[\"noop\", \"regex\", \"semver\", \"strict\", \"type-def\"]","target":7459870077939534063,"profile":17257705230225558938,"path":18206335121874424249,"deps":[[4289358735036141001,"proc_macro2",false,4386134274409224043],[5855319743879205494,"once_cell",false,3458902346603695405],[9680020106200215617,"semver",false,3158118117038451264],[10420560437213941093,"syn",false,667617596471125619],[13111758008314797071,"quote",false,15488645298819577813],[13475460906694513802,"convert_case",false,7806480704826143177],[17109794424245468765,"regex",false,15429713168089850008]],"local":[{"CheckDepInfo":{"dep_info":"release\\.fingerprint\\napi-derive-backend-b741fd0ea2b72c1a\\dep-lib-napi_derive_backend","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
|||||||
|
This file has an mtime of when this was started.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
4c7e363a10819f17
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user