(feat): migrate experimental tauri cef to electron
(feat): add flake to expose tensamin desktop package (qol): update todo
This commit is contained in:
parent
be80e2f387
commit
a209ade10b
32 changed files with 1649 additions and 459 deletions
2
apps/electron/.gitignore
vendored
Normal file
2
apps/electron/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist
|
||||
release
|
||||
61
apps/electron/flake.lock
generated
Normal file
61
apps/electron/flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779508470,
|
||||
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "29916453413845e54a65b8a1cf996842300cd299",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
88
apps/electron/flake.nix
Normal file
88
apps/electron/flake.nix
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
description = "Electron Development Environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {nixpkgs, flake-utils, ...}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system; config.allowUnfree = true;};
|
||||
electronRuntimeLibs = with pkgs; [
|
||||
alsa-lib
|
||||
at-spi2-atk
|
||||
at-spi2-core
|
||||
atk
|
||||
cairo
|
||||
cups
|
||||
dbus
|
||||
expat
|
||||
fontconfig
|
||||
freetype
|
||||
gdk-pixbuf
|
||||
glib
|
||||
gtk3
|
||||
libdrm
|
||||
libgbm
|
||||
libglvnd
|
||||
libnotify
|
||||
libpulseaudio
|
||||
libuuid
|
||||
libxkbcommon
|
||||
mesa
|
||||
nspr
|
||||
nss
|
||||
pango
|
||||
pipewire
|
||||
systemd
|
||||
wayland
|
||||
# xorg
|
||||
libX11
|
||||
libXScrnSaver
|
||||
libXcomposite
|
||||
libXcursor
|
||||
libXdamage
|
||||
libXext
|
||||
libXfixes
|
||||
libXi
|
||||
libXrandr
|
||||
libXtst
|
||||
libxcb
|
||||
];
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
nodejs_22
|
||||
corepack_22
|
||||
bun
|
||||
electron
|
||||
pkg-config
|
||||
python3
|
||||
gcc
|
||||
gnumake
|
||||
git
|
||||
jq
|
||||
patchelf
|
||||
dpkg
|
||||
rpm
|
||||
fpm
|
||||
] ++ electronRuntimeLibs;
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath electronRuntimeLibs}:$LD_LIBRARY_PATH"
|
||||
export ELECTRON_ENABLE_LOGGING=1
|
||||
export ELECTRON_OZONE_PLATFORM_HINT="''${ELECTRON_OZONE_PLATFORM_HINT:-auto}"
|
||||
export NPM_CONFIG_TARGET_ARCH="''${NPM_CONFIG_TARGET_ARCH:-x64}"
|
||||
export npm_config_build_from_source=true
|
||||
export USE_SYSTEM_FPM=true
|
||||
|
||||
alias electron-install='cd ../.. && bun install'
|
||||
alias electron-build-web='cd ../.. && bun run build:web'
|
||||
alias electron-dev='bun run dev'
|
||||
alias electron-package='bun run package:linux'
|
||||
alias electron-validate='bun run validate'
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
69
apps/electron/package.json
Normal file
69
apps/electron/package.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"name": "@tensamin/electron",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"description": "Tensamin desktop client",
|
||||
"author": "methanium",
|
||||
"homepage": "https://git.methanium.net/tensamin/client",
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist release",
|
||||
"build:web": "cd ../.. && bun run build:web",
|
||||
"build": "tsc -p tsconfig.json && esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --external:electron --outfile=dist/preload/preload.cjs",
|
||||
"dev:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron . --verbose",
|
||||
"dev": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run dev:raw; else bun run dev:raw; fi",
|
||||
"start:raw": "bun run build && electron .",
|
||||
"start": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run start:raw; else bun run start:raw; fi",
|
||||
"package:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --publish never",
|
||||
"package": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:raw; else bun run package:raw; fi",
|
||||
"package:linux:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --linux --publish never",
|
||||
"package:linux": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:linux:raw; else bun run package:linux:raw; fi",
|
||||
"package:windows:raw": "cd ../.. && bun run build:web && cd apps/electron && bun run build && electron-builder --win --publish never",
|
||||
"package:windows": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run package:windows:raw; else bun run package:windows:raw; fi",
|
||||
"checksum": "bun scripts/generate-release-metadata.ts",
|
||||
"generate-signing-key": "bun scripts/generate-signing-key.ts",
|
||||
"validate:raw": "bun run build && bun run package:linux:raw",
|
||||
"validate": "if command -v nix >/dev/null 2>&1 && [ -z \"$IN_NIX_SHELL\" ]; then nix develop --command bun run validate:raw; else bun run validate:raw; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"esbuild": "^0.25.11",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "net.tensamin.client",
|
||||
"productName": "Tensamin",
|
||||
"artifactName": "Tensamin-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../web/dist",
|
||||
"to": "web"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb", "rpm"],
|
||||
"category": "Network",
|
||||
"maintainer": "methanium"
|
||||
},
|
||||
"win": {
|
||||
"target": ["nsis", "portable"]
|
||||
},
|
||||
"mac": {
|
||||
"target": ["dmg"]
|
||||
},
|
||||
"publish": null
|
||||
}
|
||||
}
|
||||
62
apps/electron/scripts/generate-release-metadata.ts
Normal file
62
apps/electron/scripts/generate-release-metadata.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { createReadStream, existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import rootPackage from "../../../package.json" with { type: "json" };
|
||||
|
||||
const releaseDir = join(import.meta.dir, "..", "release");
|
||||
const outDir = join(import.meta.dir, "..", "..", "..", "releases");
|
||||
|
||||
function sha256(filePath: string) {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
function platformFor(file: string) {
|
||||
if (/win|nsis|portable|\.exe$/i.test(file)) return "windows";
|
||||
if (/mac|darwin|\.dmg$/i.test(file)) return "macos";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
function archFor(file: string) {
|
||||
if (/arm64|aarch64/i.test(file)) return "arm64";
|
||||
return "x64";
|
||||
}
|
||||
|
||||
if (!existsSync(releaseDir)) {
|
||||
throw new Error(`Missing Electron release directory: ${releaseDir}`);
|
||||
}
|
||||
|
||||
const files = readdirSync(releaseDir)
|
||||
.filter((file) => !file.endsWith(".blockmap") && !file.endsWith(".yml"))
|
||||
.map((file) => join(releaseDir, file))
|
||||
.filter((file) => statSync(file).isFile());
|
||||
|
||||
const artifacts = await Promise.all(
|
||||
files.map(async (filePath) => ({
|
||||
name: basename(filePath),
|
||||
platform: platformFor(filePath),
|
||||
arch: archFor(filePath),
|
||||
url: `__FORGEJO_RELEASE_ASSET_URL__/${encodeURIComponent(basename(filePath))}`,
|
||||
sha256: await sha256(filePath),
|
||||
size: statSync(filePath).size,
|
||||
})),
|
||||
);
|
||||
|
||||
const metadata = {
|
||||
version: rootPackage.version,
|
||||
tag: rootPackage.version,
|
||||
publishedAt: new Date().toISOString(),
|
||||
artifacts,
|
||||
};
|
||||
|
||||
writeFileSync(join(outDir, "electron-release-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
join(outDir, "SHA256SUMS"),
|
||||
`${artifacts.map((artifact) => `${artifact.sha256} ${artifact.name}`).join("\n")}\n`,
|
||||
);
|
||||
11
apps/electron/scripts/generate-signing-key.ts
Normal file
11
apps/electron/scripts/generate-signing-key.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
|
||||
console.log("TENSAMIN_UPDATE_PRIVATE_KEY_PEM=");
|
||||
console.log(privateKey.trim());
|
||||
console.log("\nTENSAMIN_UPDATE_PUBLIC_KEY_PEM=");
|
||||
console.log(publicKey.trim());
|
||||
274
apps/electron/src/main/main.ts
Normal file
274
apps/electron/src/main/main.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { app, BrowserWindow, desktopCapturer, ipcMain, session, shell } from "electron";
|
||||
import { checkForUpdates } from "./updates.js";
|
||||
import { ipcChannels, type DesktopScreenShareCapabilities } from "../shared/ipc.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const verbose = process.argv.includes("--verbose");
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let selectedScreenShareSourceId: string | null = null;
|
||||
|
||||
if (verbose) {
|
||||
app.commandLine.appendSwitch("enable-logging", "stderr");
|
||||
app.commandLine.appendSwitch("v", "1");
|
||||
app.commandLine.appendSwitch("log-level", "0");
|
||||
}
|
||||
|
||||
if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland" && !process.env.TENSAMIN_ENABLE_VULKAN) {
|
||||
app.commandLine.appendSwitch("disable-features", "Vulkan");
|
||||
}
|
||||
|
||||
function verboseLog(...args: unknown[]) {
|
||||
if (verbose) {
|
||||
console.log("[tensamin:electron]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function getRendererIndex() {
|
||||
if (!app.isPackaged) {
|
||||
return resolve(__dirname, "../../../web/dist/index.html");
|
||||
}
|
||||
|
||||
return join(process.resourcesPath, "web", "index.html");
|
||||
}
|
||||
|
||||
function getPlatform(): DesktopScreenShareCapabilities["platform"] {
|
||||
if (process.platform === "linux") return "linux";
|
||||
if (process.platform === "darwin") return "macos";
|
||||
if (process.platform === "win32") return "windows";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getScreenShareCapabilities(): DesktopScreenShareCapabilities {
|
||||
const platform = getPlatform();
|
||||
|
||||
return {
|
||||
runtime: "electron",
|
||||
platform,
|
||||
showAudioOutputSelector: platform === "linux",
|
||||
showAudioSwitch: platform === "windows" || platform === "macos",
|
||||
hasReliableSystemAudio: platform === "windows",
|
||||
};
|
||||
}
|
||||
|
||||
function execJson(command: string, args: string[]) {
|
||||
verboseLog("exec", command, args.join(" "));
|
||||
|
||||
return new Promise<unknown>((resolvePromise, reject) => {
|
||||
execFile(command, args, { timeout: 3000 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr.trim() || error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePromise(JSON.parse(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listAudioOutputs() {
|
||||
verboseLog("listAudioOutputs", { platform: process.platform });
|
||||
|
||||
if (process.platform !== "linux") return [];
|
||||
|
||||
const sinks = await execJson("pactl", ["--format=json", "list", "sinks"]);
|
||||
if (!Array.isArray(sinks)) return [];
|
||||
|
||||
return sinks
|
||||
.map((sink) => {
|
||||
if (!sink || typeof sink !== "object") return null;
|
||||
const record = sink as Record<string, unknown>;
|
||||
const id = record.index == null ? undefined : String(record.index);
|
||||
const name = typeof record.description === "string" ? record.description : id;
|
||||
if (!id || !name) return null;
|
||||
return { id, name, isDefault: false };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function listScreenShareSources() {
|
||||
verboseLog("listScreenShareSources");
|
||||
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
kind: source.id.startsWith("screen:") ? "screen" : "window",
|
||||
name: source.name,
|
||||
subtitle: source.id,
|
||||
thumbnail: source.thumbnail.isEmpty() ? null : source.thumbnail.toDataURL(),
|
||||
}));
|
||||
}
|
||||
|
||||
function registerDisplayMediaHandler() {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
|
||||
verboseLog("display media request", { selectedScreenShareSourceId });
|
||||
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 0, height: 0 },
|
||||
});
|
||||
|
||||
const selected = sources.find((source) => source.id === selectedScreenShareSourceId);
|
||||
selectedScreenShareSourceId = null;
|
||||
const video = selected ?? sources[0];
|
||||
|
||||
if (!video) {
|
||||
callback({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
callback({ video, audio: "loopback" });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({ video });
|
||||
});
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
verboseLog("registering ipc handlers");
|
||||
|
||||
ipcMain.handle(ipcChannels.listScreenShareSources, listScreenShareSources);
|
||||
ipcMain.handle(ipcChannels.listScreenShareAudioOutputs, listAudioOutputs);
|
||||
ipcMain.handle(ipcChannels.getScreenShareCapabilities, getScreenShareCapabilities);
|
||||
ipcMain.handle(ipcChannels.selectScreenShareSource, (_event, sourceId: unknown) => {
|
||||
if (typeof sourceId !== "string" || sourceId.length === 0 || sourceId.length > 256) {
|
||||
throw new Error("Invalid screen share source id.");
|
||||
}
|
||||
|
||||
selectedScreenShareSourceId = sourceId;
|
||||
verboseLog("selected screen share source", sourceId);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
||||
ipcMain.handle(ipcChannels.minimizeWindow, () => {
|
||||
verboseLog("window:minimize");
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
ipcMain.handle(ipcChannels.maximizeWindow, () => {
|
||||
verboseLog("window:maximize");
|
||||
if (!mainWindow) return;
|
||||
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.maximize();
|
||||
});
|
||||
ipcMain.handle(ipcChannels.closeWindow, () => {
|
||||
verboseLog("window:close");
|
||||
mainWindow?.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const rendererIndex = getRendererIndex();
|
||||
verboseLog("creating main window", {
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
isPackaged: app.isPackaged,
|
||||
rendererIndex,
|
||||
argv: process.argv,
|
||||
});
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: "Tensamin",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
verboseLog("blocked window open", url);
|
||||
void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
mainWindow.webContents.on("console-message", (_event, level, message, line, sourceId) => {
|
||||
const target = level >= 2 ? console.error : console.log;
|
||||
target("[tensamin:renderer]", message, { level, line, sourceId });
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
|
||||
console.error("[tensamin:electron] renderer failed to load", {
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
});
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("did-finish-load", () => {
|
||||
verboseLog("renderer finished loading", mainWindow?.webContents.getURL());
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
console.error("[tensamin:electron] renderer process gone", details);
|
||||
});
|
||||
|
||||
mainWindow.on("unresponsive", () => {
|
||||
console.error("[tensamin:electron] main window became unresponsive");
|
||||
});
|
||||
}
|
||||
|
||||
await mainWindow.loadFile(rendererIndex);
|
||||
}
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
verboseLog("window-all-closed");
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
verboseLog("activate");
|
||||
if (BrowserWindow.getAllWindows().length === 0) void createWindow();
|
||||
});
|
||||
|
||||
if (verbose) {
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error("[tensamin:electron] uncaught exception", error);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("[tensamin:electron] unhandled rejection", reason);
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
verboseLog("waiting for app readiness");
|
||||
await app.whenReady();
|
||||
verboseLog("app ready");
|
||||
registerIpc();
|
||||
registerDisplayMediaHandler();
|
||||
await createWindow();
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
console.error("[tensamin:electron] failed to start", error);
|
||||
app.exit(1);
|
||||
});
|
||||
131
apps/electron/src/main/updates.ts
Normal file
131
apps/electron/src/main/updates.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { app, net } from "electron";
|
||||
import type { ReleaseArtifact, ReleaseMetadata, UpdateCheckResult } from "../shared/ipc.js";
|
||||
|
||||
const metadataUrl = process.env.TENSAMIN_UPDATE_METADATA_URL;
|
||||
|
||||
function compareSemver(left: string, right: string) {
|
||||
const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||
const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function platformName() {
|
||||
if (process.platform === "win32") return "windows";
|
||||
if (process.platform === "darwin") return "macos";
|
||||
if (process.platform === "linux") return "linux";
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
function archName() {
|
||||
if (process.arch === "x64") return "x64";
|
||||
if (process.arch === "arm64") return "arm64";
|
||||
return process.arch;
|
||||
}
|
||||
|
||||
function requestText(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = net.request(url);
|
||||
request.on("response", (response) => {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(`Update request failed with HTTP ${response.statusCode}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function requestBuffer(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = net.request(url);
|
||||
request.on("response", (response) => {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
reject(new Error(`Download failed with HTTP ${response.statusCode}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function sha256File(filePath: string) {
|
||||
const hash = createHash("sha256");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function selectArtifact(metadata: ReleaseMetadata): ReleaseArtifact | undefined {
|
||||
const platform = platformName();
|
||||
const arch = archName();
|
||||
|
||||
return metadata.artifacts.find(
|
||||
(artifact) => artifact.platform === platform && artifact.arch === arch,
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
||||
const currentVersion = app.getVersion();
|
||||
|
||||
if (!metadataUrl) {
|
||||
return { available: false, currentVersion, latestVersion: currentVersion };
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(await requestText(metadataUrl)) as ReleaseMetadata;
|
||||
const artifact = selectArtifact(metadata);
|
||||
|
||||
if (!artifact || compareSemver(metadata.version, currentVersion) <= 0) {
|
||||
return { available: false, currentVersion, latestVersion: metadata.version };
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
currentVersion,
|
||||
latestVersion: metadata.version,
|
||||
artifact,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadVerifiedArtifact(artifact: ReleaseArtifact) {
|
||||
const updatesDir = join(app.getPath("userData"), "updates");
|
||||
await rm(updatesDir, { recursive: true, force: true });
|
||||
await mkdir(updatesDir, { recursive: true });
|
||||
|
||||
const destination = join(updatesDir, basename(artifact.name));
|
||||
await writeFile(destination, await requestBuffer(artifact.url), { mode: 0o600 });
|
||||
|
||||
const actualHash = await sha256File(destination);
|
||||
if (actualHash !== artifact.sha256) {
|
||||
await rm(destination, { force: true });
|
||||
throw new Error("Downloaded update failed checksum verification.");
|
||||
}
|
||||
|
||||
return destination;
|
||||
}
|
||||
43
apps/electron/src/preload/preload.ts
Normal file
43
apps/electron/src/preload/preload.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js";
|
||||
|
||||
function windowAction(channel: string) {
|
||||
return () => ipcRenderer.invoke(channel);
|
||||
}
|
||||
|
||||
const desktopApi = {
|
||||
media: {
|
||||
listScreenShareSources: () =>
|
||||
ipcRenderer.invoke(ipcChannels.listScreenShareSources),
|
||||
listScreenShareAudioOutputs: () =>
|
||||
ipcRenderer.invoke(ipcChannels.listScreenShareAudioOutputs),
|
||||
getScreenShareCapabilities: () =>
|
||||
ipcRenderer.invoke(ipcChannels.getScreenShareCapabilities),
|
||||
selectScreenShareSource: (sourceId: DesktopScreenShareSource["id"]) => {
|
||||
if (typeof sourceId !== "string" || sourceId.length === 0) {
|
||||
return Promise.reject(new Error("Invalid screen share source id."));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke(ipcChannels.selectScreenShareSource, sourceId);
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getVersion: () => ipcRenderer.invoke(ipcChannels.getVersion),
|
||||
},
|
||||
updates: {
|
||||
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
||||
},
|
||||
window: {
|
||||
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
||||
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
||||
close: () => windowAction(ipcChannels.closeWindow),
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("tensaminDesktop", desktopApi);
|
||||
contextBridge.exposeInMainWorld(
|
||||
"tensaminShowWindowControls",
|
||||
process.env.TENSAMIN_HIDE_CONTROLS == null,
|
||||
);
|
||||
|
||||
export type TensaminDesktopApi = typeof desktopApi;
|
||||
58
apps/electron/src/shared/ipc.ts
Normal file
58
apps/electron/src/shared/ipc.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export type DesktopScreenShareSource = {
|
||||
id: string;
|
||||
kind: "screen" | "window";
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
|
||||
export type DesktopScreenShareAudioOutput = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type DesktopScreenShareCapabilities = {
|
||||
runtime: "electron";
|
||||
platform: "linux" | "macos" | "windows" | "other";
|
||||
showAudioOutputSelector: boolean;
|
||||
showAudioSwitch: boolean;
|
||||
hasReliableSystemAudio: boolean;
|
||||
};
|
||||
|
||||
export type ReleaseArtifact = {
|
||||
name: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
url: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type ReleaseMetadata = {
|
||||
version: string;
|
||||
tag: string;
|
||||
publishedAt: string;
|
||||
artifacts: ReleaseArtifact[];
|
||||
};
|
||||
|
||||
export type UpdateCheckResult =
|
||||
| { available: false; currentVersion: string; latestVersion: string }
|
||||
| {
|
||||
available: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
artifact: ReleaseArtifact;
|
||||
};
|
||||
|
||||
export const ipcChannels = {
|
||||
listScreenShareSources: "desktopMedia:listScreenShareSources",
|
||||
listScreenShareAudioOutputs: "desktopMedia:listScreenShareAudioOutputs",
|
||||
getScreenShareCapabilities: "desktopMedia:getScreenShareCapabilities",
|
||||
selectScreenShareSource: "desktopMedia:selectScreenShareSource",
|
||||
minimizeWindow: "window:minimize",
|
||||
maximizeWindow: "window:maximize",
|
||||
closeWindow: "window:close",
|
||||
getVersion: "app:getVersion",
|
||||
checkForUpdates: "updates:checkForUpdates",
|
||||
} as const;
|
||||
14
apps/electron/tsconfig.json
Normal file
14
apps/electron/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["node", "electron"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
description = "Tauri development environment";
|
||||
description = "Tauri mobile development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
|
@ -53,34 +53,6 @@
|
|||
];
|
||||
};
|
||||
|
||||
desktopRuntimeLibs = with pkgs; [
|
||||
glib
|
||||
gtk3
|
||||
gdk-pixbuf
|
||||
pango
|
||||
cairo
|
||||
atk
|
||||
at-spi2-atk
|
||||
alsa-lib
|
||||
dbus
|
||||
cups
|
||||
expat
|
||||
libdrm
|
||||
libgbm
|
||||
libglvnd
|
||||
mesa
|
||||
libxkbcommon
|
||||
libx11
|
||||
libxcomposite
|
||||
libxdamage
|
||||
libxext
|
||||
libxfixes
|
||||
libxrandr
|
||||
libxcb
|
||||
systemd
|
||||
nspr
|
||||
nss
|
||||
];
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs;
|
||||
|
|
@ -91,7 +63,6 @@
|
|||
nodejs
|
||||
pkg-config
|
||||
]
|
||||
++ desktopRuntimeLibs
|
||||
++ [
|
||||
android.androidsdk
|
||||
pkgs.android-studio-tools
|
||||
|
|
@ -130,7 +101,6 @@
|
|||
export NDK_HOME="$ANDROID_NDK_ROOT"
|
||||
export NDK_PATH="$ANDROID_NDK_ROOT"
|
||||
export JAVA_HOME="${pkgs.jdk17}"
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath desktopRuntimeLibs}:$LD_LIBRARY_PATH"
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./context": {
|
||||
"types": "./src/context.tsx",
|
||||
"default": "./src/context.tsx"
|
||||
},
|
||||
"./controls": {
|
||||
"types": "./src/windowControls.tsx",
|
||||
"default": "./src/windowControls.tsx"
|
||||
|
|
@ -26,10 +22,6 @@
|
|||
"build:mobile:raw": "tauri android build",
|
||||
"dev:mobile": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:mobile:raw; else bun dev:mobile:raw; fi",
|
||||
"build:mobile": "bun run render-version.ts && if command -v nix >/dev/null 2>&1; then nix develop --command bun build:mobile:raw; else bun build:mobile:raw; fi && bun run render-version.ts --unrender",
|
||||
"dev:desktop:raw": "tauri dev",
|
||||
"build:desktop:raw": "tauri build",
|
||||
"dev:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun dev:desktop:raw; else bun dev:desktop:raw; fi",
|
||||
"build:desktop": "if command -v nix >/dev/null 2>&1; then nix develop --command bun build:desktop:raw; else bun build:desktop:raw; fi",
|
||||
"gen-icons": "tauri icon ./logo.json",
|
||||
"format": "bunx prettier --write .",
|
||||
"lint": "eslint src"
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# Outputs
|
||||
|
||||
- ./src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
|
||||
- ./src-tauri/target/release/bundle/deb/Tensamin*{version}*{arch}.deb
|
||||
- ./src-tauri/target/release/bundle/rpm/Tensamin-{version}-1.{arch}.rpm
|
||||
|
|
@ -18,36 +18,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-deep-link = "2"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))'.dependencies]
|
||||
xcap = "0.4.1"
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies.tauri]
|
||||
version = "2"
|
||||
features = []
|
||||
default-features = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies.tauri]
|
||||
version = "2"
|
||||
features = ["compression", "common-controls-v6", "dynamic-acl"]
|
||||
default-features = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies.tauri]
|
||||
version = "2"
|
||||
features = ["common-controls-v6", "cef", "compression", "dynamic-acl", "x11"]
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.tauri]
|
||||
version = "2"
|
||||
features = ["x11", "common-controls-v6", "cef", "compression", "dynamic-acl"]
|
||||
default-features = false
|
||||
|
||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||
tauri-plugin-barcode-scanner = "2"
|
||||
tauri-plugin-app-events = "0.2"
|
||||
|
|
|
|||
|
|
@ -1,204 +1,7 @@
|
|||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ScreenShareSource {
|
||||
id: String,
|
||||
kind: String,
|
||||
name: String,
|
||||
subtitle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ScreenShareAudioOutput {
|
||||
id: String,
|
||||
name: String,
|
||||
is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ScreenShareCapabilities {
|
||||
platform: String,
|
||||
show_audio_output_selector: bool,
|
||||
show_audio_switch: bool,
|
||||
has_reliable_system_audio: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_screen_share_sources() -> Result<Vec<ScreenShareSource>, String> {
|
||||
#[cfg(any(target_os = "linux", target_os = "windows", target_os = "macos"))]
|
||||
{
|
||||
use xcap::{Monitor, Window};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
match Monitor::all() {
|
||||
Ok(monitors) => {
|
||||
for (index, monitor) in monitors.into_iter().enumerate() {
|
||||
let mut subtitle = None;
|
||||
|
||||
if monitor.is_primary().unwrap_or(false) {
|
||||
subtitle = Some("Primary display".to_string());
|
||||
}
|
||||
|
||||
sources.push(ScreenShareSource {
|
||||
id: format!("screen:{index}"),
|
||||
kind: "screen".to_string(),
|
||||
name: monitor
|
||||
.name()
|
||||
.unwrap_or_else(|_| format!("Display {}", index + 1)),
|
||||
subtitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => errors.push(format!("display listing failed: {error}")),
|
||||
}
|
||||
|
||||
match Window::all() {
|
||||
Ok(windows) => {
|
||||
for (index, window) in windows.into_iter().enumerate() {
|
||||
if window.is_minimized().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = window.title().unwrap_or_default();
|
||||
|
||||
if title.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
sources.push(ScreenShareSource {
|
||||
id: format!("window:{index}"),
|
||||
kind: "window".to_string(),
|
||||
name: title,
|
||||
subtitle: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => errors.push(format!("window listing failed: {error}")),
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
if errors.is_empty() {
|
||||
return Ok(sources);
|
||||
}
|
||||
|
||||
return Err(errors.join("; "));
|
||||
}
|
||||
|
||||
return Ok(sources);
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_audio_outputs() -> Result<Vec<ScreenShareAudioOutput>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use serde_json::Value;
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("pactl")
|
||||
.args(["--format=json", "list", "sinks"])
|
||||
.output()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||
}
|
||||
|
||||
let default_sink = Command::new("pactl")
|
||||
.arg("get-default-sink")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|result| result.status.success())
|
||||
.map(|result| String::from_utf8_lossy(&result.stdout).trim().to_string());
|
||||
|
||||
let sinks: Value = serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
|
||||
let sink_entries = sinks
|
||||
.as_array()
|
||||
.ok_or_else(|| "Unexpected pactl sink response".to_string())?;
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
|
||||
for sink in sink_entries {
|
||||
let Some(index) = sink.get("index").and_then(Value::as_i64) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(name) = sink.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let description = sink
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
sink.get("properties")
|
||||
.and_then(|properties| properties.get("device.description"))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or(name)
|
||||
.to_string();
|
||||
|
||||
let is_default = default_sink.as_deref() == Some(name);
|
||||
|
||||
outputs.push(ScreenShareAudioOutput {
|
||||
id: index.to_string(),
|
||||
name: description,
|
||||
is_default,
|
||||
});
|
||||
}
|
||||
|
||||
outputs.sort_by_key(|output| !output.is_default);
|
||||
|
||||
return Ok(outputs);
|
||||
}
|
||||
|
||||
#[allow(unreachable_code)]
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_screen_share_capabilities() -> ScreenShareCapabilities {
|
||||
ScreenShareCapabilities {
|
||||
platform: if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
.to_string(),
|
||||
show_audio_output_selector: cfg!(target_os = "linux"),
|
||||
show_audio_switch: cfg!(any(target_os = "windows", target_os = "macos")),
|
||||
has_reliable_system_audio: cfg!(target_os = "windows"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
let builder = builder.command_line_args([
|
||||
("enable-media-stream", None::<String>),
|
||||
("enable-usermedia-screen-capturing", None::<String>),
|
||||
("allow-http-screen-capture", None::<String>),
|
||||
#[cfg(target_os = "linux")]
|
||||
(
|
||||
"enable-features",
|
||||
Some("WebRTCPipeWireCapturer".to_string()),
|
||||
),
|
||||
]);
|
||||
|
||||
let builder = builder
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_opener::init());
|
||||
|
|
@ -225,11 +28,6 @@ pub fn run() {
|
|||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_screen_share_sources,
|
||||
list_audio_outputs,
|
||||
get_screen_share_capabilities
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
{
|
||||
eprintln!("error while running tauri application: {error}");
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "deb", "rpm", "nsis", "msi", "dmg"],
|
||||
"targets": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
@ -37,9 +37,6 @@
|
|||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["tensamin"]
|
||||
},
|
||||
"mobile": [
|
||||
{
|
||||
"scheme": ["tensamin"],
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
export type DesktopScreenShareSource = {
|
||||
id: string;
|
||||
kind: "screen" | "window";
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
};
|
||||
|
||||
export type DesktopScreenShareAudioOutput = {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export type DesktopScreenShareCapabilities = {
|
||||
platform: "linux" | "macos" | "windows" | "other";
|
||||
showAudioOutputSelector: boolean;
|
||||
showAudioSwitch: boolean;
|
||||
hasReliableSystemAudio: boolean;
|
||||
};
|
||||
|
||||
type DesktopMediaContextValue = {
|
||||
getScreenShareCapabilities: () => Promise<DesktopScreenShareCapabilities>;
|
||||
listScreenShareSources: () => Promise<DesktopScreenShareSource[]>;
|
||||
listScreenShareAudioOutputs: () => Promise<DesktopScreenShareAudioOutput[]>;
|
||||
};
|
||||
|
||||
const defaultCapabilities: DesktopScreenShareCapabilities = {
|
||||
platform: "other",
|
||||
showAudioOutputSelector: false,
|
||||
showAudioSwitch: false,
|
||||
hasReliableSystemAudio: false,
|
||||
};
|
||||
|
||||
const desktopMediaContext = createContext<DesktopMediaContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
async function listScreenShareSources(): Promise<DesktopScreenShareSource[]> {
|
||||
if (!isTauri()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return invoke<DesktopScreenShareSource[]>("list_screen_share_sources");
|
||||
}
|
||||
|
||||
async function listScreenShareAudioOutputs(): Promise<
|
||||
DesktopScreenShareAudioOutput[]
|
||||
> {
|
||||
if (!isTauri()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return invoke<DesktopScreenShareAudioOutput[]>(
|
||||
"list_screen_share_audio_outputs",
|
||||
);
|
||||
}
|
||||
|
||||
async function getScreenShareCapabilities(): Promise<DesktopScreenShareCapabilities> {
|
||||
if (!isTauri()) {
|
||||
return defaultCapabilities;
|
||||
}
|
||||
|
||||
return invoke<DesktopScreenShareCapabilities>(
|
||||
"get_screen_share_capabilities",
|
||||
);
|
||||
}
|
||||
|
||||
export function useDesktopMedia() {
|
||||
const value = useContext(desktopMediaContext);
|
||||
|
||||
if (!value) {
|
||||
throw new Error("useDesktopMedia must be used within the desktop provider");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function Provider({ children }: { children: ReactNode }) {
|
||||
const value = useMemo<DesktopMediaContextValue>(
|
||||
() => ({
|
||||
getScreenShareCapabilities,
|
||||
listScreenShareSources,
|
||||
listScreenShareAudioOutputs,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<desktopMediaContext.Provider value={value}>
|
||||
{children}
|
||||
</desktopMediaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
RouterProvider,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createHashHistory,
|
||||
createRouter,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||
import Storage from "@tensamin/storage/context";
|
||||
import Session from "@tensamin/storage/session";
|
||||
import Crypto from "@tensamin/crypto/context";
|
||||
import Mobile from "@tensamin/tauri/context";
|
||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||
|
||||
import LegalWrapper from "@/features/legal/screen";
|
||||
|
||||
|
|
@ -126,9 +127,9 @@ function RootShell() {
|
|||
<LoginWrapper>
|
||||
<LegalWrapper>
|
||||
<Crypto>
|
||||
<Mobile>
|
||||
<DesktopMediaProvider>
|
||||
<Outlet />
|
||||
</Mobile>
|
||||
</DesktopMediaProvider>
|
||||
</Crypto>
|
||||
</LegalWrapper>
|
||||
</LoginWrapper>
|
||||
|
|
@ -271,6 +272,8 @@ const routeTree = rootRoute.addChildren([
|
|||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
history:
|
||||
window.location.protocol === "file:" ? createHashHistory() : undefined,
|
||||
defaultNotFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ function deepFilterAssetHeaders(rootDir: string): Plugin {
|
|||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
clearScreen: false,
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue