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
25 changes: 25 additions & 0 deletions apps/desktop/resources/installer.nsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
!define T3CODE_DIRECTORY_SHELL_KEY "Software\Classes\Directory\shell\T3Code.OpenInT3Code"
!define T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY "Software\Classes\Directory\Background\shell\T3Code.OpenInT3Code"

!macro WriteT3CodeContextMenuEntries
WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}" "" "Open in T3 Code"
WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
WriteRegStr HKCU "${T3CODE_DIRECTORY_SHELL_KEY}\command" "" "$\"$INSTDIR\${APP_EXECUTABLE_FILENAME}$\" $\"%1$\""

WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}" "" "Open in T3 Code"
WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
WriteRegStr HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}\command" "" "$\"$INSTDIR\${APP_EXECUTABLE_FILENAME}$\" $\"%V$\""
!macroend

!macro DeleteT3CodeContextMenuEntries
DeleteRegKey HKCU "${T3CODE_DIRECTORY_SHELL_KEY}"
DeleteRegKey HKCU "${T3CODE_DIRECTORY_BACKGROUND_SHELL_KEY}"
!macroend

!macro customInstall
!insertmacro WriteT3CodeContextMenuEntries
!macroend

!macro customUnInstall
!insertmacro DeleteT3CodeContextMenuEntries
!macroend
188 changes: 143 additions & 45 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { RotatingFileSink } from "@t3tools/shared/logging";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { fixPath } from "./fixPath";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import { extractProjectLaunchPathFromArgv } from "./windowsProjectLaunch";
import {
createInitialDesktopUpdateState,
reduceDesktopUpdateStateOnCheckFailure,
Expand All @@ -49,6 +50,10 @@ fixPath();
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL =
"desktop:mark-open-project-path-listener-ready";
const GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL = "desktop:get-pending-open-project-paths";
const OPEN_PROJECT_PATH_CHANNEL = "desktop:open-project-path";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
Expand All @@ -75,6 +80,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
const DESKTOP_UPDATE_CHANNEL = "latest";
const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
const SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH = process.platform === "win32";

type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];

Expand All @@ -91,6 +97,8 @@ let aboutCommitHashCache: string | null | undefined;
let desktopLogSink: RotatingFileSink | null = null;
let backendLogSink: RotatingFileSink | null = null;
let restoreStdIoCapture: (() => void) | null = null;
let openProjectPathListenerReady = false;
const pendingOpenProjectPaths: string[] = [];

let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
Expand All @@ -100,6 +108,15 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({
});
const initialUpdateState = (): DesktopUpdateState =>
createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);
const hasSingleInstanceLock =
!SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH || app.requestSingleInstanceLock();

if (SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH) {
const initialProjectLaunchPath = extractProjectLaunchPathFromArgv(process.argv);
if (initialProjectLaunchPath) {
pendingOpenProjectPaths.push(initialProjectLaunchPath);
}
}

function logTimestamp(): string {
return new Date().toISOString();
Expand Down Expand Up @@ -1050,6 +1067,62 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
});
}

function queueOpenProjectPath(path: string): void {
if (pendingOpenProjectPaths.includes(path)) {
return;
}
pendingOpenProjectPaths.push(path);
}

function takePendingOpenProjectPaths(): string[] {
const paths = [...pendingOpenProjectPaths];
pendingOpenProjectPaths.length = 0;
return paths;
}

function revealMainWindow(): void {
const window = mainWindow;
if (!window || window.isDestroyed()) {
return;
}

if (window.isMinimized()) {
window.restore();
}
if (!window.isVisible()) {
window.show();
}
window.focus();
}

function dispatchOpenProjectPath(path: string): void {
const window = mainWindow;
if (
!window ||
window.isDestroyed() ||
!openProjectPathListenerReady ||
window.webContents.isLoadingMainFrame()
) {
queueOpenProjectPath(path);
return;
}

window.webContents.send(OPEN_PROJECT_PATH_CHANNEL, path);
}

function handleOpenProjectPath(path: string): void {
if (!path) {
return;
}

if ((!mainWindow || mainWindow.isDestroyed()) && app.isReady()) {
mainWindow = createWindow();
}

revealMainWindow();
dispatchOpenProjectPath(path);
}

function registerIpcHandlers(): void {
ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
Expand Down Expand Up @@ -1085,6 +1158,14 @@ function registerIpcHandlers(): void {
nativeTheme.themeSource = theme;
});

ipcMain.removeHandler(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL);
ipcMain.handle(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL, async () => {
openProjectPathListenerReady = true;
});

ipcMain.removeHandler(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL);
ipcMain.handle(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL, async () => takePendingOpenProjectPaths());

