diff --git a/apps/electron/package.json b/apps/electron/package.json index f1e948f..f410033 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -58,8 +58,12 @@ "to": "web" }, { - "from": "build/icons/icon.png", - "to": "icons/icon.png" + "from": "build/icons", + "to": "icons", + "filter": [ + "32x32.png", + "icon.png" + ] } ], "linux": { diff --git a/apps/electron/src/main/main.ts b/apps/electron/src/main/main.ts index ba3b5ae..91eaa9b 100644 --- a/apps/electron/src/main/main.ts +++ b/apps/electron/src/main/main.ts @@ -12,10 +12,11 @@ import { import { checkForUpdates } from "./updates.js"; import { ipcChannels, + type DesktopCallStatus, type DesktopScreenShareAudioOutput, type DesktopScreenShareCapabilities, } from "../shared/ipc.js"; -import { initTray } from "./tray.js"; +import { initTray, setTrayCallStatus } from "./tray.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const verbose = process.argv.includes("--verbose"); @@ -189,6 +190,25 @@ function registerIpc() { ); ipcMain.handle(ipcChannels.getVersion, () => app.getVersion()); ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates); + ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => { + if ( + typeof status !== "object" || + status === null || + typeof (status as DesktopCallStatus).inCall !== "boolean" || + typeof (status as DesktopCallStatus).speaking !== "boolean" || + ((status as DesktopCallStatus).iconDataUrl !== undefined && + (typeof (status as DesktopCallStatus).iconDataUrl !== "string" || + !(status as DesktopCallStatus).iconDataUrl?.startsWith( + "data:image/png;base64,", + ) || + (status as DesktopCallStatus).iconDataUrl!.length > 16_384)) + ) { + throw new Error("Invalid call status."); + } + + const { inCall, iconDataUrl } = status as DesktopCallStatus; + setTrayCallStatus(inCall, iconDataUrl); + }); ipcMain.handle(ipcChannels.minimizeWindow, () => { verboseLog("window:minimize"); mainWindow?.minimize(); @@ -311,7 +331,7 @@ async function start() { verboseLog("app ready"); registerIpc(); registerDisplayMediaHandler(); - initTray(); + initTray(() => mainWindow); await createWindow(); } diff --git a/apps/electron/src/main/tray.ts b/apps/electron/src/main/tray.ts index 774f7bf..3937a4d 100644 --- a/apps/electron/src/main/tray.ts +++ b/apps/electron/src/main/tray.ts @@ -1,4 +1,4 @@ -import { Tray, Menu } from "electron"; +import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -7,19 +7,56 @@ let tray: Tray | null = null; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export function initTray() { - const iconPath = path.join(__dirname, "../../build/icons/32x32.png"); +function getTrayIconPath(filename: string) { + if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename); + return path.resolve(__dirname, "../../build/icons", filename); +} - tray = new Tray(iconPath); // keep reference alive +export function setTrayCallStatus( + inCall: boolean, + iconDataUrl?: string, +) { + if (!tray) return; + + if (inCall && iconDataUrl) { + const image = nativeImage.createFromDataURL(iconDataUrl); + if (!image.isEmpty()) { + tray.setImage(image); + return; + } + } + + tray.setImage(getTrayIconPath("32x32.png")); +} + +export function initTray(getMainWindow: () => BrowserWindow | null) { + tray = new Tray(getTrayIconPath("32x32.png")); // keep reference alive const contextMenu = Menu.buildFromTemplate([ - { label: "Restart", type: "normal" }, - { label: "Quit", type: "normal" }, + { + label: "Restart", + type: "normal", + click: () => { + app.relaunch(); + app.quit(); + }, + }, + { label: "Quit", type: "normal", click: () => app.quit() }, ]); tray.setContextMenu(contextMenu); - tray.on("click", (event) => { - console.log(event); + tray.on("click", () => { + const mainWindow = getMainWindow(); + if (!mainWindow) return; + + if (mainWindow.isVisible()) { + mainWindow.hide(); + return; + } + + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); }); } diff --git a/apps/electron/src/preload/preload.ts b/apps/electron/src/preload/preload.ts index ce65ca7..6ecfad5 100644 --- a/apps/electron/src/preload/preload.ts +++ b/apps/electron/src/preload/preload.ts @@ -1,5 +1,9 @@ import { contextBridge, ipcRenderer } from "electron"; -import { ipcChannels, type DesktopScreenShareSource } from "../shared/ipc.js"; +import { + ipcChannels, + type DesktopCallStatus, + type DesktopScreenShareSource, +} from "../shared/ipc.js"; function windowAction(channel: string) { return () => ipcRenderer.invoke(channel); @@ -27,6 +31,21 @@ const desktopApi = { updates: { checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), }, + call: { + setStatus: (status: DesktopCallStatus) => { + if ( + typeof status?.inCall !== "boolean" || + typeof status?.speaking !== "boolean" || + (status.iconDataUrl !== undefined && + (typeof status.iconDataUrl !== "string" || + !status.iconDataUrl.startsWith("data:image/png;base64,"))) + ) { + return Promise.reject(new Error("Invalid call status.")); + } + + return ipcRenderer.invoke(ipcChannels.setCallStatus, status); + }, + }, window: { minimize: () => windowAction(ipcChannels.minimizeWindow), maximize: () => windowAction(ipcChannels.maximizeWindow), diff --git a/apps/electron/src/shared/ipc.ts b/apps/electron/src/shared/ipc.ts index d1754f9..bec8688 100644 --- a/apps/electron/src/shared/ipc.ts +++ b/apps/electron/src/shared/ipc.ts @@ -20,6 +20,12 @@ export type DesktopScreenShareCapabilities = { hasReliableSystemAudio: boolean; }; +export type DesktopCallStatus = { + inCall: boolean; + speaking: boolean; + iconDataUrl?: string; +}; + export type ReleaseArtifact = { name: string; platform: string; @@ -55,4 +61,5 @@ export const ipcChannels = { closeWindow: "window:close", getVersion: "app:getVersion", checkForUpdates: "updates:checkForUpdates", + setCallStatus: "call:setStatus", } as const; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index bbfb2dd..f8c600a 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -23,7 +23,8 @@ import Login from "@/routes/screens/login"; import CallPopout from "@tensamin/call/popout"; import ChatContext from "@tensamin/chat/context"; -import { useInitializeCall } from "@tensamin/call/store"; +import { useCall, useInitializeCall } from "@tensamin/call/store"; +import { useIsSpeaking } from "@tensamin/call/speakingState"; import { Provider as MTPProvider } from "@tensamin/mtp"; import UserProvider from "@tensamin/user/context"; import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; @@ -274,8 +275,94 @@ function AppShell() { ); } +function createCallTrayIcon(color: string, speaking: boolean) { + const canvas = document.createElement("canvas"); + canvas.width = 32; + canvas.height = 32; + + const context = canvas.getContext("2d"); + if (!context) return undefined; + + context.globalAlpha = speaking ? 1 : 0.55; + context.fillStyle = color; + context.beginPath(); + context.arc(16, 16, 13, 0, Math.PI * 2); + context.fill(); + + if (speaking) { + context.globalAlpha = 0.3; + context.fillStyle = "#ffffff"; + context.fill(); + } + + return canvas.toDataURL("image/png"); +} + function CallInit() { - return useInitializeCall(); + useInitializeCall(); + + const { load } = useStorage(); + const { + themeColor, + themePalette, + themePrimaryColor, + themePolarity, + themeTint, + themeCustomCss, + } = useTheme(); + const [localUserId, setLocalUserId] = useState(-1); + const [primaryColor, setPrimaryColor] = useState(""); + const inCall = useCall((state) => state.state === "open"); + const speaking = useIsSpeaking(localUserId); + + useEffect(() => { + let active = true; + + load("user_id").then((userId) => { + if (active) setLocalUserId(userId); + }); + + return () => { + active = false; + }; + }, [load]); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + setPrimaryColor( + getComputedStyle(document.documentElement) + .getPropertyValue("--primary") + .trim(), + ); + }); + + return () => cancelAnimationFrame(frame); + }, [ + themeColor, + themeCustomCss, + themePalette, + themePolarity, + themePrimaryColor, + themeTint, + ]); + + useEffect(() => { + const iconDataUrl = primaryColor + ? createCallTrayIcon(primaryColor, speaking) + : undefined; + + void window.tensaminDesktop?.call + ?.setStatus?.({ + inCall, + speaking: inCall && speaking, + iconDataUrl: inCall ? iconDataUrl : undefined, + }) + .catch((error: unknown) => { + console.error("Failed to update desktop call status", error); + }); + }, [inCall, primaryColor, speaking]); + + return null; } const rootRoute = createRootRoute({ diff --git a/packages/call/package.json b/packages/call/package.json index 79111fb..576caa1 100644 --- a/packages/call/package.json +++ b/packages/call/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { "./store": "./src/store.tsx", + "./speakingState": "./src/speakingState.ts", "./screen": "./src/screen.tsx", "./utils": "./src/utils.ts", "./sidebarBox": "./src/components/sidebarBox.tsx", diff --git a/packages/call/src/screenshare.ts b/packages/call/src/screenshare.ts index 8911a71..5046506 100644 --- a/packages/call/src/screenshare.ts +++ b/packages/call/src/screenshare.ts @@ -50,6 +50,13 @@ declare global { >; selectScreenShareSource?: (sourceId: string) => Promise; }; + call?: { + setStatus?: (status: { + inCall: boolean; + speaking: boolean; + iconDataUrl?: string; + }) => Promise; + }; }; } } diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index 4da0119..ec26bb6 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -31,6 +31,13 @@ type ElectronDesktopApi = { >; selectScreenShareSource?: (sourceId: string) => Promise; }; + call?: { + setStatus?: (status: { + inCall: boolean; + speaking: boolean; + iconDataUrl?: string; + }) => Promise; + }; }; declare global { diff --git a/todo.md b/todo.md index 71b7816..7c62ac9 100644 --- a/todo.md +++ b/todo.md @@ -1,4 +1,5 @@ - Move legal to extra onboarding package - Add a bunch of tests - Add packages/cache/ to handle caching +- Add packages/hotkeys/ - Full accessability