Skip to content

Commit a43fcef

Browse files
committed
feat: initial addition of tray icon
1 parent e3d46b6 commit a43fcef

4 files changed

Lines changed: 102 additions & 2 deletions

File tree

apps/desktop/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"dependencies": {
1717
"effect": "catalog:",
1818
"electron": "40.6.0",
19-
"electron-updater": "^6.6.2"
19+
"electron-updater": "^6.6.2",
20+
"sharp": "^0.34.5"
2021
},
2122
"devDependencies": {
2223
"@t3tools/contracts": "workspace:*",

apps/desktop/src/main.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
protocol,
1616
shell,
1717
} from "electron";
18-
import type { MenuItemConstructorOptions } from "electron";
18+
import type { MenuItemConstructorOptions, Tray } from "electron";
1919
import * as Effect from "effect/Effect";
2020
import type {
2121
DesktopTheme,
@@ -43,6 +43,7 @@ import {
4343
reduceDesktopUpdateStateOnUpdateAvailable,
4444
} from "./updateMachine";
4545
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
46+
import { createTray } from "./tray";
4647

4748
fixPath();
4849

@@ -79,6 +80,7 @@ const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
7980
type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
8081

8182
let mainWindow: BrowserWindow | null = null;
83+
let tray: Tray | null = null;
8284
let backendProcess: ChildProcess.ChildProcess | null = null;
8385
let backendPort = 0;
8486
let backendAuthToken = "";
@@ -636,6 +638,11 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
636638
return resolveResourcePath(`icon.${ext}`);
637639
}
638640

641+
async function configureTray(): Promise<void> {
642+
// TODO: Add a context menu to the tray
643+
tray = await createTray(Menu.buildFromTemplate([]));
644+
}
645+
639646
/**
640647
* Resolve the Electron userData directory path.
641648
*
@@ -1308,13 +1315,17 @@ async function bootstrap(): Promise<void> {
13081315
writeDesktopLogHeader("bootstrap backend start requested");
13091316
mainWindow = createWindow();
13101317
writeDesktopLogHeader("bootstrap main window created");
1318+
await configureTray();
1319+
writeDesktopLogHeader("bootstrap tray created");
13111320
}
13121321

13131322
app.on("before-quit", () => {
13141323
isQuitting = true;
13151324
writeDesktopLogHeader("before-quit received");
13161325
clearUpdatePollTimer();
13171326
stopBackend();
1327+
tray?.destroy();
1328+
tray = null;
13181329
restoreStdIoCapture?.();
13191330
});
13201331

apps/desktop/src/tray.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import sharp from "sharp";
2+
import { nativeImage, app, Tray, type Menu } from "electron";
3+
4+
// Stolen from the T3Wordmark component in the web app
5+
const T3_WORDMARK_VIEW_BOX = "15.5309 37 94.3941 56.96";
6+
const T3_WORDMARK_PATH_STRING =
7+
"M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z";
8+
9+
const T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X = 1.6; // vertically centering the wordmark looks weird, so we offset it slightly
10+
const TRAY_SIZE_1X = 16;
11+
12+
/**
13+
* Rasterizes an SVG to a square template image.
14+
* @see: https://developer.apple.com/documentation/appkit/nsimage/istemplate
15+
* @param viewBox The viewBox of the SVG to rasterize
16+
* @param path The path of the SVG to rasterize
17+
* @param size The size of the resulting image
18+
* @param opticalYOffset The optical Y offset of the resulting image
19+
* @returns The resulting image as a PNG buffer
20+
*/
21+
async function rasterizeSvgToSquareTemplateImage(
22+
viewBox: string,
23+
path: string,
24+
size: number,
25+
opticalYOffset: number,
26+
) {
27+
// Template images "should consist of only black and clear colors" (see above-linked documentation).
28+
const svg = `<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg"><path d="${path}" fill="black" /></svg>`;
29+
return await sharp(Buffer.from(svg), {
30+
density: 2000,
31+
})
32+
.resize({
33+
width: size,
34+
height: size,
35+
fit: "contain",
36+
position: "centre",
37+
background: { r: 0, g: 0, b: 0, alpha: 0 },
38+
})
39+
.extend({
40+
top: opticalYOffset,
41+
bottom: 0,
42+
left: 0,
43+
right: 0,
44+
background: { r: 0, g: 0, b: 0, alpha: 0 },
45+
})
46+
.extract({ left: 0, top: 0, width: size, height: size })
47+
.png()
48+
.toBuffer();
49+
}
50+
51+
async function createTrayTemplateImage() {
52+
const rasterizeT3Wordmark = async (size: number) => {
53+
const opticalYOffset = Math.max(0, Math.round((T3_TRAY_IMAGE_OPTICAL_Y_OFFSET_1X * size) / TRAY_SIZE_1X));
54+
return await rasterizeSvgToSquareTemplateImage(
55+
T3_WORDMARK_VIEW_BOX,
56+
T3_WORDMARK_PATH_STRING,
57+
size,
58+
opticalYOffset,
59+
);
60+
};
61+
const image = nativeImage.createEmpty();
62+
const addRepresentation = async (scaleFactor: number, size: number) => {
63+
image.addRepresentation({
64+
scaleFactor: scaleFactor,
65+
width: size,
66+
height: size,
67+
buffer: await rasterizeT3Wordmark(size),
68+
});
69+
};
70+
await addRepresentation(1, TRAY_SIZE_1X);
71+
await addRepresentation(2, TRAY_SIZE_1X * 2);
72+
image.setTemplateImage(true);
73+
return image;
74+
}
75+
76+
async function createTray(contextMenu: Menu): Promise<Tray | null> {
77+
// macOS only (for now)
78+
if (process.platform !== "darwin") return null;
79+
80+
const image = await createTrayTemplateImage();
81+
const tray = new Tray(image);
82+
tray.setToolTip(app.getName());
83+
tray.setContextMenu(contextMenu);
84+
return tray;
85+
}
86+
87+
export { createTray };

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)