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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"dev:preload": "cross-env NODE_ENV=development webpack --config webpack.preload.config.js",
"dev:renderer": "cross-env NODE_ENV=development webpack-dev-server --config webpack.renderer.config.js",
"dev:main": "cross-env NODE_ENV=development webpack --watch --config webpack.main.config.js",
"dev:run": "cross-env NODE_ENV=development nodemon --on-change-only --watch build/main.js --exec \"sleep 3 && electron ./build/main.js\"",
"dev:run": "cross-env NODE_ENV=development nodemon --on-change-only --watch build/main.js --exec \"sleep 3 && electron --gtk-version=3 ./build/main.js\"",
"prod": "rimraf build && yarn run prod:assets && concurrently --names \"RENDERER,MAIN,PRELOAD\" -c \"blue.bold,magenta.bold,green.bold\" \"yarn run prod:renderer\" \"yarn run prod:main\" \"yarn run prod:preload\"",
"prod:assets": "copyfiles --up 1 src/assets/**/* build/",
"prod:preload": "cross-env NODE_ENV=production webpack --config webpack.preload.config.js",
Expand Down Expand Up @@ -95,7 +95,11 @@
"webpack-dev-server": "^5.0.4"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"clsx": "^2.1.1",
"node-pty": "^1.0.0",
"react-xtermjs": "^1.0.10",
"uuid": "^10.0.0",
"zustand": "^4.5.4"
}
Expand Down
19 changes: 19 additions & 0 deletions src/common/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,22 @@ export type IpcShowBrowserWindowRes = void;
export const ipcExecCmdLinesInTerminalChannel = makeIpcChannelName('exec-cmd-lines-in-terminal');
export type IpcExecCmdLinesInTerminalArgs = [cmdLines: ReadonlyArray<string>, cwd?: string];
export type IpcExecCmdLinesInTerminalRes = void;

export const ipcTerminalPtyCreateChannel = makeIpcChannelName('terminal-pty-create');
export type IpcTerminalPtyCreateArgs = [cols: number, rows: number, cwd?: string];
export type IpcTerminalPtyCreateRes = string;

export const ipcTerminalPtyWriteChannel = makeIpcChannelName('terminal-pty-write');
export type IpcTerminalPtyWriteArgs = [sessionId: string, data: string];
export type IpcTerminalPtyWriteRes = void;

export const ipcTerminalPtyResizeChannel = makeIpcChannelName('terminal-pty-resize');
export type IpcTerminalPtyResizeArgs = [sessionId: string, cols: number, rows: number];
export type IpcTerminalPtyResizeRes = void;

export const ipcTerminalPtyCloseChannel = makeIpcChannelName('terminal-pty-close');
export type IpcTerminalPtyCloseArgs = [sessionId: string];
export type IpcTerminalPtyCloseRes = void;

