feat: implement local terminal support with shell configuration and UI integration
This commit is contained in:
@@ -1 +1 @@
|
|||||||
ce0d75b28075a7688ba4535c94b4784c
|
99c6bbb0236daee684251a5f62bbd974
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+17
@@ -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>>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -523,5 +592,6 @@ export namespace models {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user