ipcMain.removeHandler(CONTEXT_MENU_CHANNEL);
ipcMain.handle(
CONTEXT_MENU_CHANNEL,
Expand Down Expand Up @@ -1200,6 +1281,8 @@ function getIconOption(): { icon: string } | Record<string, never> {
}

function createWindow(): BrowserWindow {
openProjectPathListenerReady = false;

const window = new BrowserWindow({
width: 1100,
height: 780,
Expand Down Expand Up @@ -1277,6 +1360,7 @@ function createWindow(): BrowserWindow {
window.on("closed", () => {
if (mainWindow === window) {
mainWindow = null;
openProjectPathListenerReady = false;
}
});

Expand Down Expand Up @@ -1311,60 +1395,74 @@ async function bootstrap(): Promise<void> {
writeDesktopLogHeader("bootstrap main window created");
}

app.on("before-quit", () => {
isQuitting = true;
writeDesktopLogHeader("before-quit received");
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
});

app
.whenReady()
.then(() => {
writeDesktopLogHeader("app ready");
configureAppIdentity();
configureApplicationMenu();
registerDesktopProtocol();
configureAutoUpdater();
void bootstrap().catch((error) => {
handleFatalStartupError("bootstrap", error);
});

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
if (!hasSingleInstanceLock) {
app.quit();
} else {
if (SHOULD_HANDLE_WINDOWS_PROJECT_LAUNCH) {
app.on("second-instance", (_event, argv) => {
revealMainWindow();
const projectLaunchPath = extractProjectLaunchPathFromArgv(argv);
if (projectLaunchPath) {
handleOpenProjectPath(projectLaunchPath);
}
});
})
.catch((error) => {
handleFatalStartupError("whenReady", error);
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});

if (process.platform !== "win32") {
process.on("SIGINT", () => {
if (isQuitting) return;
app.on("before-quit", () => {
isQuitting = true;
writeDesktopLogHeader("SIGINT received");
writeDesktopLogHeader("before-quit received");
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
app.quit();
});

process.on("SIGTERM", () => {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGTERM received");
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
app.quit();
app
.whenReady()
.then(() => {
writeDesktopLogHeader("app ready");
configureAppIdentity();
configureApplicationMenu();
registerDesktopProtocol();
configureAutoUpdater();
void bootstrap().catch((error) => {
handleFatalStartupError("bootstrap", error);
});

app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
}
});
})
.catch((error) => {
handleFatalStartupError("whenReady", error);
});

app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});

if (process.platform !== "win32") {
process.on("SIGINT", () => {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGINT received");
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
app.quit();
});

process.on("SIGTERM", () => {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGTERM received");
clearUpdatePollTimer();
stopBackend();
restoreStdIoCapture?.();
app.quit();
});
}
}
18 changes: 18 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { DesktopBridge } from "@t3tools/contracts";
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
const SET_THEME_CHANNEL = "desktop:set-theme";
const MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL =
"desktop:mark-open-project-path-listener-ready";
const GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL = "desktop:get-pending-open-project-paths";
const OPEN_PROJECT_PATH_CHANNEL = "desktop:open-project-path";
const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
const MENU_ACTION_CHANNEL = "desktop:menu-action";
Expand All @@ -18,6 +22,20 @@ contextBridge.exposeInMainWorld("desktopBridge", {
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
markOpenProjectPathListenerReady: () =>
ipcRenderer.invoke(MARK_OPEN_PROJECT_PATH_LISTENER_READY_CHANNEL),
getPendingOpenProjectPaths: () => ipcRenderer.invoke(GET_PENDING_OPEN_PROJECT_PATHS_CHANNEL),
onOpenProjectPath: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, path: unknown) => {
if (typeof path !== "string") return;
listener(path);
};

ipcRenderer.on(OPEN_PROJECT_PATH_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(OPEN_PROJECT_PATH_CHANNEL, wrappedListener);
};
},
showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
onMenuAction: (listener) => {
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/windowsProjectLaunch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";

import { extractProjectLaunchPathFromArgv } from "./windowsProjectLaunch";

describe("extractProjectLaunchPathFromArgv", () => {
it("returns the first absolute directory argument", () => {
const path = extractProjectLaunchPathFromArgv(
[
"C:\\Program Files\\T3 Code\\T3 Code.exe",
"--inspect=0",
"C:\\Users\\alice\\src\\demo",
"C:\\Users\\alice\\src\\other",
],
(candidate) => ({
isDirectory: () => candidate === "C:\\Users\\alice\\src\\demo",
}),
);

expect(path).toBe("C:\\Users\\alice\\src\\demo");
});

it("ignores files, relative paths, and flags", () => {
const path = extractProjectLaunchPathFromArgv(
[
"main.js",
"--t3code-dev-root=C:\\repo\\apps\\desktop",
"C:\\Users\\alice\\src\\demo\\README.md",
],
() => ({
isDirectory: () => false,
}),
);

expect(path).toBeNull();
});

it("accepts quoted explorer command arguments", () => {
const path = extractProjectLaunchPathFromArgv(['"C:\\Users\\alice\\src\\quoted"'], () => ({
isDirectory: () => true,
}));

expect(path).toBe("C:\\Users\\alice\\src\\quoted");
});
});
Loading
Loading