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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,23 @@ jobs:

- name: Verify packaged runtime dependencies
run: pnpm --dir apps/desktop run verify:packaged-runtime-deps:linux

desktop-package-windows:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

# Builds both x64 and arm64 NSIS installers (electron-builder.yml win arch).
- name: Package Windows installers
run: pnpm --filter @pi-gui/desktop run package:win
41 changes: 41 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,44 @@ jobs:
apps/desktop/release/latest-linux*.yml
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }}
generate_release_notes: true

release-windows:
runs-on: windows-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm typecheck

# Skip @electron/rebuild: node-pty ships win32 N-API prebuilds (rebuild
# fails on its bundled winpty). Installers are unsigned (no cert).
- name: Package Windows installers
shell: bash
run: |
cd apps/desktop
pnpm run build
pnpm exec electron-builder --win \
-c.npmRebuild=false \
--publish never \
"-c.extraMetadata.version=${GITHUB_REF_NAME#v}"

- name: Release
uses: softprops/action-gh-release@v2
with:
files: |
apps/desktop/release/*.exe
apps/desktop/release/*.exe.blockmap
apps/desktop/release/latest.yml
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }}
generate_release_notes: true
17 changes: 17 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extraResources:
asar: true
asarUnpack:
- "**/*.node"
- "**/*.exe"
- "**/*.wasm"
- "**/node_modules/node-pty/**/*"
- "**/node_modules/.pnpm/node-pty*/node_modules/node-pty/**/*"
Expand Down Expand Up @@ -47,6 +48,22 @@ linux:
target:
- target: AppImage

win:
icon: resources/icon.ico
target:
- target: nsis
arch:
- x64
- arm64

nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: pi-gui

