diff --git a/package.json b/package.json index 145978b..a5c26ef 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" } diff --git a/src/common/ipc/channels.ts b/src/common/ipc/channels.ts index d5a244b..dd00fd9 100644 --- a/src/common/ipc/channels.ts +++ b/src/common/ipc/channels.ts @@ -127,3 +127,22 @@ export type IpcShowBrowserWindowRes = void; export const ipcExecCmdLinesInTerminalChannel = makeIpcChannelName('exec-cmd-lines-in-terminal'); export type IpcExecCmdLinesInTerminalArgs = [cmdLines: ReadonlyArray, 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]; diff --git a/src/main/controllers/terminalPty.ts b/src/main/controllers/terminalPty.ts new file mode 100644 index 0000000..3599afc --- /dev/null +++ b/src/main/controllers/terminalPty.ts @@ -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, + Controller, + Controller, + Controller, +] { + 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); + }, + }, + ]; +} diff --git a/src/main/index.ts b/src/main/index.ts index 830a177..fea53d0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 @@ -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 }), @@ -201,7 +204,8 @@ if (!app.requestSingleInstanceLock()) { ...createGlobalShortcutControllers({ setMainShortcutUseCase }), ...createTrayMenuControllers({ setTrayMenuUseCase }), ...createBrowserWindowControllers({ showBrowserWindowUseCase }), - ...createTerminalControllers({ execCmdLinesInTerminalUseCase }) + ...createTerminalControllers({ execCmdLinesInTerminalUseCase }), + ...createTerminalPtyControllers({ terminalPtyManager }) ]) const [windowStore] = createWindowStore({ diff --git a/src/main/infra/terminalPty/terminalPtyManager.ts b/src/main/infra/terminalPty/terminalPtyManager.ts new file mode 100644 index 0000000..60f70aa --- /dev/null +++ b/src/main/infra/terminalPty/terminalPtyManager.ts @@ -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(); + + 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, + }); + + 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); + } +} diff --git a/src/renderer/base/state/ui.ts b/src/renderer/base/state/ui.ts index e27eb31..598153d 100644 --- a/src/renderer/base/state/ui.ts +++ b/src/renderer/base/state/ui.ts @@ -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: '', diff --git a/src/renderer/infra/terminalPty/terminalPtyClient.ts b/src/renderer/infra/terminalPty/terminalPtyClient.ts new file mode 100644 index 0000000..de68e1a --- /dev/null +++ b/src/renderer/infra/terminalPty/terminalPtyClient.ts @@ -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>(); +let isListenerBound = false; + +function ensureDataListener() { + if (isListenerBound) { + return; + } + isListenerBound = true; + electronIpcRenderer.on(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(); + 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 { + ensureDataListener(); + return electronIpcRenderer.invoke( + ipcTerminalPtyCreateChannel, + cols, + rows, + cwd + ); +} + +export async function writeTerminalPty(sessionId: string, data: string): Promise { + return electronIpcRenderer.invoke( + ipcTerminalPtyWriteChannel, + sessionId, + data + ); +} + +export async function resizeTerminalPty(sessionId: string, cols: number, rows: number): Promise { + return electronIpcRenderer.invoke( + ipcTerminalPtyResizeChannel, + sessionId, + cols, + rows + ); +} + +export async function closeTerminalPty(sessionId: string): Promise { + return electronIpcRenderer.invoke( + ipcTerminalPtyCloseChannel, + sessionId + ); +} diff --git a/src/renderer/widgets/index.ts b/src/renderer/widgets/index.ts index 5f44cfc..dc05920 100644 --- a/src/renderer/widgets/index.ts +++ b/src/renderer/widgets/index.ts @@ -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'; @@ -18,6 +19,7 @@ const widgetTypes = [ fileOpener, linkOpener, note, + terminal, timer, toDoList, webpage, diff --git a/src/renderer/widgets/terminal/icons/index.ts b/src/renderer/widgets/terminal/icons/index.ts new file mode 100644 index 0000000..cf55d19 --- /dev/null +++ b/src/renderer/widgets/terminal/icons/index.ts @@ -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 +}; diff --git a/src/renderer/widgets/terminal/icons/widget.svg b/src/renderer/widgets/terminal/icons/widget.svg new file mode 100644 index 0000000..346479e --- /dev/null +++ b/src/renderer/widgets/terminal/icons/widget.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/renderer/widgets/terminal/index.ts b/src/renderer/widgets/terminal/index.ts new file mode 100644 index 0000000..f3de488 --- /dev/null +++ b/src/renderer/widgets/terminal/index.ts @@ -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 = { + 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; diff --git a/src/renderer/widgets/terminal/settings.tsx b/src/renderer/widgets/terminal/settings.tsx new file mode 100644 index 0000000..6ba1153 --- /dev/null +++ b/src/renderer/widgets/terminal/settings.tsx @@ -0,0 +1,146 @@ +/* + * 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 { CreateSettingsState, ReactComponent, SettingsEditorReactComponentProps, SettingBlock } from '@/widgets/appModules'; + +export interface Settings { + fontFamily: string; + fontSize: number; + cursorStyle: 'block' | 'underline' | 'bar'; + theme: 'light' | 'dark'; + initialCommand: string; +} + +const cursorStyles: Settings['cursorStyle'][] = ['block', 'underline', 'bar']; +const themes: Settings['theme'][] = ['light', 'dark']; +const defaultSettings: Settings = { + fontFamily: 'monospace', + fontSize: 14, + cursorStyle: 'block', + theme: 'light', + initialCommand: '' +}; + +function isCursorStyle(value: unknown): value is Settings['cursorStyle'] { + return cursorStyles.includes(value as Settings['cursorStyle']); +} + +function isTheme(value: unknown): value is Settings['theme'] { + return themes.includes(value as Settings['theme']); +} + +export const createSettingsState: CreateSettingsState = (settings) => ({ + fontFamily: typeof settings.fontFamily === 'string' && settings.fontFamily.trim() !== '' + ? settings.fontFamily + : defaultSettings.fontFamily, + fontSize: typeof settings.fontSize === 'number' && Number.isFinite(settings.fontSize) && settings.fontSize > 0 + ? Math.round(settings.fontSize) + : defaultSettings.fontSize, + cursorStyle: isCursorStyle(settings.cursorStyle) ? settings.cursorStyle : defaultSettings.cursorStyle, + theme: isTheme(settings.theme) ? settings.theme : defaultSettings.theme, + initialCommand: typeof settings.initialCommand === 'string' ? settings.initialCommand : defaultSettings.initialCommand +}); + +function SettingsEditorComp({ settings, settingsApi }: SettingsEditorReactComponentProps) { + const { updateSettings } = settingsApi; + + return ( + <> + + updateSettings({ + ...settings, + fontFamily: e.target.value + })} + placeholder='monospace' + /> + + + { + const parsed = Number.parseInt(e.target.value, 10); + updateSettings({ + ...settings, + fontSize: Number.isNaN(parsed) ? defaultSettings.fontSize : parsed + }); + }} + /> + + + + + + + + +