Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn, spawnSync } from "node:child_process";
import { watch } from "node:fs";
import * as NodeOS from "node:os";
import { join } from "node:path";

import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs";
Expand Down Expand Up @@ -29,6 +30,8 @@ const forcedShutdownTimeoutMs = 1_500;
const restartDebounceMs = 120;
const childTreeGracePeriodMs = 1_200;
const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
const hostPlatform = NodeOS.platform();

await waitForResources({
baseDir: desktopDir,
Expand All @@ -53,15 +56,15 @@ const expectedExits = new WeakSet();
const watchers = [];

function killChildTreeByPid(pid, signal) {
if (process.platform === "win32" || typeof pid !== "number") {
if (hostPlatform === "win32" || typeof pid !== "number") {
return;
}

spawnSync("pkill", [`-${signal}`, "-P", String(pid)], { stdio: "ignore" });
}

function cleanupStaleDevApps() {
if (process.platform === "win32") {
if (hostPlatform === "win32") {
return;
}

Expand Down Expand Up @@ -189,7 +192,7 @@ function startWatchers() {
}

function killChildTree(signal) {
if (process.platform === "win32") {
if (hostPlatform === "win32") {
return;
}

Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/scripts/electron-launcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import * as NodeOS from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { ensureElectronRuntime } from "./ensure-electron-runtime.mjs";
Expand All @@ -33,6 +34,8 @@ const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"];
const LAUNCHER_VERSION = 10;
const defaultIconPath = join(desktopDir, "resources", "icon.icns");
const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher script has no Effect runtime.
const hostPlatform = NodeOS.platform();

function resolveDevelopmentProtocolCallbackPort() {
const configuredPort = Number.parseInt(process.env.T3CODE_PORT ?? "", 10);
Expand Down Expand Up @@ -313,15 +316,15 @@ export function resolveElectronPath() {
const require = createRequire(import.meta.url);
const electronBinaryPath = require("electron");

if (process.platform !== "darwin") {
if (hostPlatform !== "darwin") {
return electronBinaryPath;
}

return buildMacLauncher(electronBinaryPath);
}

export function resolveDevProtocolClient() {
if (process.platform !== "darwin" || !isDevelopment) {
if (hostPlatform !== "darwin" || !isDevelopment) {
return null;
}

Expand Down
24 changes: 14 additions & 10 deletions apps/desktop/scripts/ensure-electron-runtime.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { arch, platform, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { spawnSync } from "node:child_process";

const require = createRequire(import.meta.url);
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime.
const hostPlatform = platform();
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone repair script has no Effect runtime.
const hostArch = arch();

function getPlatformPath() {
switch (process.platform) {
switch (hostPlatform) {
case "darwin":
return "Electron.app/Contents/MacOS/Electron";
case "freebsd":
Expand All @@ -17,12 +21,12 @@ function getPlatformPath() {
case "win32":
return "electron.exe";
default:
throw new Error(`Electron builds are not available on platform: ${process.platform}`);
throw new Error(`Electron builds are not available on platform: ${hostPlatform}`);
}
}

function ensureExecutable(filePath) {
if (process.platform !== "win32") {
if (hostPlatform !== "win32") {
chmodSync(filePath, 0o755);
}
}
Expand All @@ -39,7 +43,7 @@ function repairPathFile(electronDir, platformPath) {
function getRequiredRuntimePaths(electronDir, platformPath) {
const paths = [join(electronDir, "dist", platformPath)];

if (process.platform === "darwin") {
if (hostPlatform === "darwin") {
paths.push(
join(electronDir, "dist", "Electron.app", "Contents", "Info.plist"),
join(
Expand All @@ -58,7 +62,7 @@ function getRequiredRuntimePaths(electronDir, platformPath) {
}

function isMachO(filePath) {
if (process.platform !== "darwin") {
if (hostPlatform !== "darwin") {
return true;
}

Expand All @@ -76,7 +80,7 @@ function missingRuntimePaths(electronDir, platformPath) {
}

function invalidRuntimePaths(electronDir, platformPath) {
if (process.platform !== "darwin") {
if (hostPlatform !== "darwin") {
return [];
}

Expand Down Expand Up @@ -111,16 +115,16 @@ function runChecked(command, args) {

function installElectronRuntime(electronDir, version) {
const tempDir = mkdtempSync(join(tmpdir(), "t3-electron-"));
const zipPath = join(tempDir, `electron-v${version}-${process.platform}-${process.arch}.zip`);
const zipPath = join(tempDir, `electron-v${version}-${hostPlatform}-${hostArch}.zip`);

try {
runChecked("curl", [
"-fsSL",
`https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${process.platform}-${process.arch}.zip`,
`https://github.com/electron/electron/releases/download/v${version}/electron-v${version}-${hostPlatform}-${hostArch}.zip`,
"-o",
zipPath,
]);
if (process.platform === "darwin") {
if (hostPlatform === "darwin") {
runChecked("ditto", ["-x", "-k", zipPath, join(electronDir, "dist")]);
} else {
runChecked("python3", [
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* (
> {
const fileSystem = yield* FileSystem.FileSystem;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
if (environment.isDevelopment && process.platform === "darwin" && ext === "png") {
if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") {
const developmentDockIconPath = environment.developmentDockIconPath;
const developmentDockIconExists = yield* fileSystem
.exists(developmentDockIconPath)
Expand Down
193 changes: 99 additions & 94 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as Electron from "electron";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

export interface ElectronMenuPosition {
readonly x: number;
Expand Down Expand Up @@ -73,109 +74,113 @@ const normalizePosition = (
({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,
).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));

export const layer = Layer.sync(ElectronMenu, () => {
let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
export const layer = Layer.effect(
ElectronMenu,
Effect.gen(function* () {
const platform = yield* HostProcessPlatform;
let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;

const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
if (process.platform !== "darwin") {
return Option.none();
}
if (destructiveMenuIconCache !== undefined) {
return destructiveMenuIconCache;
}

try {
const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
width: 12,
height: 12,
});
destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
} catch {
destructiveMenuIconCache = Option.none();
}

return destructiveMenuIconCache;
};

const buildTemplate = (
entries: readonly ContextMenuItem[],
complete: (selectedItemId: Option.Option<string>) => void,
): Electron.MenuItemConstructorOptions[] => {
const template: Electron.MenuItemConstructorOptions[] = [];
let hasInsertedDestructiveSeparator = false;

for (const item of entries) {
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
template.push({ type: "separator" });
hasInsertedDestructiveSeparator = true;
const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
if (platform !== "darwin") {
return Option.none();
}

const itemOption: Electron.MenuItemConstructorOptions = {
label: item.label,
enabled: !item.disabled,
};
if (item.children && item.children.length > 0) {
itemOption.submenu = buildTemplate(item.children, complete);
} else {
itemOption.click = () => complete(Option.some(item.id));
if (destructiveMenuIconCache !== undefined) {
return destructiveMenuIconCache;
}
if (item.destructive && (!item.children || item.children.length === 0)) {
const destructiveIcon = getDestructiveMenuIcon();
if (Option.isSome(destructiveIcon)) {
itemOption.icon = destructiveIcon.value;
}

try {
const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
width: 12,
height: 12,
});
destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
} catch {
destructiveMenuIconCache = Option.none();
}

template.push(itemOption);
}
return destructiveMenuIconCache;
};

return template;
};

return ElectronMenu.of({
setApplicationMenu: (template) =>
Effect.sync(() => {
Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
}),
popupTemplate: (input) =>
Effect.sync(() => {
if (input.template.length === 0) {
return;
const buildTemplate = (
entries: readonly ContextMenuItem[],
complete: (selectedItemId: Option.Option<string>) => void,
): Electron.MenuItemConstructorOptions[] => {
const template: Electron.MenuItemConstructorOptions[] = [];
let hasInsertedDestructiveSeparator = false;

for (const item of entries) {
if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
template.push({ type: "separator" });
hasInsertedDestructiveSeparator = true;
}
Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
}),
showContextMenu: (input) =>
Effect.callback<Option.Option<string>>((resume) => {
const normalizedItems = normalizeContextMenuItems(input.items);
if (normalizedItems.length === 0) {
resume(Effect.succeed(Option.none()));
return;

const itemOption: Electron.MenuItemConstructorOptions = {
label: item.label,
enabled: !item.disabled,
};
if (item.children && item.children.length > 0) {
itemOption.submenu = buildTemplate(item.children, complete);
} else {
itemOption.click = () => complete(Option.some(item.id));
}
if (item.destructive && (!item.children || item.children.length === 0)) {
const destructiveIcon = getDestructiveMenuIcon();
if (Option.isSome(destructiveIcon)) {
itemOption.icon = destructiveIcon.value;
}
}

template.push(itemOption);
}

let completed = false;
const complete = (selectedItemId: Option.Option<string>) => {
if (completed) {
return template;
};

return ElectronMenu.of({
setApplicationMenu: (template) =>
Effect.sync(() => {
Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
}),
popupTemplate: (input) =>
Effect.sync(() => {
if (input.template.length === 0) {
return;
}
Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
}),
showContextMenu: (input) =>
Effect.callback<Option.Option<string>>((resume) => {
const normalizedItems = normalizeContextMenuItems(input.items);
if (normalizedItems.length === 0) {
resume(Effect.succeed(Option.none()));
return;
}
completed = true;
resume(Effect.succeed(selectedItemId));
};

const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
callback: () => complete(Option.none()),
}),
onSome: (position): Electron.PopupOptions => ({
window: input.window,
x: position.x,
y: position.y,
callback: () => complete(Option.none()),
}),
});
menu.popup(popupOptions);
}),
});
});
let completed = false;
const complete = (selectedItemId: Option.Option<string>) => {
if (completed) {
return;
}
completed = true;
resume(Effect.succeed(selectedItemId));
};

const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
const popupPosition = normalizePosition(input.position);
const popupOptions = Option.match(popupPosition, {
onNone: (): Electron.PopupOptions => ({
window: input.window,
callback: () => complete(Option.none()),
}),
onSome: (position): Electron.PopupOptions => ({
window: input.window,
x: position.x,
y: position.y,
callback: () => complete(Option.none()),
}),
});
menu.popup(popupOptions);
}),
});
}),
);
Loading
Loading