TTP -> MTP, A lot of other stuff #21
10 changed files with 205 additions and 15 deletions
(feat): improve tray icon
(qol): update todo
commit
943203eeaf
|
|
@ -58,8 +58,12 @@
|
||||||
"to": "web"
|
"to": "web"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "build/icons/icon.png",
|
"from": "build/icons",
|
||||||
"to": "icons/icon.png"
|
"to": "icons",
|
||||||
|
"filter": [
|
||||||
|
"32x32.png",
|
||||||
|
"icon.png"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linux": {
|
"linux": {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ import {
|
||||||
import { checkForUpdates } from "./updates.js";
|
import { checkForUpdates } from "./updates.js";
|
||||||
import {
|
import {
|
||||||
ipcChannels,
|
ipcChannels,
|
||||||
|
type DesktopCallStatus,
|
||||||
type DesktopScreenShareAudioOutput,
|
type DesktopScreenShareAudioOutput,
|
||||||
type DesktopScreenShareCapabilities,
|
type DesktopScreenShareCapabilities,
|
||||||
} from "../shared/ipc.js";
|
} from "../shared/ipc.js";
|
||||||
import { initTray } from "./tray.js";
|
import { initTray, setTrayCallStatus } from "./tray.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const verbose = process.argv.includes("--verbose");
|
const verbose = process.argv.includes("--verbose");
|
||||||
|
|
@ -189,6 +190,25 @@ function registerIpc() {
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||||
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
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, () => {
|
ipcMain.handle(ipcChannels.minimizeWindow, () => {
|
||||||
verboseLog("window:minimize");
|
verboseLog("window:minimize");
|
||||||
mainWindow?.minimize();
|
mainWindow?.minimize();
|
||||||
|
|
@ -311,7 +331,7 @@ async function start() {
|
||||||
verboseLog("app ready");
|
verboseLog("app ready");
|
||||||
registerIpc();
|
registerIpc();
|
||||||
registerDisplayMediaHandler();
|
registerDisplayMediaHandler();
|
||||||
initTray();
|
initTray(() => mainWindow);
|
||||||
await createWindow();
|
await createWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Tray, Menu } from "electron";
|
import { app, BrowserWindow, Menu, nativeImage, Tray } from "electron";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
|
@ -7,19 +7,56 @@ let tray: Tray | null = null;
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
export function initTray() {
|
function getTrayIconPath(filename: string) {
|
||||||
const iconPath = path.join(__dirname, "../../build/icons/32x32.png");
|
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([
|
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.setContextMenu(contextMenu);
|
||||||
|
|
||||||
tray.on("click", (event) => {
|
tray.on("click", () => {
|
||||||
console.log(event);
|
const mainWindow = getMainWindow();
|
||||||
|
if (!mainWindow) return;
|
||||||
|
|
||||||
|
if (mainWindow.isVisible()) {
|
||||||
|
mainWindow.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { contextBridge, ipcRenderer } from "electron";
|
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) {
|
function windowAction(channel: string) {
|
||||||
return () => ipcRenderer.invoke(channel);
|
return () => ipcRenderer.invoke(channel);
|
||||||
|
|
@ -27,6 +31,21 @@ const desktopApi = {
|
||||||
updates: {
|
updates: {
|
||||||
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
|
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: {
|
window: {
|
||||||
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
||||||
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ export type DesktopScreenShareCapabilities = {
|
||||||
hasReliableSystemAudio: boolean;
|
hasReliableSystemAudio: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DesktopCallStatus = {
|
||||||
|
inCall: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
iconDataUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ReleaseArtifact = {
|
export type ReleaseArtifact = {
|
||||||
name: string;
|
name: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
|
@ -55,4 +61,5 @@ export const ipcChannels = {
|
||||||
closeWindow: "window:close",
|
closeWindow: "window:close",
|
||||||
getVersion: "app:getVersion",
|
getVersion: "app:getVersion",
|
||||||
checkForUpdates: "updates:checkForUpdates",
|
checkForUpdates: "updates:checkForUpdates",
|
||||||
|
setCallStatus: "call:setStatus",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import Login from "@/routes/screens/login";
|
||||||
|
|
||||||
import CallPopout from "@tensamin/call/popout";
|
import CallPopout from "@tensamin/call/popout";
|
||||||
import ChatContext from "@tensamin/chat/context";
|
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 { Provider as MTPProvider } from "@tensamin/mtp";
|
||||||
import UserProvider from "@tensamin/user/context";
|
import UserProvider from "@tensamin/user/context";
|
||||||
import DeeplinkContext from "@tensamin/tauri/deeplinkHandler";
|
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() {
|
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({
|
const rootRoute = createRootRoute({
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./store": "./src/store.tsx",
|
"./store": "./src/store.tsx",
|
||||||
|
"./speakingState": "./src/speakingState.ts",
|
||||||
"./screen": "./src/screen.tsx",
|
"./screen": "./src/screen.tsx",
|
||||||
"./utils": "./src/utils.ts",
|
"./utils": "./src/utils.ts",
|
||||||
"./sidebarBox": "./src/components/sidebarBox.tsx",
|
"./sidebarBox": "./src/components/sidebarBox.tsx",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ declare global {
|
||||||
>;
|
>;
|
||||||
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
call?: {
|
||||||
|
setStatus?: (status: {
|
||||||
|
inCall: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
iconDataUrl?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ type ElectronDesktopApi = {
|
||||||
>;
|
>;
|
||||||
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
selectScreenShareSource?: (sourceId: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
call?: {
|
||||||
|
setStatus?: (status: {
|
||||||
|
inCall: boolean;
|
||||||
|
speaking: boolean;
|
||||||
|
iconDataUrl?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
1
todo.md
1
todo.md
|
|
@ -1,4 +1,5 @@
|
||||||
- Move legal to extra onboarding package
|
- Move legal to extra onboarding package
|
||||||
- Add a bunch of tests
|
- Add a bunch of tests
|
||||||
- Add packages/cache/ to handle caching
|
- Add packages/cache/ to handle caching
|
||||||
|
- Add packages/hotkeys/
|
||||||
- Full accessability
|
- Full accessability
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue