(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
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;
|
||||
Loading…
Reference in a new issue