feat: implement local terminal support with shell configuration and UI integration

This commit is contained in:
Andy Dixon
2026-05-21 13:13:06 +01:00
parent 3ff04feb92
commit a7cf28b404
21 changed files with 626 additions and 3 deletions
+1 -1
View File
@@ -1 +1 @@
ce0d75b28075a7688ba4535c94b4784c 99c6bbb0236daee684251a5f62bbd974
+10
View File
@@ -467,6 +467,16 @@
color: var(--danger); color: var(--danger);
} }
.settings-shell-row {
display: flex;
gap: var(--space-2);
align-items: center;
}
.settings-shell-row .form-input {
flex: 1;
}
.form-success { .form-success {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--success); color: var(--success);
+21
View File
@@ -253,6 +253,12 @@
.sidebar-item .protocol-indicator.proto-github::before { .sidebar-item .protocol-indicator.proto-github::before {
content: "GH"; content: "GH";
} }
.sidebar-item .protocol-indicator.proto-vnc::before {
content: "⊞";
}
.sidebar-item .protocol-indicator.proto-local::before {
content: "›_";
}
.sidebar-item .protocol-indicator.proto-ssh { .sidebar-item .protocol-indicator.proto-ssh {
background: rgba(78, 205, 196, 0.18); background: rgba(78, 205, 196, 0.18);
@@ -270,6 +276,21 @@
background: rgba(124, 138, 255, 0.18); background: rgba(124, 138, 255, 0.18);
color: var(--protocol-github); color: var(--protocol-github);
} }
.sidebar-item .protocol-indicator.proto-vnc {
background: rgba(232, 93, 74, 0.18);
color: var(--protocol-vnc);
}
.sidebar-item .protocol-indicator.proto-local {
background: rgba(139, 195, 74, 0.18);
color: var(--protocol-local);
}
/* Local machine item separator */
.sidebar-item-local {
border-bottom: 1px solid var(--border-subtle);
margin: var(--space-2) var(--space-2) var(--space-2);
padding-bottom: var(--space-2);
}
/* Text block (name + host) */ /* Text block (name + host) */
.sidebar-item-text { .sidebar-item-text {
+8
View File
@@ -114,6 +114,14 @@
background: rgba(124, 138, 255, 0.15); background: rgba(124, 138, 255, 0.15);
color: var(--protocol-github); color: var(--protocol-github);
} }
.tab-badge.badge-vnc {
background: rgba(232, 93, 74, 0.15);
color: var(--protocol-vnc);
}
.tab-badge.badge-local {
background: rgba(139, 195, 74, 0.15);
color: var(--protocol-local);
}
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Status dot (connection state) Status dot (connection state)
+1
View File
@@ -58,6 +58,7 @@
--protocol-raw: #ab47bc; --protocol-raw: #ab47bc;
--protocol-github: #7c8aff; --protocol-github: #7c8aff;
--protocol-vnc: #e85d4a; --protocol-vnc: #e85d4a;
--protocol-local: #8bc34a;
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Spacing scale (4 px base unit) Spacing scale (4 px base unit)
+27
View File
@@ -541,6 +541,9 @@
this._bindClick("btn-new-tab", () => this.profileEditor.openNew()); this._bindClick("btn-new-tab", () => this.profileEditor.openNew());
this._bindClick("btn-broadcast", () => this.toggleBroadcast()); this._bindClick("btn-broadcast", () => this.toggleBroadcast());
// Wire the "Local Machine" sidebar button
this.sidebar.onConnectLocal = () => this.connectLocal();
// Right-click on broadcast button opens target selector // Right-click on broadcast button opens target selector
const broadcastBtn = document.getElementById("btn-broadcast"); const broadcastBtn = document.getElementById("btn-broadcast");
if (broadcastBtn) { if (broadcastBtn) {
@@ -809,6 +812,30 @@
} }
} }
/**
* Opens a local terminal session.
*/
async connectLocal() {
try {
const sessionInfo = await window.go.api.Service.ConnectLocal();
this.state.addSession(sessionInfo);
this.tabs.addTab(sessionInfo);
const panelId = `panel-${sessionInfo.id}`;
const prefs = this.state.preferences || {};
this.terminals.createTerminal(sessionInfo.id, panelId, {
terminalFontFamily: prefs.defaultFontFamily,
terminalFontSize: prefs.defaultFontSize,
terminalScrollback: prefs.defaultScrollback,
terminalCursorStyle: prefs.defaultCursorStyle,
});
this.notifications.info("Opening local terminal…");
} catch (err) {
this.notifications.error("Local terminal failed: " + err);
}
}
/** /**
* Opens a quick connect dialog for ad-hoc connections without saving a profile. * Opens a quick connect dialog for ad-hoc connections without saving a profile.
*/ */
+8
View File
@@ -107,6 +107,14 @@
action: () => this.app.quickConnect(), action: () => this.app.quickConnect(),
}); });
// Local terminal
cmds.push({
id: "local-terminal",
label: "Open Local Terminal",
section: "Actions",
action: () => this.app.connectLocal(),
});
// Tab actions // Tab actions
cmds.push({ cmds.push({
id: "close-tab", id: "close-tab",
+46
View File
@@ -53,6 +53,12 @@
this._knownHosts = []; this._knownHosts = [];
} }
try {
this._defaultShell = await window.go.api.Service.GetDefaultShell();
} catch (_) {
this._defaultShell = "";
}
this._render(); this._render();
} }
@@ -281,6 +287,45 @@
{ min: 180, max: 500 }, { min: 180, max: 500 },
), ),
); );
// Local shell path with browse button
const shellGroup = createElement("div", "form-group settings-field");
const shellLabel = createElement("label", "form-label", {
textContent: "Local shell",
for: "setting-localShell",
});
shellGroup.appendChild(shellLabel);
const shellRow = createElement("div", "settings-shell-row");
const shellInput = createElement("input", "form-input", {
type: "text",
id: "setting-localShell",
name: "setting-localShell",
value: p.localShell || "",
placeholder: this._defaultShell || "/bin/zsh",
});
shellRow.appendChild(shellInput);
const browseBtn = createElement("button", "btn btn-sm btn-secondary", {
textContent: "Browse…",
type: "button",
});
browseBtn.addEventListener("click", async () => {
try {
const path = await window.go.api.Service.BrowseForShell();
if (path) {
shellInput.value = path;
}
} catch (_) {}
});
shellRow.appendChild(browseBtn);
shellGroup.appendChild(shellRow);
const shellHint = createElement("span", "form-hint", {
textContent: "Leave empty to use system default",
});
shellGroup.appendChild(shellHint);
body.appendChild(shellGroup);
} }
/** /**
@@ -1050,6 +1095,7 @@
"defaultScrollback", "defaultScrollback",
"defaultCursorStyle", "defaultCursorStyle",
"useNativePaste", "useNativePaste",
"localShell",
]; ];
for (const field of fields) { for (const field of fields) {
+33
View File
@@ -68,6 +68,9 @@
this.el.appendChild(this._renderBulkActions()); this.el.appendChild(this._renderBulkActions());
} }
// Local Machine quick-connect button
this.el.appendChild(this._renderLocalMachineItem());
// Favourites // Favourites
const favourites = profiles.filter((p) => p.favourite); const favourites = profiles.filter((p) => p.favourite);
if (favourites.length > 0) { if (favourites.length > 0) {
@@ -531,6 +534,36 @@
); );
} }
/** @private */
_renderLocalMachineItem() {
const item = createElement("div", "sidebar-item sidebar-item-local");
item.addEventListener("mousedown", (e) => e.preventDefault());
const indicator = createElement(
"span",
"protocol-indicator proto-local",
{
title: "Local",
},
);
item.appendChild(indicator);
const textWrapper = createElement("div", "sidebar-item-text");
const nameEl = createElement("span", "sidebar-item-name", {
textContent: "Local Machine",
});
textWrapper.appendChild(nameEl);
item.appendChild(textWrapper);
item.addEventListener("click", () => {
if (this.onConnectLocal) {
this.onConnectLocal();
}
});
return item;
}
/** @private */ /** @private */
_renderBulkActions() { _renderBulkActions() {
const bar = createElement("div", "sidebar-bulk-actions"); const bar = createElement("div", "sidebar-bulk-actions");
+3 -1
View File
@@ -480,7 +480,9 @@
const protoLabel = const protoLabel =
sessionInfo.protocol === "github" sessionInfo.protocol === "github"
? "GitHub" ? "GitHub"
: sessionInfo.protocol.toUpperCase(); : sessionInfo.protocol === "local"
? "Local"
: sessionInfo.protocol.toUpperCase();
const badge = createElement( const badge = createElement(
"span", "span",
`tab-badge badge-${sessionInfo.protocol}`, `tab-badge badge-${sessionInfo.protocol}`,
+2
View File
@@ -229,6 +229,8 @@
return "proto-github"; return "proto-github";
case "vnc": case "vnc":
return "proto-vnc"; return "proto-vnc";
case "local":
return "proto-local";
default: default:
return ""; return "";
} }
+17
View File
@@ -1,8 +1,11 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {models} from '../models'; import {models} from '../models';
import {api} from '../models';
import {context} from '../models'; import {context} from '../models';
export function BrowseForShell():Promise<string>;
export function CancelReconnect(arg1:string):Promise<void>; export function CancelReconnect(arg1:string):Promise<void>;
export function CheckForUpdates():Promise<Record<string, any>>; export function CheckForUpdates():Promise<Record<string, any>>;
@@ -11,6 +14,8 @@ export function ClearSessionHistory(arg1:string):Promise<void>;
export function CloseAllSessions():Promise<void>; export function CloseAllSessions():Promise<void>;
export function ConnectLocal():Promise<models.SessionInfo>;
export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>; export function ConnectQuick(arg1:models.Profile):Promise<models.SessionInfo>;
export function ConnectSession(arg1:string):Promise<models.SessionInfo>; export function ConnectSession(arg1:string):Promise<models.SessionInfo>;
@@ -27,6 +32,8 @@ export function DeleteProfileSecrets(arg1:string):Promise<void>;
export function DeleteRecording(arg1:string):Promise<void>; export function DeleteRecording(arg1:string):Promise<void>;
export function DeleteSSHKey(arg1:string):Promise<void>;
export function DeleteSnippet(arg1:string):Promise<void>; export function DeleteSnippet(arg1:string):Promise<void>;
export function DeleteTOTPSecret(arg1:string):Promise<void>; export function DeleteTOTPSecret(arg1:string):Promise<void>;
@@ -45,20 +52,28 @@ export function FileExists(arg1:string):Promise<boolean>;
export function FileIsPrivateKey(arg1:string):Promise<boolean>; export function FileIsPrivateKey(arg1:string):Promise<boolean>;
export function GenerateSSHKey(arg1:string,arg2:string,arg3:number,arg4:string):Promise<void>;
export function GetAppInfo():Promise<Record<string, string>>; export function GetAppInfo():Promise<Record<string, string>>;
export function GetDefaultShell():Promise<string>;
export function GetPreferences():Promise<models.AppPreferences>; export function GetPreferences():Promise<models.AppPreferences>;
export function GetProfile(arg1:string):Promise<models.Profile>; export function GetProfile(arg1:string):Promise<models.Profile>;
export function GetRecording(arg1:string):Promise<Record<string, any>>; export function GetRecording(arg1:string):Promise<Record<string, any>>;
export function GetSSHPublicKey(arg1:string):Promise<string>;
export function GetSessionCwd(arg1:string):Promise<string>; export function GetSessionCwd(arg1:string):Promise<string>;
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>; export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
export function GetTOTPCode(arg1:string):Promise<string>; export function GetTOTPCode(arg1:string):Promise<string>;
export function GetVNCWebSocketURL(arg1:string):Promise<string>;
export function HasTOTPSecret(arg1:string):Promise<boolean>; export function HasTOTPSecret(arg1:string):Promise<boolean>;
export function ImportProfiles(arg1:string,arg2:string):Promise<number>; export function ImportProfiles(arg1:string,arg2:string):Promise<number>;
@@ -93,6 +108,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
export function ListRecordings():Promise<Array<Record<string, any>>>; export function ListRecordings():Promise<Array<Record<string, any>>>;
export function ListSSHKeyPairs():Promise<Array<api.SSHKeyInfo>>;
export function ListSSHKeys():Promise<Array<string>>; export function ListSSHKeys():Promise<Array<string>>;
export function ListSessions():Promise<Array<models.SessionInfo>>; export function ListSessions():Promise<Array<models.SessionInfo>>;
+32
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function BrowseForShell() {
return window['go']['api']['Service']['BrowseForShell']();
}
export function CancelReconnect(arg1) { export function CancelReconnect(arg1) {
return window['go']['api']['Service']['CancelReconnect'](arg1); return window['go']['api']['Service']['CancelReconnect'](arg1);
} }
@@ -18,6 +22,10 @@ export function CloseAllSessions() {
return window['go']['api']['Service']['CloseAllSessions'](); return window['go']['api']['Service']['CloseAllSessions']();
} }
export function ConnectLocal() {
return window['go']['api']['Service']['ConnectLocal']();
}
export function ConnectQuick(arg1) { export function ConnectQuick(arg1) {
return window['go']['api']['Service']['ConnectQuick'](arg1); return window['go']['api']['Service']['ConnectQuick'](arg1);
} }
@@ -50,6 +58,10 @@ export function DeleteRecording(arg1) {
return window['go']['api']['Service']['DeleteRecording'](arg1); return window['go']['api']['Service']['DeleteRecording'](arg1);
} }
export function DeleteSSHKey(arg1) {
return window['go']['api']['Service']['DeleteSSHKey'](arg1);
}
export function DeleteSnippet(arg1) { export function DeleteSnippet(arg1) {
return window['go']['api']['Service']['DeleteSnippet'](arg1); return window['go']['api']['Service']['DeleteSnippet'](arg1);
} }
@@ -86,10 +98,18 @@ export function FileIsPrivateKey(arg1) {
return window['go']['api']['Service']['FileIsPrivateKey'](arg1); return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
} }
export function GenerateSSHKey(arg1, arg2, arg3, arg4) {
return window['go']['api']['Service']['GenerateSSHKey'](arg1, arg2, arg3, arg4);
}
export function GetAppInfo() { export function GetAppInfo() {
return window['go']['api']['Service']['GetAppInfo'](); return window['go']['api']['Service']['GetAppInfo']();
} }
export function GetDefaultShell() {
return window['go']['api']['Service']['GetDefaultShell']();
}
export function GetPreferences() { export function GetPreferences() {
return window['go']['api']['Service']['GetPreferences'](); return window['go']['api']['Service']['GetPreferences']();
} }
@@ -102,6 +122,10 @@ export function GetRecording(arg1) {
return window['go']['api']['Service']['GetRecording'](arg1); return window['go']['api']['Service']['GetRecording'](arg1);
} }
export function GetSSHPublicKey(arg1) {
return window['go']['api']['Service']['GetSSHPublicKey'](arg1);
}
export function GetSessionCwd(arg1) { export function GetSessionCwd(arg1) {
return window['go']['api']['Service']['GetSessionCwd'](arg1); return window['go']['api']['Service']['GetSessionCwd'](arg1);
} }
@@ -114,6 +138,10 @@ export function GetTOTPCode(arg1) {
return window['go']['api']['Service']['GetTOTPCode'](arg1); return window['go']['api']['Service']['GetTOTPCode'](arg1);
} }
export function GetVNCWebSocketURL(arg1) {
return window['go']['api']['Service']['GetVNCWebSocketURL'](arg1);
}
export function HasTOTPSecret(arg1) { export function HasTOTPSecret(arg1) {
return window['go']['api']['Service']['HasTOTPSecret'](arg1); return window['go']['api']['Service']['HasTOTPSecret'](arg1);
} }
@@ -182,6 +210,10 @@ export function ListRecordings() {
return window['go']['api']['Service']['ListRecordings'](); return window['go']['api']['Service']['ListRecordings']();
} }
export function ListSSHKeyPairs() {
return window['go']['api']['Service']['ListSSHKeyPairs']();
}
export function ListSSHKeys() { export function ListSSHKeys() {
return window['go']['api']['Service']['ListSSHKeys'](); return window['go']['api']['Service']['ListSSHKeys']();
} }
+70
View File
@@ -1,3 +1,28 @@
export namespace api {
export class SSHKeyInfo {
name: string;
type: string;
bits: string;
publicKey: string;
privateKeyPath: string;
static createFrom(source: any = {}) {
return new SSHKeyInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.type = source["type"];
this.bits = source["bits"];
this.publicKey = source["publicKey"];
this.privateKeyPath = source["privateKeyPath"];
}
}
}
export namespace models { export namespace models {
export class AppPreferences { export class AppPreferences {
@@ -13,6 +38,7 @@ export namespace models {
defaultScrollback: number; defaultScrollback: number;
defaultCursorStyle: string; defaultCursorStyle: string;
useNativePaste: boolean; useNativePaste: boolean;
localShell: string;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AppPreferences(source); return new AppPreferences(source);
@@ -32,6 +58,7 @@ export namespace models {
this.defaultScrollback = source["defaultScrollback"]; this.defaultScrollback = source["defaultScrollback"];
this.defaultCursorStyle = source["defaultCursorStyle"]; this.defaultCursorStyle = source["defaultCursorStyle"];
this.useNativePaste = source["useNativePaste"]; this.useNativePaste = source["useNativePaste"];
this.localShell = source["localShell"];
} }
} }
export class AuditEntry { export class AuditEntry {
@@ -222,6 +249,46 @@ export namespace models {
this.label = source["label"]; this.label = source["label"];
} }
} }
export class VNCConfig {
vncHost: string;
vncPort: number;
secretMode: string;
viewOnly: boolean;
scaleToFit: boolean;
quality: number;
sharedSession: boolean;
sshTunnel: boolean;
sshHost: string;
sshPort: number;
sshUsername: string;
sshAuthMethod: string;
sshSecretMode: string;
sshPrivateKeyPath: string;
sshAgentAuth: boolean;
static createFrom(source: any = {}) {
return new VNCConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.vncHost = source["vncHost"];
this.vncPort = source["vncPort"];
this.secretMode = source["secretMode"];
this.viewOnly = source["viewOnly"];
this.scaleToFit = source["scaleToFit"];
this.quality = source["quality"];
this.sharedSession = source["sharedSession"];
this.sshTunnel = source["sshTunnel"];
this.sshHost = source["sshHost"];
this.sshPort = source["sshPort"];
this.sshUsername = source["sshUsername"];
this.sshAuthMethod = source["sshAuthMethod"];
this.sshSecretMode = source["sshSecretMode"];
this.sshPrivateKeyPath = source["sshPrivateKeyPath"];
this.sshAgentAuth = source["sshAgentAuth"];
}
}
export class RAWConfig { export class RAWConfig {
autoReconnect: boolean; autoReconnect: boolean;
lineEnding: string; lineEnding: string;
@@ -410,6 +477,7 @@ export namespace models {
telnet?: TelnetConfig; telnet?: TelnetConfig;
raw?: RAWConfig; raw?: RAWConfig;
github?: GitHubConfig; github?: GitHubConfig;
vnc?: VNCConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Profile(source); return new Profile(source);
@@ -442,6 +510,7 @@ export namespace models {
this.telnet = this.convertValues(source["telnet"], TelnetConfig); this.telnet = this.convertValues(source["telnet"], TelnetConfig);
this.raw = this.convertValues(source["raw"], RAWConfig); this.raw = this.convertValues(source["raw"], RAWConfig);
this.github = this.convertValues(source["github"], GitHubConfig); this.github = this.convertValues(source["github"], GitHubConfig);
this.vnc = this.convertValues(source["vnc"], VNCConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -522,6 +591,7 @@ export namespace models {
this.updatedAt = source["updatedAt"]; this.updatedAt = source["updatedAt"];
} }
} }
} }
+2 -1
View File
@@ -3,7 +3,9 @@ module github.com/andydixon/nexus
go 1.25.0 go 1.25.0
require ( require (
github.com/creack/pty v1.1.24
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
github.com/pkg/sftp v1.13.10 github.com/pkg/sftp v1.13.10
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
@@ -22,7 +24,6 @@ require (
github.com/danieljoos/wincred v1.2.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kr/fs v0.1.0 // indirect github.com/kr/fs v0.1.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect
+2
View File
@@ -6,6 +6,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+42
View File
@@ -24,6 +24,7 @@ import (
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/exportimport" "github.com/andydixon/nexus/internal/exportimport"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/preferences" "github.com/andydixon/nexus/internal/preferences"
"github.com/andydixon/nexus/internal/secrets" "github.com/andydixon/nexus/internal/secrets"
@@ -369,6 +370,24 @@ func (s *Service) ConnectQuick(profile models.Profile) (models.SessionInfo, erro
return *info, nil return *info, nil
} }
// ConnectLocal opens a local shell session using the configured (or default) shell.
func (s *Service) ConnectLocal() (models.SessionInfo, error) {
shell := ""
if s.prefs != nil {
prefs := s.prefs.Get()
shell = prefs.LocalShell
}
sessionID, err := s.sessions.ConnectLocal(shell)
if err != nil {
return models.SessionInfo{}, err
}
info := s.sessions.GetSessionInfo(sessionID)
if info == nil {
return models.SessionInfo{}, fmt.Errorf("session created but info not found")
}
return *info, nil
}
// DisconnectSession closes a session by ID. // DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error { func (s *Service) DisconnectSession(sessionID string) error {
return s.sessions.Disconnect(sessionID) return s.sessions.Disconnect(sessionID)
@@ -923,6 +942,29 @@ func (s *Service) GetVNCWebSocketURL(sessionID string) (string, error) {
return s.sessions.GetVNCWebSocketURL(sessionID) return s.sessions.GetVNCWebSocketURL(sessionID)
} }
// ---------------------------------------------------------------------------
// Local shell
// ---------------------------------------------------------------------------
// GetDefaultShell returns the system's default shell path.
func (s *Service) GetDefaultShell() string {
return localshell.DefaultShell()
}
// BrowseForShell opens a file dialog to select a shell binary.
func (s *Service) BrowseForShell() (string, error) {
if s.ctx == nil {
return "", fmt.Errorf("application context not set")
}
path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{
Title: "Select Shell Binary",
})
if err != nil {
return "", fmt.Errorf("file dialog failed: %w", err)
}
return path, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Port forwarding // Port forwarding
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+190
View File
@@ -0,0 +1,190 @@
// Package localshell provides a local PTY-based terminal session.
// It spawns a shell process and bridges I/O for the frontend terminal.
package localshell
import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"
"github.com/creack/pty"
)
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
func DefaultShell() string {
if runtime.GOOS == "windows" {
// Prefer PowerShell if available, else cmd.exe.
if ps, err := exec.LookPath("powershell.exe"); err == nil {
return ps
}
return "cmd.exe"
}
// Unix: use SHELL env var, fallback to /bin/sh.
if sh := os.Getenv("SHELL"); sh != "" {
return sh
}
return "/bin/sh"
}
// NewClient creates a new local shell client.
func NewClient(cfg Config) *Client {
shell := cfg.Shell
if shell == "" {
shell = DefaultShell()
}
cols := cfg.Cols
if cols == 0 {
cols = 80
}
rows := cfg.Rows
if rows == 0 {
rows = 24
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
// Start spawns the shell process and begins reading output.
func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = os.Environ()
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
// Set TERM for proper terminal behaviour.
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
Rows: c.rows,
})
if err != nil {
return fmt.Errorf("failed to start shell %q: %w", c.shell, err)
}
c.cmd = cmd
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
// Wait for process exit.
go func() {
_ = cmd.Wait()
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
c.onClose()
}
}()
return nil
}
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return fmt.Errorf("shell not running")
}
_, err := ptmx.Write(data)
return err
}
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
if ptmx == nil {
return nil
}
return pty.Setsize(ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
c.closed = true
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
if err != nil {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
c.onError(err)
}
return
}
}
}
+1
View File
@@ -51,6 +51,7 @@ func DefaultAppPreferences() AppPreferences {
DefaultScrollback: DefaultScrollback, DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle, DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false, UseNativePaste: false,
LocalShell: "", // empty means use system default
} }
} }
+2
View File
@@ -13,6 +13,7 @@ const (
ProtocolRAW Protocol = "raw" ProtocolRAW Protocol = "raw"
ProtocolGitHub Protocol = "github" ProtocolGitHub Protocol = "github"
ProtocolVNC Protocol = "vnc" ProtocolVNC Protocol = "vnc"
ProtocolLocal Protocol = "local"
) )
// HostKeyPolicy determines how unknown or changed SSH host keys are handled. // HostKeyPolicy determines how unknown or changed SSH host keys are handled.
@@ -302,6 +303,7 @@ type AppPreferences struct {
DefaultScrollback int `json:"defaultScrollback"` DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"` DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"` UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
} }
// AuditEntry records an auditable action within the application. // AuditEntry records an auditable action within the application.
+108
View File
@@ -18,6 +18,7 @@ import (
"github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/githubclient" "github.com/andydixon/nexus/internal/githubclient"
"github.com/andydixon/nexus/internal/localshell"
"github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/rawclient" "github.com/andydixon/nexus/internal/rawclient"
"github.com/andydixon/nexus/internal/recording" "github.com/andydixon/nexus/internal/recording"
@@ -38,6 +39,7 @@ type Session struct {
githubClient *githubclient.Client githubClient *githubclient.Client
githubCancel context.CancelFunc // stops the auto-refresh goroutine githubCancel context.CancelFunc // stops the auto-refresh goroutine
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording recorder *recording.Recorder // active recording, nil if not recording
@@ -163,6 +165,104 @@ func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string)
return sessionID, nil return sessionID, nil
} }
// ConnectLocal creates a local shell session without a saved profile.
// The shell path is resolved from the given parameter or falls back to the system default.
func (m *Manager) ConnectLocal(shell string) (string, error) {
sessionID := uuid.New().String()
now := time.Now()
info := models.SessionInfo{
ID: sessionID,
ProfileID: "",
ProfileName: "Local Machine",
Protocol: models.ProtocolLocal,
Host: "localhost",
Port: 0,
State: models.SessionConnecting,
TabName: "Local",
ConnectedAt: now,
}
sess := &Session{
Info: info,
}
m.mu.Lock()
m.sessions[sessionID] = sess
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnecting),
})
go func() {
client := localshell.NewClient(localshell.Config{
Shell: shell,
Cols: 80,
Rows: 24,
OnData: func(data []byte) {
m.emitEvent("session:data", map[string]interface{}{
"sessionID": sessionID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
OnError: func(err error) {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
OnClose: func() {
m.handleSessionClosed(sessionID)
},
})
if err := client.Start(); err != nil {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
return
}
sess.localClient = client
m.mu.Lock()
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
m.auditLog.LogConnect("", sessionID, "local", "localhost")
}()
return sessionID, nil
}
// Disconnect closes a specific session. // Disconnect closes a specific session.
func (m *Manager) Disconnect(sessionID string) error { func (m *Manager) Disconnect(sessionID string) error {
m.mu.Lock() m.mu.Lock()
@@ -240,6 +340,8 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error {
return sess.telnetClient.Write(data) return sess.telnetClient.Write(data)
case sess.rawClient != nil: case sess.rawClient != nil:
return sess.rawClient.Write(data) return sess.rawClient.Write(data)
case sess.localClient != nil:
return sess.localClient.Write(data)
} }
return fmt.Errorf("sessions: no active client for session %q", sessionID) return fmt.Errorf("sessions: no active client for session %q", sessionID)
} }
@@ -259,6 +361,8 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
return sess.sshClient.Resize(cols, rows) return sess.sshClient.Resize(cols, rows)
case sess.telnetClient != nil: case sess.telnetClient != nil:
return sess.telnetClient.Resize(cols, rows) return sess.telnetClient.Resize(cols, rows)
case sess.localClient != nil:
return sess.localClient.Resize(uint16(cols), uint16(rows))
} }
// RAW sessions have no concept of terminal size. // RAW sessions have no concept of terminal size.
return nil return nil
@@ -1490,6 +1594,10 @@ func (m *Manager) closeSession(sess *Session) {
sess.vncClient.Close() sess.vncClient.Close()
sess.vncClient = nil sess.vncClient = nil
} }
if sess.localClient != nil {
sess.localClient.Close()
sess.localClient = nil
}
m.mu.Lock() m.mu.Lock()
sess.Info.State = models.SessionDisconnected sess.Info.State = models.SessionDisconnected