export const ipcTerminalPtyDataChannel = makeIpcChannelName('terminal-pty-data');
export type IpcTerminalPtyDataArgs = [sessionId: string, data: string];
58 changes: 58 additions & 0 deletions src/main/controllers/terminalPty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright: (c) 2024, Alex Kaul
* GNU General Public License v3.0 or later (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
*/

import { Controller } from '@/controllers/controller';
import { TerminalPtyManager } from '@/infra/terminalPty/terminalPtyManager';
import {
ipcTerminalPtyCreateChannel,
IpcTerminalPtyCreateArgs,
IpcTerminalPtyCreateRes,
ipcTerminalPtyWriteChannel,
IpcTerminalPtyWriteArgs,
IpcTerminalPtyWriteRes,
ipcTerminalPtyResizeChannel,
IpcTerminalPtyResizeArgs,
IpcTerminalPtyResizeRes,
ipcTerminalPtyCloseChannel,
IpcTerminalPtyCloseArgs,
IpcTerminalPtyCloseRes,
} from '@common/ipc/channels';

type Deps = {
terminalPtyManager: TerminalPtyManager;
};

export function createTerminalPtyControllers({ terminalPtyManager }: Deps): [
Controller<IpcTerminalPtyCreateArgs, IpcTerminalPtyCreateRes>,
Controller<IpcTerminalPtyWriteArgs, IpcTerminalPtyWriteRes>,
Controller<IpcTerminalPtyResizeArgs, IpcTerminalPtyResizeRes>,
Controller<IpcTerminalPtyCloseArgs, IpcTerminalPtyCloseRes>,
] {
return [
{
channel: ipcTerminalPtyCreateChannel,
handle: async (event, cols, rows, cwd) =>
terminalPtyManager.createSession(event, cols, rows, cwd),
},
{
channel: ipcTerminalPtyWriteChannel,
handle: async (_event, sessionId, data) => {
terminalPtyManager.write(sessionId, data);
},
},
{
channel: ipcTerminalPtyResizeChannel,
handle: async (_event, sessionId, cols, rows) => {
terminalPtyManager.resize(sessionId, cols, rows);
},
},
{
channel: ipcTerminalPtyCloseChannel,
handle: async (_event, sessionId) => {
terminalPtyManager.close(sessionId);
},
},
];
}
6 changes: 5 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ import { createChildProcessProvider } from '@/infra/childProcessProvider/childPr
import { createOpenPathUseCase } from '@/application/useCases/shell/openPath';
import { createCopyWidgetDataStorageUseCase } from '@/application/useCases/widgetDataStorage/copyWidgetDataStorage';
import { createOpenAppUseCase } from '@/application/useCases/shell/openApp';
import { createTerminalPtyControllers } from '@/controllers/terminalPty';
import { TerminalPtyManager } from '@/infra/terminalPty/terminalPtyManager';

let appWindow: BrowserWindow | null = null; // ref to the app window

Expand Down Expand Up @@ -176,6 +178,7 @@ if (!app.requestSingleInstanceLock()) {
const execCmdLinesInTerminalUseCase = createExecCmdLinesInTerminalUseCase({ appsProvider, childProcessProvider, processProvider })

const openAppUseCase = createOpenAppUseCase({ childProcessProvider, processProvider })
const terminalPtyManager = new TerminalPtyManager();

registerControllers(ipcMain, [
...createAppDataStorageControllers({ getTextFromAppDataStorageUseCase, setTextInAppDataStorageUseCase }),
Expand All @@ -201,7 +204,8 @@ if (!app.requestSingleInstanceLock()) {
...createGlobalShortcutControllers({ setMainShortcutUseCase }),
...createTrayMenuControllers({ setTrayMenuUseCase }),
...createBrowserWindowControllers({ showBrowserWindowUseCase }),
...createTerminalControllers({ execCmdLinesInTerminalUseCase })
...createTerminalControllers({ execCmdLinesInTerminalUseCase }),
...createTerminalPtyControllers({ terminalPtyManager })
])

const [windowStore] = createWindowStore({
Expand Down
76 changes: 76 additions & 0 deletions src/main/infra/terminalPty/terminalPtyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright: (c) 2024, Alex Kaul
* GNU General Public License v3.0 or later (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
*/

import os from 'node:os';
import { IpcMainEvent } from '@/controllers/interfaces/ipcMain';
import { WebContents } from '@/application/interfaces/webContents';
import { ipcTerminalPtyDataChannel } from '@common/ipc/channels';
import type { IPty } from 'node-pty';
import { spawn } from 'node-pty';

type PtySession = {
pty: IPty;
sender: WebContents;
};

function getDefaultShell(): string {
if (process.platform === 'win32') {
return process.env.ComSpec || 'cmd.exe';
}
return process.env.SHELL || '/bin/bash';
}

let nextSessionId = 1;

export class TerminalPtyManager {
private readonly sessions = new Map<string, PtySession>();

createSession(event: IpcMainEvent, cols: number, rows: number, cwd?: string): string {
const sessionId = `pty-${nextSessionId++}`;
const pty = spawn(getDefaultShell(), [], {
name: 'xterm-color',
cols,
rows,
cwd: cwd || os.homedir(),
env: process.env as Record<string, string>,
});

const sender = event.sender;
pty.onData((data) => {
sender.send(ipcTerminalPtyDataChannel, sessionId, data);
});
pty.onExit(() => {
this.sessions.delete(sessionId);
});

this.sessions.set(sessionId, { pty, sender });
return sessionId;
}

write(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.pty.write(data);
}

resize(sessionId: string, cols: number, rows: number): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.pty.resize(cols, rows);
}

close(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
return;
}
session.pty.kill();
this.sessions.delete(sessionId);
}
}
2 changes: 1 addition & 1 deletion src/renderer/base/state/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export function createUiState(): UiState {
order: []
},
palette: {
widgetTypeIds: ['commander', 'file-opener', 'link-opener', 'note', 'timer', 'to-do-list', 'web-query', 'webpage']
widgetTypeIds: ['commander', 'file-opener', 'link-opener', 'note', 'terminal', 'timer', 'to-do-list', 'web-query', 'webpage']
},
projectSwitcher: {
currentProjectId: '',
Expand Down
96 changes: 96 additions & 0 deletions src/renderer/infra/terminalPty/terminalPtyClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright: (c) 2024, Alex Kaul
* GNU General Public License v3.0 or later (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
*/

import {
ipcTerminalPtyCreateChannel,
IpcTerminalPtyCreateArgs,
IpcTerminalPtyCreateRes,
ipcTerminalPtyWriteChannel,
IpcTerminalPtyWriteArgs,
IpcTerminalPtyWriteRes,
ipcTerminalPtyResizeChannel,
IpcTerminalPtyResizeArgs,
IpcTerminalPtyResizeRes,
ipcTerminalPtyCloseChannel,
IpcTerminalPtyCloseArgs,
IpcTerminalPtyCloseRes,
ipcTerminalPtyDataChannel,
IpcTerminalPtyDataArgs,
} from '@common/ipc/channels';
import { electronIpcRenderer } from '@/infra/mainApi/mainApi';

type PtyDataHandler = (data: string) => void;

const handlersBySession = new Map<string, Set<PtyDataHandler>>();
let isListenerBound = false;

function ensureDataListener() {
if (isListenerBound) {
return;
}
isListenerBound = true;
electronIpcRenderer.on<IpcTerminalPtyDataArgs>(ipcTerminalPtyDataChannel, (sessionId, data) => {
const handlers = handlersBySession.get(sessionId);
if (!handlers) {
return;
}
handlers.forEach(handler => handler(data));
});
}

export function onTerminalPtyData(sessionId: string, handler: PtyDataHandler): () => void {
ensureDataListener();
let handlers = handlersBySession.get(sessionId);
if (!handlers) {
handlers = new Set<PtyDataHandler>();
handlersBySession.set(sessionId, handlers);
}
handlers.add(handler);

return () => {
const current = handlersBySession.get(sessionId);
if (!current) {
return;
}
current.delete(handler);
if (current.size === 0) {
handlersBySession.delete(sessionId);
}
};
}

export async function createTerminalPtySession(cols: number, rows: number, cwd?: string): Promise<string> {
ensureDataListener();
return electronIpcRenderer.invoke<IpcTerminalPtyCreateArgs, IpcTerminalPtyCreateRes>(
ipcTerminalPtyCreateChannel,
cols,
rows,
cwd
);
}

export async function writeTerminalPty(sessionId: string, data: string): Promise<void> {
return electronIpcRenderer.invoke<IpcTerminalPtyWriteArgs, IpcTerminalPtyWriteRes>(
ipcTerminalPtyWriteChannel,
sessionId,
data
);
}

export async function resizeTerminalPty(sessionId: string, cols: number, rows: number): Promise<void> {
return electronIpcRenderer.invoke<IpcTerminalPtyResizeArgs, IpcTerminalPtyResizeRes>(
ipcTerminalPtyResizeChannel,
sessionId,
cols,
rows
);
}

export async function closeTerminalPty(sessionId: string): Promise<void> {
return electronIpcRenderer.invoke<IpcTerminalPtyCloseArgs, IpcTerminalPtyCloseRes>(
ipcTerminalPtyCloseChannel,
sessionId
);
}
2 changes: 2 additions & 0 deletions src/renderer/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import commander from './commander';
import fileOpener from './file-opener';
import linkOpener from './link-opener';
import note from './note';
import terminal from './terminal';
import timer from './timer';
import toDoList from './to-do-list';
import webpage from './webpage';
Expand All @@ -18,6 +19,7 @@ const widgetTypes = [
fileOpener,
linkOpener,
note,
terminal,
timer,
toDoList,
webpage,
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/widgets/terminal/icons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright: (c) 2024, Alex Kaul
* GNU General Public License v3.0 or later (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
*/

import widgetSvg from './widget.svg';

export {
widgetSvg
};
5 changes: 5 additions & 0 deletions src/renderer/widgets/terminal/icons/widget.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions src/renderer/widgets/terminal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright: (c) 2024, Alex Kaul
* GNU General Public License v3.0 or later (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
*/

import { WidgetType } from '@/widgets/appModules';
import { settingsEditorComp, Settings, createSettingsState } from './settings';
import { widgetComp } from './widget';
import { widgetSvg } from './icons';

const widgetType: WidgetType<Settings> = {
id: 'terminal',
icon: widgetSvg,
name: 'Terminal',
minSize: {
w: 2,
h: 2
},
description: 'The Terminal widget lets you run a local shell directly inside Freeter.',
maximizable: true,
createSettingsState,
settingsEditorComp,
widgetComp,
requiresApi: ['clipboard']
};

export default widgetType;
Loading