toolsets:
appimage: "1.0.2"

Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/electron/app-store-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function getChangedFiles(workspacePath: string): Promise<ChangedFileEntry
execFile(
"git",
["status", "--porcelain"],
{ cwd: workspacePath, maxBuffer: 2 * 1024 * 1024 },
{ cwd: workspacePath, maxBuffer: 2 * 1024 * 1024, windowsHide: true },
(error, stdout) => {
if (error) {
resolve([]);
Expand Down Expand Up @@ -56,14 +56,14 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise<st
execFile(
"git",
["diff", "--", filePath],
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 },
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true },
(error, stdout) => {
if (error || !stdout.trim()) {
// Try staged diff
execFile(
"git",
["diff", "--cached", "--", filePath],
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 },
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true },
(error2, stdout2) => {
if (!error2 && stdout2.trim()) {
resolve(stdout2);
Expand All @@ -73,7 +73,7 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise<st
execFile(
"git",
["diff", "--no-index", "--", "/dev/null", filePath],
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 },
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true },
(_error3, stdout3) => {
// git diff --no-index exits 1 when files differ, which is expected
resolve(stdout3 || "");
Expand All @@ -95,7 +95,7 @@ export function stageFile(workspacePath: string, filePath: string): Promise<void
execFile(
"git",
["add", "--", filePath],
{ cwd: workspacePath },
{ cwd: workspacePath, windowsHide: true },
(error) => {
if (error) {
reject(error);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/electron/app-store-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function listWorkspaceFiles(workspacePath: string): Promise<string[]> {
execFile(
"git",
["ls-files", "--cached", "--others", "--exclude-standard"],
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 },
{ cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true },
(error, stdout) => {
if (error) {
resolve([]);
Expand Down
121 changes: 113 additions & 8 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Patch child_process for windowsHide before the pi runtime is imported below. Keep first.
import "./suppress-windows-console";
import {
app,
BrowserWindow,
Expand All @@ -23,10 +25,10 @@ import {
NotificationPermissionService,
} from "./notification-permission";
import { checkForUpdate, initUpdateChecker } from "./update-checker";
import { ThemeManager } from "./theme-manager";
import { ThemeManager, windowsTitleBarOverlay } from "./theme-manager";
import { TerminalService } from "./terminal-service";
import type { DesktopAppState, ThemeMode } from "../src/desktop-state";
import { desktopIpc, getDesktopCommandFromShortcut } from "../src/ipc";
import { desktopCommands, desktopIpc, getDesktopCommandFromShortcut, type PiDesktopCommand } from "../src/ipc";
import { SUPPORTED_COMPOSER_IMAGE_TYPES } from "../src/composer-attachments";
import type {
ComposerAttachment,
Expand Down Expand Up @@ -118,17 +120,26 @@ function readClipboardImageAttachment(): ComposerImageAttachment | null {

function createWindow(): BrowserWindow {
const backgroundTestMode = windowTestMode === "background";
const enableTransparency = store ? store.state.enableTransparency : false;
const isMac = process.platform === "darwin";
// macOS: inset traffic lights + vibrancy. Windows: Window Controls Overlay.
// Linux: standard frame.
const useTransparency = isMac && (store ? store.state.enableTransparency : false);
const window = new BrowserWindow({
width: 1480,
height: 980,
minWidth: 1200,
minHeight: 760,
transparent: enableTransparency,
vibrancy: process.platform === "darwin" && enableTransparency ? "under-window" : undefined,
titleBarStyle: "hiddenInset",
backgroundColor: enableTransparency ? "#00000000" : "#f3f4f8",
trafficLightPosition: { x: 18, y: 18 },
transparent: useTransparency,
vibrancy: useTransparency ? "under-window" : undefined,
backgroundColor: useTransparency ? "#00000000" : "#f3f4f8",
...(isMac
? { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 18, y: 18 } }
: process.platform === "win32"
? {
titleBarStyle: "hidden" as const,
titleBarOverlay: windowsTitleBarOverlay(themeManager.getResolvedTheme()),
}
: {}),
show: false,
icon: appIcon,
webPreferences: {
Expand Down Expand Up @@ -184,6 +195,30 @@ function createWindow(): BrowserWindow {
}
});

if (process.platform === "win32") {
// No menu bar on Windows: right-click edit menu for text fields.
window.webContents.on("context-menu", (_event, params) => {
const template: MenuItemConstructorOptions[] = params.isEditable
? [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut", enabled: params.editFlags.canCut },
{ role: "copy", enabled: params.editFlags.canCopy },
{ role: "paste", enabled: params.editFlags.canPaste },
{ type: "separator" },
{ role: "selectAll" },
]
: params.selectionText.trim().length > 0
? [{ role: "copy" }, { type: "separator" }, { role: "selectAll" }]
: [];
if (template.length === 0) {
return;
}
Menu.buildFromTemplate(template).popup({ window });
});
}

if (isDev) {
void window.loadURL(process.env.ELECTRON_RENDERER_URL as string);
if (process.env.PI_APP_OPEN_DEVTOOLS !== "0") {
Expand Down Expand Up @@ -326,6 +361,11 @@ async function runManualUpdateCheck(): Promise<void> {
}

function installApplicationMenu(): void {
if (process.platform === "win32") {
// No menu bar on Windows; the in-app topbar is the only chrome.
Menu.setApplicationMenu(null);
return;
}
if (process.platform !== "darwin") {
return;
}
Expand Down Expand Up @@ -376,6 +416,67 @@ function installApplicationMenu(): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}

function sendDesktopCommand(command: PiDesktopCommand): void {
if (mainWindow && canPublishToWindow(mainWindow)) {
mainWindow.webContents.send(desktopIpc.appCommand, command);
}
}

function showAboutDialog(): void {
const window = mainWindow && canPublishToWindow(mainWindow) ? mainWindow : undefined;
const options: MessageBoxOptions = {
type: "info",
title: "pi-gui",
message: "pi-gui",
detail: `Version ${app.getVersion()}\nElectron ${process.versions.electron}\nA Codex-style desktop app for pi.`,
buttons: ["OK"],
};
if (window) {
void dialog.showMessageBox(window, options);
} else {
void dialog.showMessageBox(options);
}
}

// Surfaced from the topbar menu button (popupAppMenu); Windows has no menu bar.
function buildWindowsAppMenu(): Menu {
const template: MenuItemConstructorOptions[] = [
{
label: "File",
submenu: [
{ label: "New Thread", click: () => sendDesktopCommand(desktopCommands.openNewThread) },
{
label: "Open Folder…",
click: () => {
void pickWorkspaceViaDialog();
},
},
{ type: "separator" },
{ label: "Settings", click: () => sendDesktopCommand(desktopCommands.openSettings) },
{ type: "separator" },
{ role: "quit" },
],
},
{ role: "editMenu" },
{ role: "viewMenu" },
{ role: "windowMenu" },
{
label: "Help",
submenu: [
{
id: CHECK_FOR_UPDATES_MENU_ITEM_ID,
label: "Check for Updates…",
click: () => {
void runManualUpdateCheck();
},
},
{ label: "About pi", click: () => showAboutDialog() },
],
},
];
return Menu.buildFromTemplate(template);
}

app.setName("pi");

const configuredUserDataDir = process.env.PI_APP_USER_DATA_DIR?.trim() || app.getPath("userData");
Expand Down Expand Up @@ -735,6 +836,10 @@ app.whenReady().then(async () => {

window.maximize();
});
ipcMain.handle(desktopIpc.popupAppMenu, (event) => {
const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow ?? undefined;
buildWindowsAppMenu().popup(window ? { window } : undefined);
});

mainWindow = createWindow();
notificationManager.trackWindow(mainWindow);
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/electron/notification-permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ async function openSystemNotificationSettingsInternal(): Promise<void> {
return;
}

if (process.platform === "win32") {
await shell.openExternal("ms-settings:notifications");
return;
}

if (process.platform !== "darwin") {
await shell.openExternal("https://support.apple.com/guide/mac-help/change-notifications-settings-mh40583/mac");
return;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ contextBridge.exposeInMainWorld("piApp", {
stageFile: (workspaceId: string, filePath: string) =>
ipcRenderer.invoke(desktopIpc.stageFile, workspaceId, filePath) as Promise<void>,
toggleWindowMaximize: () => ipcRenderer.invoke(desktopIpc.toggleWindowMaximize) as Promise<void>,
popupAppMenu: () => ipcRenderer.invoke(desktopIpc.popupAppMenu) as Promise<void>,
openExternal: (url: string) => ipcRenderer.invoke(desktopIpc.openExternal, url) as Promise<void>,
getThemeMode: () => ipcRenderer.invoke(desktopIpc.getThemeMode) as Promise<"system" | "light" | "dark">,
getResolvedTheme: () => ipcRenderer.invoke(desktopIpc.getResolvedTheme) as Promise<"light" | "dark">,
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/electron/suppress-windows-console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// A GUI process has no console, so spawning console programs (git, npm, bash)
// flashes a console window on Windows. Default windowsHide:true on every
// child_process spawn in the main process (no-op off Windows; explicit values win).
import childProcess from "node:child_process";

if (process.platform === "win32") {
const methods = ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync"] as const;
for (const name of methods) {
const original = (childProcess as unknown as Record<string, unknown>)[name];
if (typeof original !== "function") {
continue;
}
const wrapped = function patchedChildProcess(this: unknown, ...args: unknown[]) {
const hasTrailingCallback = typeof args[args.length - 1] === "function";
const optionsIndex = hasTrailingCallback ? args.length - 2 : args.length - 1;
const candidate = optionsIndex >= 0 ? args[optionsIndex] : undefined;
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
const options = candidate as Record<string, unknown>;
if (options.windowsHide === undefined) {
options.windowsHide = true;
}
} else {
args.splice(hasTrailingCallback ? args.length - 1 : args.length, 0, { windowsHide: true });
}
return (original as (...callArgs: unknown[]) => unknown).apply(this, args);
};
(childProcess as unknown as Record<string, unknown>)[name] = wrapped;
}
}
5 changes: 4 additions & 1 deletion apps/desktop/electron/terminal-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,10 @@ export class TerminalService {

private resolveShell(): string {
const configuredShell = this.options.getIntegratedTerminalShell()?.trim();
const shellPath = configuredShell || process.env.SHELL || defaultShellForPlatform();
const shellPath =
configuredShell ||
(process.platform === "win32" ? undefined : process.env.SHELL) ||
defaultShellForPlatform();
if (process.platform !== "win32" && !path.isAbsolute(shellPath)) {
throw new Error(`Integrated terminal shell must be an absolute path: ${shellPath}`);
}
Expand Down
Loading