Merge branch 'main' of github.com:wavetermdev/waveterm into red/aichat-sidebar

This commit is contained in:
Red Adaya 2024-04-26 09:56:50 +08:00
commit 268bfabec2
42 changed files with 1281 additions and 667 deletions

View File

@ -6,7 +6,7 @@
},
"productName": "Wave",
"description": "An Open-Source, AI-Native, Terminal Built for Seamless Workflows",
"version": "0.7.2",
"version": "0.7.3",
"main": "dist/emain.js",
"license": "Apache-2.0",
"repository": {
@ -32,10 +32,10 @@
"electron-squirrel-startup": "^1.0.0",
"electron-updater": "^6.1.8",
"framer-motion": "^10.16.16",
"lexical": "^0.14.3",
"lexical": "0.14.5",
"mobx": "6.12",
"mobx-react": "^7.5.0",
"monaco-editor": "^0.44.0",
"monaco-editor": "0.48.0",
"mustache": "^4.2.0",
"node-fetch": "^3.2.10",
"overlayscrollbars": "^2.6.1",
@ -83,8 +83,8 @@
"babel-loader": "^9.1.3",
"babel-plugin-jsx-control-statements": "^4.1.2",
"copy-webpack-plugin": "^12.0.0",
"css-loader": "^6.7.1",
"electron": "^29.0.1",
"css-loader": "^7.1.0",
"electron": "^30.0.1",
"electron-builder": "^24.13.3",
"electron-builder-squirrel-windows": "^24.13.3",
"file-loader": "^6.2.0",
@ -96,7 +96,7 @@
"prettier": "^2.8.8",
"raw-loader": "^4.0.2",
"react-split-it": "^2.0.0",
"style-loader": "^3.3.1",
"style-loader": "4.0.0",
"typescript": "^5.0.0",
"webpack": "^5.73.0",
"webpack-bundle-analyzer": "^4.10.1",
@ -108,4 +108,4 @@
"scripts": {
"postinstall": "electron-builder install-app-deps"
}
}
}

View File

@ -44,7 +44,7 @@ rm -rf bin/
rm -rf build/
node_modules/.bin/webpack --env prod
WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))')
WAVESHELL_VERSION=v0.6
WAVESHELL_VERSION=v0.7
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
function buildWaveShell {
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)
@ -69,7 +69,7 @@ rm -rf bin/
rm -rf build/
node_modules/.bin/webpack --env prod
WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))')
WAVESHELL_VERSION=v0.6
WAVESHELL_VERSION=v0.7
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
function buildWaveShell {
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)
@ -96,7 +96,7 @@ CGO_ENABLED=1 go build -tags "osusergo,netgo,sqlite_omit_load_extension" -ldflag
```bash
# @scripthaus command fullbuild-waveshell
set -e
WAVESHELL_VERSION=v0.6
WAVESHELL_VERSION=v0.7
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
function buildWaveShell {
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)

View File

@ -4,10 +4,10 @@
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import cn from "classnames";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel } from "@/models";
import { isBlank } from "@/util/util";
import { WorkspaceView } from "./workspace/workspaceview";
@ -22,15 +22,15 @@ import { DisconnectedModal, ClientStopModal } from "@/modals";
import { ModalsProvider } from "@/modals/provider";
import { Button } from "@/elements";
import { ErrorBoundary } from "@/common/error/errorboundary";
import cn from "classnames";
import "./app.less";
import { TermStyleList } from "@/elements";
dayjs.extend(localizedFormat);
import "./app.less";
@mobxReact.observer
class App extends React.Component<{}, {}> {
dcWait: OV<boolean> = mobx.observable.box(false, { name: "dcWait" });
mainContentRef: React.RefObject<HTMLDivElement> = React.createRef();
termThemesLoaded: OV<boolean> = mobx.observable.box(false, { name: "termThemesLoaded" });
constructor(props: {}) {
super(props);
@ -80,6 +80,13 @@ class App extends React.Component<{}, {}> {
rightSidebarModel.saveState(width, false);
}
@boundMethod
handleTermThemesRendered() {
mobx.action(() => {
this.termThemesLoaded.set(true);
})();
}
render() {
const remotesModel = GlobalModel.remotesModel;
const disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get();
@ -90,7 +97,8 @@ class App extends React.Component<{}, {}> {
// Previously, this is done in sidebar.tsx but it causes flicker when clientData is null cos screen-view shifts around.
// Doing it here fixes the flicker cos app is not rendered until clientData is populated.
if (clientData == null) {
// wait for termThemes as well (this actually means that the "connect" packet has been received)
if (clientData == null || GlobalModel.termThemes.get() == null) {
return null;
}
@ -118,52 +126,65 @@ class App extends React.Component<{}, {}> {
if (dcWait) {
setTimeout(() => this.updateDcWait(false), 0);
}
// used to force a full reload of the application
const renderVersion = GlobalModel.renderVersion.get();
const mainSidebarCollapsed = GlobalModel.mainSidebarModel.getCollapsed();
const rightSidebarCollapsed = GlobalModel.rightSidebarModel.getCollapsed();
const activeMainView = GlobalModel.activeMainView.get();
const lightDarkClass = GlobalModel.isDarkTheme.get() ? "is-dark" : "is-light";
const mainClassName = cn(
"platform-" + platform,
{
"mainsidebar-collapsed": mainSidebarCollapsed,
"rightsidebar-collapsed": rightSidebarCollapsed,
},
lightDarkClass
);
return (
<div
key={"version-" + renderVersion}
id="main"
className={cn(
"platform-" + platform,
{ "mainsidebar-collapsed": mainSidebarCollapsed, "rightsidebar-collapsed": rightSidebarCollapsed },
lightDarkClass
)}
onContextMenu={this.handleContextMenu}
>
<If condition={mainSidebarCollapsed}>
<div key="logo-button" className="logo-button-container">
<div className="logo-button-spacer" />
<div className="logo-button" onClick={this.openMainSidebar}>
<img src="public/logos/wave-logo.png" alt="logo" />
<>
<TermStyleList onRendered={this.handleTermThemesRendered} />
<div
key={`version- + ${renderVersion}`}
id="main"
className={mainClassName}
onContextMenu={this.handleContextMenu}
>
<If condition={this.termThemesLoaded.get()}>
<If condition={mainSidebarCollapsed}>
<div key="logo-button" className="logo-button-container">
<div className="logo-button-spacer" />
<div className="logo-button" onClick={this.openMainSidebar}>
<img src="public/logos/wave-logo.png" alt="logo" />
</div>
</div>
</If>
<If condition={GlobalModel.isDev && rightSidebarCollapsed && activeMainView == "session"}>
<div className="right-sidebar-triggers">
<Button
className="secondary ghost right-sidebar-trigger"
onClick={this.openRightSidebar}
>
<i className="fa-sharp fa-solid fa-sidebar-flip"></i>
</Button>
</div>
</If>
<div ref={this.mainContentRef} className="main-content">
<MainSideBar parentRef={this.mainContentRef} />
<ErrorBoundary>
<PluginsView />
<WorkspaceView />
<HistoryView />
<BookmarksView />
<ConnectionsView model={remotesModel} />
<ClientSettingsView model={remotesModel} />
</ErrorBoundary>
<RightSideBar parentRef={this.mainContentRef} />
</div>
</div>
</If>
<If condition={GlobalModel.isDev && rightSidebarCollapsed && activeMainView == "session"}>
<div className="right-sidebar-triggers">
<Button className="secondary ghost right-sidebar-trigger" onClick={this.openRightSidebar}>
<i className="fa-sharp fa-solid fa-sidebar-flip"></i>
</Button>
</div>
</If>
<div ref={this.mainContentRef} className="main-content">
<MainSideBar parentRef={this.mainContentRef} />
<ErrorBoundary>
<PluginsView />
<WorkspaceView />
<HistoryView />
<BookmarksView />
<ConnectionsView model={remotesModel} />
<ClientSettingsView model={remotesModel} />
</ErrorBoundary>
<RightSideBar parentRef={this.mainContentRef} />
<ModalsProvider />
</If>
</div>
<ModalsProvider />
</div>
</>
);
}
}

View File

@ -46,6 +46,10 @@ export const TabIcons = [
"heart",
"file",
];
export const DefaultSudoPwStore = "on";
export const DefaultSudoPwTimeoutMs = 5 * 60 * 1000;
export const MaxWebSocketSendSize = 64 * 1024 - 100;
// @ts-ignore
export const VERSION = __WAVETERM_VERSION__;

View File

@ -76,14 +76,13 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
@boundMethod
handleChangeTermTheme(theme: string): void {
// For global terminal theme, the key is global, otherwise it's either
// For root terminal theme, the key is root, otherwise it's either
// sessionId or screenId.
const currTheme = GlobalModel.getTermTheme()["global"];
const currTheme = GlobalModel.getTermThemeSettings()["root"];
if (currTheme == theme) {
return;
}
const prtn = GlobalCommandRunner.setGlobalTermTheme(theme, false);
const prtn = GlobalCommandRunner.setRootTermTheme(theme, false);
commandRtnHandler(prtn, this.errorMessage);
}
@ -158,6 +157,12 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
commandRtnHandler(prtn, this.errorMessage);
}
@boundMethod
inlineUpdateOpenAITimeout(newTimeout: string): void {
const prtn = GlobalCommandRunner.setClientOpenAISettings({ timeout: newTimeout });
commandRtnHandler(prtn, this.errorMessage);
}
@boundMethod
setErrorMessage(msg: string): void {
mobx.action(() => {
@ -192,6 +197,35 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
GlobalModel.clientSettingsViewModel.closeView();
}
@boundMethod
getSudoPwStoreOptions(): DropdownItem[] {
const sudoCacheSources: DropdownItem[] = [];
sudoCacheSources.push({ label: "On", value: "on" });
sudoCacheSources.push({ label: "Off", value: "off" });
sudoCacheSources.push({ label: "On Without Timeout", value: "notimeout" });
return sudoCacheSources;
}
@boundMethod
handleChangeSudoPwStoreConfig(store: string) {
const prtn = GlobalCommandRunner.setSudoPwStore(store);
commandRtnHandler(prtn, this.errorMessage);
}
@boundMethod
handleChangeSudoPwTimeoutConfig(timeout: string) {
if (Number(timeout) != 0) {
const prtn = GlobalCommandRunner.setSudoPwTimeout(timeout);
commandRtnHandler(prtn, this.errorMessage);
}
}
@boundMethod
handleChangeSudoPwClearOnSleepConfig(clearOnSleep: boolean) {
const prtn = GlobalCommandRunner.setSudoPwClearOnSleep(clearOnSleep);
commandRtnHandler(prtn, this.errorMessage);
}
render() {
const isHidden = GlobalModel.activeMainView.get() != "clientsettings";
if (isHidden) {
@ -204,11 +238,17 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
const maxTokensStr = String(
openAIOpts.maxtokens == null || openAIOpts.maxtokens == 0 ? 1000 : openAIOpts.maxtokens
);
const aiTimeoutStr = String(
openAIOpts.timeout == null || openAIOpts.timeout == 0 ? 10 : openAIOpts.timeout / 1000
);
const curFontSize = GlobalModel.getTermFontSize();
const curFontFamily = GlobalModel.getTermFontFamily();
const curTheme = GlobalModel.getThemeSource();
const termThemes = getTermThemes(GlobalModel.termThemes, "Wave Default");
const currTermTheme = GlobalModel.getTermTheme()["global"] ?? termThemes[0].label;
const termThemes = getTermThemes(GlobalModel.termThemes.get(), "Wave Default");
const currTermTheme = GlobalModel.getTermThemeSettings()["root"] ?? termThemes[0].label;
const curSudoPwStore = GlobalModel.getSudoPwStore();
const curSudoPwTimeout = String(GlobalModel.getSudoPwTimeout());
const curSudoPwClearOnSleep = GlobalModel.getSudoPwClearOnSleep();
return (
<MainView className="clientsettings-view" title="Client Settings" onClose={this.handleClose}>
@ -343,6 +383,19 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">AI Timeout (seconds)</div>
<div className="settings-input">
<InlineSettingsTextEdit
placeholder=""
text={aiTimeoutStr}
value={aiTimeoutStr}
onChange={this.inlineUpdateOpenAITimeout}
maxLength={10}
showIcon={true}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Global Hotkey</div>
<div className="settings-input">
@ -354,6 +407,40 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Remember Sudo Password</div>
<div className="settings-input">
<Dropdown
className="hotkey-dropdown"
options={this.getSudoPwStoreOptions()}
defaultValue={curSudoPwStore}
onChange={this.handleChangeSudoPwStoreConfig}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Sudo Timeout (Minutes)</div>
<div className="settings-input">
<InlineSettingsTextEdit
placeholder=""
text={curSudoPwTimeout}
value={curSudoPwTimeout}
onChange={this.handleChangeSudoPwTimeoutConfig}
maxLength={6}
showIcon={true}
isNumber={true}
/>
</div>
</div>
<div className="settings-field">
<div className="settings-label">Clear Sudo Password on Sleep</div>
<div className="settings-input">
<Toggle
checked={curSudoPwClearOnSleep}
onChange={this.handleChangeSudoPwClearOnSleepConfig}
/>
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />
</div>
</MainView>

View File

@ -258,7 +258,11 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
document.getElementById("app")!
)
: null;
let selectedOptionLabelStyle = {};
const wrapperClientWidth = this.wrapperRef.current?.clientWidth;
if ((wrapperClientWidth ?? 0) > 0) {
selectedOptionLabelStyle["width"] = Math.max(wrapperClientWidth - 55, 0);
}
return (
<div
className={cn("wave-dropdown", className, {
@ -284,7 +288,10 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
</div>
</If>
<div
className={cn("wave-dropdown-display unselectable", { "offset-left": decoration?.startDecoration })}
className={cn("wave-dropdown-display unselectable truncate", {
"offset-left": decoration?.startDecoration,
})}
style={selectedOptionLabelStyle}
>
{selectedOptionLabel}
</div>

View File

@ -8,7 +8,6 @@ export { InputDecoration } from "./inputdecoration";
export { LinkButton } from "./linkbutton";
export { Markdown } from "./markdown";
export { Modal } from "./modal";
export { NumberField } from "./numberfield";
export { PasswordField } from "./passwordfield";
export { ResizableSidebar } from "./resizablesidebar";
export { SettingsError } from "./settingserror";
@ -18,4 +17,5 @@ export { Toggle } from "./toggle";
export { Tooltip } from "./tooltip";
export { TabIcon } from "./tabicon";
export { DatePicker } from "./datepicker";
export { TermStyleList } from "./termstyle";
export { CopyButton } from "./copybutton";

View File

@ -22,6 +22,7 @@ class InlineSettingsTextEdit extends React.Component<
maxLength: number;
placeholder: string;
showIcon?: boolean;
isNumber?: boolean;
},
{}
> {
@ -46,6 +47,12 @@ class InlineSettingsTextEdit extends React.Component<
@boundMethod
handleChangeText(e: any): void {
const isNumber = this.props.isNumber ?? false;
const value = e.target.value;
if (isNumber && value !== "" && !/^\d*$/.test(value)) {
return;
}
mobx.action(() => {
this.tempText.set(e.target.value);
})();

View File

@ -1,39 +0,0 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import { boundMethod } from "autobind-decorator";
import { TextField } from "./textfield";
class NumberField extends TextField {
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const { required, onChange } = this.props;
const inputValue = e.target.value;
// Allow only numeric input
if (inputValue === "" || /^\d*$/.test(inputValue)) {
// Update the internal state only if the component is not controlled.
if (this.props.value === undefined) {
const isError = required ? inputValue.trim() === "" : false;
this.setState({
internalValue: inputValue,
error: isError,
hasContent: Boolean(inputValue),
});
}
onChange && onChange(inputValue);
}
}
render() {
// Use the render method from TextField but add the onKeyDown handler
const renderedTextField = super.render();
return React.cloneElement(renderedTextField);
}
}
export { NumberField };

View File

@ -0,0 +1,133 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import { GlobalModel } from "@/models";
import ReactDOM from "react-dom";
import { For } from "tsx-control-statements/components";
import * as mobx from "mobx";
const VALID_CSS_VARIABLES = [
"--term-black",
"--term-red",
"--term-green",
"--term-yellow",
"--term-blue",
"--term-magenta",
"--term-cyan",
"--term-white",
"--term-bright-black",
"--term-bright-red",
"--term-bright-green",
"--term-bright-yellow",
"--term-bright-blue",
"--term-bright-magenta",
"--term-bright-cyan",
"--term-bright-white",
"--term-gray",
"--term-cmdtext",
"--term-foreground",
"--term-background",
"--term-selection-background",
"--term-cursor-accent",
];
@mobxReact.observer
class TermStyle extends React.Component<{
themeName: string;
selector: string;
}> {
componentDidMount() {
GlobalModel.bumpTermRenderVersion();
}
componentWillUnmount() {
GlobalModel.bumpTermRenderVersion();
}
componentDidUpdate(prevProps) {
if (prevProps.themeName !== this.props.themeName || prevProps.selector !== this.props.selector) {
GlobalModel.bumpTermRenderVersion();
}
}
isValidCSSColor(color) {
const element = document.createElement("div");
element.style.color = color;
return element.style.color !== "";
}
camelCaseToKebabCase(str) {
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
}
getStyleRules() {
const { selector, themeName } = this.props;
const termThemeOptions = GlobalModel.getTermThemes();
if (!(themeName in termThemeOptions)) {
return null;
}
const theme = termThemeOptions[themeName];
if (!theme) {
return null;
}
const styleProperties = Object.entries(theme)
.filter(([key, value]) => {
const cssVarName = `--term-${this.camelCaseToKebabCase(key)}`;
return VALID_CSS_VARIABLES.includes(cssVarName) && this.isValidCSSColor(value);
})
.map(([key, value]) => `--term-${key}: ${value};`)
.join(" ");
if (!styleProperties) {
return null;
}
return `${selector} { ${styleProperties} }`;
}
render() {
const styleRules = this.getStyleRules();
if (!styleRules) {
return null;
}
return ReactDOM.createPortal(<style>{styleRules}</style>, document.head);
}
}
@mobxReact.observer
class TermStyleList extends React.Component<{ onRendered: () => void }, {}> {
componentDidMount(): void {
this.props.onRendered();
}
getSelector(themeKey: string) {
const sessions = GlobalModel.getSessionNames();
const screens = GlobalModel.getScreenNames();
if (themeKey === "root") {
return ":root";
} else if (themeKey in screens) {
return `.main-content [data-screenid="${themeKey}"]`;
} else if (themeKey in sessions) {
return `.main-content [data-sessionid="${themeKey}"]`;
}
return null;
}
render() {
const termTheme = GlobalModel.getTermThemeSettings();
const themeKey = null;
return (
<>
<For index="idx" each="themeKey" of={Object.keys(termTheme)}>
<TermStyle key={themeKey} themeName={termTheme[themeKey]} selector={this.getSelector(themeKey)} />
</For>
</>
);
}
}
export { TermStyleList };

View File

@ -27,6 +27,7 @@ interface TextFieldProps {
maxLength?: number;
autoFocus?: boolean;
disabled?: boolean;
isNumber?: boolean;
}
interface TextFieldState {
@ -108,9 +109,13 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
@boundMethod
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const { required, onChange } = this.props;
const { required, onChange, isNumber } = this.props;
const inputValue = e.target.value;
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
return;
}
// Check if value is empty and the field is required
if (required && !inputValue) {
this.setState({ error: true, hasContent: false });

View File

@ -7,7 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models";
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements";
import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements";
import * as util from "@/util/util";
import "./createremoteconn.less";
@ -236,11 +236,12 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
/>
</div>
<div className="port-section">
<NumberField
<TextField
label="Port"
placeholder="22"
value={this.tempPort.get()}
onChange={this.handleChangePort}
isNumber={true}
decoration={{
endDecoration: (
<InputDecoration>

View File

@ -156,7 +156,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
@boundMethod
handleChangeTermTheme(theme: string): void {
const currTheme = GlobalModel.getTermTheme()[this.screenId];
const currTheme = GlobalModel.getTermThemeSettings()[this.screenId];
if (currTheme == theme) {
return;
}
@ -175,13 +175,8 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
if (screen == null) {
return null;
}
let color: string = null;
let icon: string = null;
let index: number = 0;
const curRemote = GlobalModel.getRemote(GlobalModel.getActiveScreen().getCurRemoteInstance().remoteid);
const termThemes = getTermThemes(GlobalModel.termThemes);
const currTermTheme = GlobalModel.getTermTheme()[this.screenId] ?? termThemes[0].label;
const termThemes = getTermThemes(GlobalModel.termThemes.get());
const currTermTheme = GlobalModel.getTermThemeSettings()[this.screenId] ?? termThemes[0].label;
return (
<Modal className="screen-settings-modal">
<Modal.Header onClose={this.closeModal} title={`Tab Settings (${screen.name.get()})`} />

View File

@ -80,7 +80,7 @@ class SessionSettingsModal extends React.Component<{}, {}> {
@boundMethod
handleChangeTermTheme(theme: string): void {
const currTheme = GlobalModel.getTermTheme()[this.sessionId];
const currTheme = GlobalModel.getTermThemeSettings()[this.sessionId];
if (currTheme == theme) {
return;
}
@ -99,8 +99,8 @@ class SessionSettingsModal extends React.Component<{}, {}> {
if (this.session == null) {
return null;
}
const termThemes = getTermThemes(GlobalModel.termThemes);
const currTermTheme = GlobalModel.getTermTheme()[this.sessionId] ?? termThemes[0].label;
const termThemes = getTermThemes(GlobalModel.termThemes.get());
const currTermTheme = GlobalModel.getTermThemeSettings()[this.sessionId] ?? termThemes[0].label;
return (
<Modal className="session-settings-modal">

View File

@ -649,11 +649,6 @@ class LineContainer extends React.Component<{ historyId: string; width: number }
this.line = hvm.getLineById(this.historyItem.lineid);
}
componentDidMount(): void {
GlobalModel.bumpTermRenderVersion();
GlobalModel.termThemeSrcEl.set(null);
}
@boundMethod
handleHeightChange(lineNum: number, newHeight: number, oldHeight: number): void {
return;

View File

@ -31,6 +31,8 @@
flex-direction: row;
color: var(--app-warning-color);
align-items: center;
padding: var(--termpad) calc(var(--termpad) * 2) 0 calc(var(--termpad) * 2);
margin-left: 2px;
.wave-button,
.button {

View File

@ -13,6 +13,7 @@ import { getMonoFontSize } from "@/util/textmeasure";
import * as appconst from "@/app/appconst";
type OV<T> = mobx.IObservableValue<T>;
const MaxInputLength = 10 * 1024;
function pageSize(div: any): number {
if (div == null) {
@ -616,7 +617,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
</If>
<If condition={renderHistoryKeybindings}>
<HistoryKeybindings inputObject={this}></HistoryKeybindings>
<HistoryKeybindings></HistoryKeybindings>
</If>
<If condition={!util.isBlank(shellType)}>
@ -637,6 +638,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
onChange={this.onChange}
onSelect={this.onSelect}
placeholder="Type here..."
maxLength={MaxInputLength}
className={cn("textarea", { "display-disabled": auxViewFocused })}
></textarea>
<input

View File

@ -184,10 +184,12 @@ class ScreenView extends React.Component<{ session: Session; screen: Screen }, {
winWidth = screenWidth - realWidth + "px";
sidebarWidth = realWidth - MagicLayout.ScreenSidebarWidthPadding + "px";
}
const termRenderVersion = GlobalModel.termRenderVersion.get();
return (
<div className="screen-view" data-screenid={screen.screenId} ref={this.screenViewRef}>
<div className="screen-view" id={screen.screenId} data-screenid={screen.screenId} ref={this.screenViewRef}>
<ScreenWindowView
key={screen.screenId + ":" + fontSize + ":" + dprStr}
key={screen.screenId + ":" + fontSize + ":" + dprStr + ":" + termRenderVersion}
session={session}
screen={screen}
width={winWidth}

View File

@ -144,7 +144,7 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
@boundMethod
handleChangeTermTheme(theme: string): void {
const { screenId } = this.props.screen;
const currTheme = GlobalModel.getTermTheme()[screenId];
const currTheme = GlobalModel.getTermThemeSettings()[screenId];
if (currTheme == theme) {
return;
}
@ -155,8 +155,8 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
render() {
const { screen } = this.props;
const rptr = screen.curRemote.get();
const termThemes = getTermThemes(GlobalModel.termThemes);
const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label;
const termThemes = getTermThemes(GlobalModel.termThemes.get());
const currTermTheme = GlobalModel.getTermThemeSettings()[screen.screenId] ?? termThemes[0].label;
return (
<div className="newtab-container">
<div className="newtab-section name-section">
@ -212,59 +212,6 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
@mobxReact.observer
class WorkspaceView extends React.Component<{}, {}> {
sessionRef = React.createRef<HTMLDivElement>();
theme: string;
themeReactionDisposer: mobx.IReactionDisposer;
componentDidMount() {
this.setupThemeReaction();
}
componentDidUpdate() {
this.setupThemeReaction();
}
setupThemeReaction() {
if (this.themeReactionDisposer) {
this.themeReactionDisposer();
}
// This handles session and screen-level terminal theming.
// Ideally, screen-level theming should be handled in the inner-level component, but
// the frequent mounting and unmounting of the screen view make it really difficult to work.
this.themeReactionDisposer = mobx.reaction(
() => {
return {
termTheme: GlobalModel.getTermTheme(),
session: GlobalModel.getActiveSession(),
screen: GlobalModel.getActiveScreen(),
};
},
({ termTheme, session, screen }) => {
let currTheme = termTheme[session.sessionId];
if (termTheme[screen.screenId]) {
currTheme = termTheme[screen.screenId];
}
if (session && currTheme !== this.theme && this.sessionRef.current) {
const reset = currTheme == null;
const theme = currTheme ?? this.theme;
const themeSrcEl = reset ? null : this.sessionRef.current;
const rtn = GlobalModel.updateTermTheme(this.sessionRef.current, theme, reset);
rtn.then(() => {
GlobalModel.termThemeSrcEl.set(themeSrcEl);
}).then(() => {
GlobalModel.bumpTermRenderVersion();
});
this.theme = currTheme;
}
}
);
}
componentWillUnmount() {
if (this.themeReactionDisposer) {
this.themeReactionDisposer();
}
}
@boundMethod
toggleTabSettings() {
@ -281,15 +228,14 @@ class WorkspaceView extends React.Component<{}, {}> {
sessionId = session.sessionId;
activeScreen = session.getActiveScreen();
}
const isHidden = GlobalModel.activeMainView.get() != "session";
const mainSidebarModel = GlobalModel.mainSidebarModel;
const termRenderVersion = GlobalModel.termRenderVersion.get();
const showTabSettings = GlobalModel.tabSettingsOpen.get();
return (
<div
ref={this.sessionRef}
className={cn("mainview", "session-view", { "is-hidden": isHidden })}
id={sessionId}
data-sessionid={sessionId}
style={{
width: `${window.innerWidth - mainSidebarModel.getWidth()}px`,
@ -311,11 +257,7 @@ class WorkspaceView extends React.Component<{}, {}> {
</div>
</If>
<ErrorBoundary key="eb">
<ScreenView
key={`screenview-${sessionId}-${termRenderVersion}`}
session={session}
screen={activeScreen}
/>
<ScreenView key={`screenview-${sessionId}`} session={session} screen={activeScreen} />
<If condition={activeScreen != null}>
<CmdInput key={"cmdinput-" + sessionId} />
</If>

View File

@ -419,6 +419,17 @@ function mainResizeHandler(_: any, win: Electron.BrowserWindow) {
});
}
function mainPowerHandler(status: string) {
const url = new URL(getBaseHostPort() + "/api/power-monitor");
const fetchHeaders = getFetchHeaders();
const body = { status: status };
fetch(url, { method: "post", body: JSON.stringify(body), headers: fetchHeaders })
.then((resp) => handleJsonFetchResponse(url, resp))
.catch((err) => {
console.log("error setting power monitor state", err);
});
}
function calcBounds(clientData: ClientDataType): Electron.Rectangle {
const primaryDisplay = electron.screen.getPrimaryDisplay();
const pdBounds = primaryDisplay.bounds;
@ -946,3 +957,5 @@ function configureAutoUpdater(enabled: boolean) {
}
});
})();
electron.powerMonitor.on("suspend", () => mainPowerHandler("suspend"));

View File

@ -381,29 +381,40 @@ class CommandRunner {
return GlobalModel.submitCommand("client", "set", null, kwargs, interactive);
}
setGlobalTermTheme(theme: string, interactive: boolean): Promise<CommandRtnType> {
setRootTermTheme(theme: string, interactive: boolean): Promise<CommandRtnType> {
let ftheme = theme;
if (ftheme == "inherit") {
ftheme = "";
}
let kwargs = {
nohist: "1",
termtheme: theme,
termtheme: ftheme,
};
return GlobalModel.submitCommand("client", "set", null, kwargs, interactive);
}
setSessionTermTheme(sessionId: string, name: string, interactive: boolean): Promise<CommandRtnType> {
console.log("setSessionTermTheme-------");
let fname = name;
if (name == "inherit") {
fname = "";
}
let kwargs = {
nohist: "1",
id: sessionId,
name: name,
name: fname,
};
return GlobalModel.submitCommand("session", "termtheme", null, kwargs, interactive);
}
setScreenTermTheme(screenId: string, name: string, interactive: boolean): Promise<CommandRtnType> {
let fname = name;
if (name == "inherit") {
fname = "";
}
let kwargs = {
nohist: "1",
id: screenId,
name: name,
name: fname,
};
return GlobalModel.submitCommand("screen", "termtheme", null, kwargs, interactive);
}
@ -413,6 +424,7 @@ class CommandRunner {
apitoken?: string;
maxtokens?: string;
baseurl?: string;
timeout?: string;
}): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
@ -429,6 +441,9 @@ class CommandRunner {
if (opts.baseurl != null) {
kwargs["openaibaseurl"] = opts.baseurl;
}
if (opts.timeout != null) {
kwargs["openaitimeout"] = opts.timeout;
}
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
@ -452,6 +467,31 @@ class CommandRunner {
return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false);
}
setSudoPwStore(store: string): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
sudopwstore: store,
};
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
setSudoPwTimeout(timeout: string): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
sudopwtimeout: timeout,
};
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
setSudoPwClearOnSleep(clear: boolean): Promise<CommandRtnType> {
let kwargs = {
nohist: "1",
sudopwclearonsleep: String(clear),
};
console.log(kwargs);
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
}
editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
let kwargs = {
nohist: "1",

View File

@ -137,22 +137,16 @@ class Model {
renderVersion: OV<number> = mobx.observable.box(0, {
name: "renderVersion",
});
appUpdateStatus = mobx.observable.box(getApi().getAppUpdateStatus(), {
name: "appUpdateStatus",
});
termThemes: OMap<string, string> = mobx.observable.array([], {
termThemes: OV<TermThemesType> = mobx.observable.box(null, {
name: "terminalThemes",
deep: false,
});
termThemeSrcEl: OV<HTMLElement> = mobx.observable.box(null, {
name: "termThemeSrcEl",
});
termRenderVersion: OV<number> = mobx.observable.box(0, {
name: "termRenderVersion",
});
currGlobalTermTheme: string;
private constructor() {
this.clientId = getApi().getId();
@ -166,7 +160,6 @@ class Model {
this.ws.reconnect();
this.keybindManager = new KeybindManager(this);
this.readConfigKeybindings();
this.fetchTerminalThemes();
this.initSystemKeybindings();
this.initAppKeybindings();
this.inputModel = new InputModel(this);
@ -239,42 +232,6 @@ class Model {
}
}
fetchTerminalThemes() {
const url = new URL(this.getBaseHostPort() + "/config/terminal-themes");
fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
.then((resp) => {
if (resp.status == 404) {
return [];
} else if (!resp.ok) {
util.handleNotOkResp(resp, url);
}
return resp.json();
})
.then((themes) => {
const tt = themes.map((theme) => theme.name.split(".")[0]);
this.termThemes.replace(tt);
});
}
updateTermTheme(element: HTMLElement, themeFileName: string, reset: boolean) {
const url = new URL(this.getBaseHostPort() + `/config/terminal-themes/${themeFileName}.json`);
return fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
.then((resp) => resp.json())
.then((themeVars: TermThemeType) => {
Object.keys(themeVars).forEach((key) => {
if (reset) {
this.resetStyleVar(element, `--term-${key}`);
} else {
this.resetStyleVar(element, `--term-${key}`);
this.setStyleVar(element, `--term-${key}`, themeVars[key]);
}
});
})
.catch((error) => {
console.error(`error applying theme: ${themeFileName}`, error);
});
}
bumpTermRenderVersion() {
mobx.action(() => {
this.termRenderVersion.set(this.termRenderVersion.get() + 1);
@ -486,10 +443,10 @@ class Model {
}
}
getTermTheme(): TermThemeType {
getTermThemeSettings(): TermThemeSettingsType {
let cdata = this.clientData.get();
if (cdata?.feopts?.termtheme) {
return mobx.toJS(cdata.feopts.termtheme);
if (cdata?.feopts?.termthemesettings) {
return mobx.toJS(cdata.feopts.termthemesettings);
}
return {};
}
@ -498,6 +455,22 @@ class Model {
return this.termFontSize.get();
}
getSudoPwStore(): string {
let cdata = this.clientData.get();
return cdata?.feopts?.sudopwstore ?? appconst.DefaultSudoPwStore;
}
getSudoPwTimeout(): number {
let cdata = this.clientData.get();
const sudoPwTimeoutMs = cdata?.feopts?.sudopwtimeoutms ?? appconst.DefaultSudoPwTimeoutMs;
return sudoPwTimeoutMs / 1000 / 60;
}
getSudoPwClearOnSleep(): boolean {
let cdata = this.clientData.get();
return !cdata?.feopts?.nosudopwclearonsleep;
}
updateTermFontSizeVars() {
let lhe = this.recomputeLineHeightEnv();
mobx.action(() => {
@ -937,6 +910,27 @@ class Model {
}
}
mergeTermThemes(termThemes: TermThemesType) {
mobx.action(() => {
if (this.termThemes.get() == null) {
this.termThemes.set(termThemes);
return;
}
for (const [themeName, theme] of Object.entries(termThemes)) {
if (theme == null) {
delete this.termThemes.get()[themeName];
continue;
}
this.termThemes.get()[themeName] = theme;
}
})();
this.bumpTermRenderVersion();
}
getTermThemes(): TermThemesType {
return this.termThemes.get();
}
updateScreenStatusIndicators(screenStatusIndicators: ScreenStatusIndicatorUpdateType[]) {
for (const update of screenStatusIndicators) {
this.getScreenById_single(update.screenid)?.setStatusIndicator(update.status);
@ -983,7 +977,7 @@ class Model {
if (update.connect.screenstatusindicators != null) {
this.updateScreenStatusIndicators(update.connect.screenstatusindicators);
}
this.mergeTermThemes(update.connect.termthemes ?? {});
this.sessionListLoaded.set(true);
this.remotesLoaded.set(true);
} else if (update.screen != null) {
@ -1056,6 +1050,8 @@ class Model {
} else if (update.userinputrequest != null) {
const userInputRequest: UserInputRequest = update.userinputrequest;
this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest);
} else if (update.termthemes != null) {
this.mergeTermThemes(update.termthemes);
} else if (update.sessiontombstone != null || update.screentombstone != null) {
// nothing (ignore)
} else {
@ -1309,9 +1305,6 @@ class Model {
newTheme = appconst.DefaultTheme;
}
const themeUpdated = newTheme != this.getThemeSource();
const oldTermTheme = this.getTermTheme();
const newTermTheme = clientData?.feopts?.termtheme;
const ttUpdated = this.termThemeUpdated(newTermTheme, oldTermTheme);
mobx.action(() => {
this.clientData.set(clientData);
})();
@ -1332,36 +1325,6 @@ class Model {
getApi().setNativeThemeSource(newTheme);
this.bumpRenderVersion();
}
// Only for global terminal theme. For session and screen terminal theme,
// they are handled in workspace view.
if (newTermTheme) {
const el = document.documentElement;
const globaltt = newTermTheme["global"] ?? this.currGlobalTermTheme;
const reset = newTermTheme["global"] == null;
if (globaltt) {
const rtn = this.updateTermTheme(el, globaltt, reset);
rtn.then(() => {
if (ttUpdated) {
this.bumpTermRenderVersion();
}
});
this.currGlobalTermTheme = globaltt;
}
}
}
termThemeUpdated(newTermTheme, oldTermTheme) {
for (const key in oldTermTheme) {
if (!(key in newTermTheme)) {
return true;
}
}
for (const key in newTermTheme) {
if (!oldTermTheme[key] || oldTermTheme[key] !== newTermTheme[key]) {
return true;
}
}
return false;
}
submitCommandPacket(cmdPk: FeCmdPacketType, interactive: boolean): Promise<CommandRtnType> {

View File

@ -5,6 +5,7 @@ import * as mobx from "mobx";
import { sprintf } from "sprintf-js";
import { boundMethod } from "autobind-decorator";
import dayjs from "dayjs";
import * as appconst from "@/app/appconst";
class WSControl {
wsConn: any;
@ -171,7 +172,13 @@ class WSControl {
if (!this.open.get()) {
return;
}
this.wsConn.send(JSON.stringify(data));
let msg = JSON.stringify(data);
const byteSize = new Blob([msg]).size;
if (byteSize > appconst.MaxWebSocketSendSize) {
console.log("ws message too large", byteSize, data.type, msg.substring(0, 100));
return;
}
this.wsConn.send(msg);
}
pushMessage(data: any) {

View File

@ -229,7 +229,6 @@ class SourceCodeRenderer extends React.Component<
}, 2000);
editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
console.log("keydown?", waveEvent);
if (
GlobalModel.keybindManager.checkKeysPressed(waveEvent, [
"codeedit:save",
@ -568,7 +567,6 @@ class SourceCodeRenderer extends React.Component<
);
const theme = `wave-theme-${GlobalModel.isDarkTheme.get() ? "dark" : "light"}`;
console.log("lineis selected:", lineIsSelected.get());
return (
<div className="code-renderer">
<If condition={lineIsSelected.get()}>

View File

@ -51,10 +51,9 @@ type TermWrapOpts = {
onUpdateContentHeight: (termContext: RendererContext, height: number) => void;
};
function getThemeFromCSSVars(themeSrcEl: HTMLElement): ITheme {
function getThemeFromCSSVars(el: Element): ITheme {
const theme: ITheme = {};
const tse = themeSrcEl ?? document.documentElement;
let rootStyle = getComputedStyle(tse);
const rootStyle = getComputedStyle(el);
theme.foreground = rootStyle.getPropertyValue("--term-foreground");
theme.background = rootStyle.getPropertyValue("--term-background");
theme.black = rootStyle.getPropertyValue("--term-black");
@ -131,8 +130,7 @@ class TermWrap {
let cols = windowWidthToCols(opts.winSize.width, opts.fontSize);
this.termSize = { rows: opts.termOpts.rows, cols: cols };
}
const themeSrcEl = GlobalModel.termThemeSrcEl.get();
let theme = getThemeFromCSSVars(themeSrcEl);
let theme = getThemeFromCSSVars(this.connectedElem);
this.terminal = new Terminal({
rows: this.termSize.rows,
cols: this.termSize.cols,

16
src/types/custom.d.ts vendored
View File

@ -345,6 +345,7 @@ declare global {
screenstatusindicators: ScreenStatusIndicatorUpdateType[];
screennumrunningcommands: ScreenNumRunningCommandsUpdateType[];
activesessionid: string;
termthemes: TermThemesType;
};
type BookmarksUpdateType = {
@ -386,6 +387,13 @@ declare global {
userinputrequest?: UserInputRequest;
screentombstone?: any;
sessiontombstone?: any;
termthemes?: TermThemesType;
};
type TermThemesType = {
[key: string]: {
[innerKey: string]: string;
};
};
type HistoryViewDataType = {
@ -581,7 +589,7 @@ declare global {
data: Uint8Array;
};
type TermThemeType = {
type TermThemeSettingsType = {
[k: string]: string | null;
};
@ -589,7 +597,10 @@ declare global {
termfontsize: number;
termfontfamily: string;
theme: NativeThemeSource;
termtheme: TermThemeType;
termthemesettings: TermThemeSettingsType;
sudopwstore: "on" | "off" | "notimeout";
sudopwtimeoutms: number;
nosudopwclearonsleep: boolean;
};
type ConfirmFlagsType = {
@ -651,6 +662,7 @@ declare global {
maxtokens?: number;
maxchoices?: number;
baseurl?: string;
timeout?: number;
};
type PlaybookType = {

View File

@ -1,10 +1,13 @@
function getTermThemes(termThemes: string[], noneLabel = "Inherit"): DropdownItem[] {
function getTermThemes(termThemeOptions: string[], noneLabel = "Inherit"): DropdownItem[] {
if (!termThemeOptions) {
return [];
}
const tt: DropdownItem[] = [];
tt.push({
label: noneLabel,
value: null,
value: "inherit",
});
for (const themeName of termThemes) {
for (const themeName of Object.keys(termThemeOptions)) {
tt.push({
label: themeName,
value: themeName,

View File

@ -29,7 +29,7 @@ const SSHCommandVarName = "SSH_COMMAND"
const MShellDebugVarName = "MSHELL_DEBUG"
const SessionsDirBaseName = "sessions"
const RcFilesDirBaseName = "rcfiles"
const MShellVersion = "v0.6.0"
const MShellVersion = "v0.7.0"
const RemoteIdFile = "remoteid"
const DefaultMShellInstallBinDir = "/opt/mshell/bin"
const LogFileName = "mshell.log"

View File

@ -197,11 +197,7 @@ func StreamCommandWithExtraFd(ctx context.Context, ecmd *exec.Cmd, outputCh chan
go func() {
// ignore error (/dev/ptmx has read error when process is done)
defer outputWg.Done()
err := utilfn.CopyToChannel(outputCh, cmdPty)
if err != nil {
errStr := fmt.Sprintf("\r\nerror reading from pty: %v\r\n", err)
outputCh <- []byte(errStr)
}
utilfn.CopyToChannel(outputCh, cmdPty)
}()
go func() {
defer outputWg.Done()

View File

@ -37,6 +37,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
"github.com/wavetermdev/waveterm/wavesrv/pkg/bufferedpipe"
"github.com/wavetermdev/waveterm/wavesrv/pkg/cmdrunner"
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/ephemeral"
"github.com/wavetermdev/waveterm/wavesrv/pkg/pcloud"
"github.com/wavetermdev/waveterm/wavesrv/pkg/releasechecker"
@ -156,6 +157,7 @@ func HandleWs(w http.ResponseWriter, r *http.Request) {
removeWSStateAfterTimeout(clientId, stateConnectTime, WSStateReconnectTime)
}()
log.Printf("WebSocket opened %s %s\n", state.ClientId, shell.RemoteAddr)
state.RunWSRead()
}
@ -207,6 +209,33 @@ func HandleSetWinSize(w http.ResponseWriter, r *http.Request) {
WriteJsonSuccess(w, true)
}
func HandlePowerMonitor(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var body sstore.PowerMonitorEventType
err := decoder.Decode(&body)
if err != nil {
WriteJsonError(w, fmt.Errorf(ErrorDecodingJson, err))
return
}
cdata, err := sstore.EnsureClientData(r.Context())
if err != nil {
WriteJsonError(w, err)
return
}
switch body.Status {
case "suspend":
if !cdata.FeOpts.NoSudoPwClearOnSleep && cdata.FeOpts.SudoPwStore != "notimeout" {
for _, proc := range remote.GetRemoteMap() {
proc.ClearCachedSudoPw()
}
}
WriteJsonSuccess(w, true)
default:
WriteJsonError(w, fmt.Errorf("unknown status: %s", body.Status))
return
}
}
// params: fg, active, open
func HandleLogActiveState(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
@ -976,6 +1005,10 @@ func doShutdown(reason string) {
log.Printf("[wave] closing db connection\n")
sstore.CloseDB()
log.Printf("[wave] *** shutting down local server\n")
watcher := configstore.GetWatcher()
if watcher != nil {
watcher.Close()
}
time.Sleep(1 * time.Second)
syscall.Kill(syscall.Getpid(), syscall.SIGINT)
time.Sleep(5 * time.Second)
@ -984,7 +1017,6 @@ func doShutdown(reason string) {
}
func configDirHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("running?")
configPath := r.URL.Path
configFullPath := path.Join(scbase.GetWaveHomeDir(), configPath)
dirFile, err := os.Open(configFullPath)
@ -1016,6 +1048,13 @@ func configDirHandler(w http.ResponseWriter, r *http.Request) {
w.Write(dirListJson)
}
func configWatcher() {
watcher := configstore.GetWatcher()
if watcher != nil {
watcher.Start()
}
}
func startupActivityUpdate() {
activity := telemetry.ActivityUpdate{
NumConns: remote.NumRemotes(),
@ -1080,7 +1119,7 @@ func main() {
log.Printf("[error] %v\n", err)
return
}
_, err = scbase.EnsureConfigDir()
_, err = scbase.EnsureConfigDirs()
if err != nil {
log.Printf("[error] ensuring config directory: %v\n", err)
return
@ -1120,6 +1159,7 @@ func main() {
startupActivityUpdate()
installSignalHandlers()
go telemetryLoop()
go configWatcher()
go stdinReadWatch()
go runWebSocketServer()
go func() {
@ -1136,6 +1176,7 @@ func main() {
gr.HandleFunc(bufferedpipe.BufferedPipeGetterUrl, AuthKeyWrapAllowHmac(bufferedpipe.HandleGetBufferedPipeOutput))
gr.HandleFunc("/api/get-client-data", AuthKeyWrap(HandleGetClientData))
gr.HandleFunc("/api/set-winsize", AuthKeyWrap(HandleSetWinSize))
gr.HandleFunc("/api/power-monitor", AuthKeyWrap(HandlePowerMonitor))
gr.HandleFunc("/api/log-active-state", AuthKeyWrap(HandleLogActiveState))
gr.HandleFunc("/api/read-file", AuthKeyWrapAllowHmac(HandleReadFile))
gr.HandleFunc("/api/write-file", AuthKeyWrap(HandleWriteFile)).Methods("POST")

View File

@ -8,6 +8,7 @@ require (
github.com/alessio/shellescape v1.4.1
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
github.com/creack/pty v1.1.18
github.com/fsnotify/fsnotify v1.6.0
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/google/go-github/v60 v60.0.0
github.com/google/uuid v1.3.0

View File

@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
@ -63,6 +65,7 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=

View File

@ -84,9 +84,9 @@ const TermFontSizeMax = 24
const TsFormatStr = "2006-01-02 15:04:05"
const OpenAIPacketTimeout = 10 * time.Second
const OpenAIPacketTimeout = 10 * 1000 * time.Millisecond
const OpenAIStreamTimeout = 5 * time.Minute
const OpenAICloudCompletionTelemetryOffErrorMsg = "To ensure responsible usage and prevent misuse, Wave AI requires telemetry to be enabled when using its free AI features.\n\nIf you prefer not to enable telemetry, you can still access Wave AI's features by providing your own OpenAI API key in the Settings menu. Please note that when using your personal API key, requests will be sent directly to the OpenAI API without being proxied through Wave's servers.\n\nIf you wish to continue using Wave AI's free features, you can easily enable telemetry by running the '/telemetry:on' command in the terminal. This will allow you to access the free AI features while helping to protect the platform from abuse."
const OpenAICloudCompletionTelemetryOffErrorMsg = "To ensure responsible usage and prevent misuse, Wave AI requires telemetry to be enabled when using its free AI features.\n\nIf you prefer not to enable telemetry, you can still access Wave AI's features by providing your own OpenAI API key or AI Base URL in the Settings menu. Please note that when using your personal API key, requests will be sent directly to the OpenAI API or the API that you specified with the AI Base URL, without being proxied through Wave's servers.\n\nIf you wish to continue using Wave AI's free features, you can easily enable telemetry by running the '/telemetry:on' command in the terminal. This will allow you to access the free AI features while helping to protect the platform from abuse."
const (
KwArgRenderer = "renderer"
@ -642,10 +642,17 @@ func RunCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.Up
}
runPacket.Command = strings.TrimSpace(cmdStr)
runPacket.ReturnState = resolveBool(pk.Kwargs["rtnstate"], isRtnStateCmd)
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
}
feOpts := clientData.FeOpts
if sudoArg, ok := pk.Kwargs[KwArgSudo]; ok {
runPacket.IsSudo = resolveBool(sudoArg, false)
runPacket.IsSudo = resolveBool(sudoArg, false) && feOpts.SudoPwStore != "off"
} else {
runPacket.IsSudo = IsSudoCommand(cmdStr)
runPacket.IsSudo = IsSudoCommand(cmdStr) && feOpts.SudoPwStore != "off"
}
rcOpts := remote.RunCommandOpts{
SessionId: ids.SessionId,
@ -2693,8 +2700,6 @@ func getCmdInfoEngineeredPrompt(userQuery string, curLineStr string, shellType s
}
func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType, curLineStr string) {
var hadError bool
log.Println("had error: ", hadError)
ctx, cancelFn := context.WithTimeout(context.Background(), OpenAIStreamTimeout)
defer cancelFn()
defer func() {
@ -2702,7 +2707,6 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
if r != nil {
panicMsg := fmt.Sprintf("panic: %v", r)
log.Printf("panic in doOpenAICompletion: %s\n", panicMsg)
hadError = true
}
}()
var ch chan *packet.OpenAIPacketType
@ -2730,12 +2734,15 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
return
}
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
packetTimeout := OpenAIPacketTimeout
if opts.Timeout >= 0 {
packetTimeout = time.Duration(opts.Timeout) * time.Millisecond
}
doneWaitingForPackets := false
for !doneWaitingForPackets {
select {
case <-time.After(OpenAIPacketTimeout):
case <-time.After(packetTimeout):
// timeout reading from channel
hadError = true
doneWaitingForPackets = true
asstOutputPk.Error = "timeout waiting for server response"
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
@ -2743,7 +2750,6 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
if ok {
// got a packet
if pk.Error != "" {
hadError = true
asstOutputPk.Error = pk.Error
}
if pk.Model != "" && pk.Index == 0 {
@ -2823,10 +2829,14 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
writeErrorToPty(cmd, fmt.Sprintf("error calling OpenAI API: %v", err), outputPos)
return
}
packetTimeout := OpenAIPacketTimeout
if opts.Timeout >= 0 {
packetTimeout = time.Duration(opts.Timeout) * time.Millisecond
}
doneWaitingForPackets := false
for !doneWaitingForPackets {
select {
case <-time.After(OpenAIPacketTimeout):
case <-time.After(packetTimeout):
// timeout reading from channel
hadError = true
pk := openai.CreateErrorPacket(fmt.Sprintf("timeout waiting for server response"))
@ -2895,7 +2905,7 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus
return nil, fmt.Errorf("error retrieving client open ai options")
}
opts := clientData.OpenAIOpts
if opts.APIToken == "" {
if opts.APIToken == "" && opts.BaseURL == "" {
if clientData.ClientOpts.NoTelemetry {
return nil, fmt.Errorf(OpenAICloudCompletionTelemetryOffErrorMsg)
}
@ -3640,13 +3650,13 @@ func TermSetThemeCommand(ctx context.Context, pk *scpacket.FeCommandPacketType)
}
themeName, themeNameOk := pk.Kwargs["name"]
feOpts := clientData.FeOpts
if feOpts.TermTheme == nil {
feOpts.TermTheme = make(map[string]string)
if feOpts.TermThemeSettings == nil {
feOpts.TermThemeSettings = make(map[string]string)
}
if themeNameOk && themeName != "" {
feOpts.TermTheme[id] = themeName
feOpts.TermThemeSettings[id] = themeName
} else {
delete(feOpts.TermTheme, id)
delete(feOpts.TermThemeSettings, id)
}
err = sstore.UpdateClientFeOpts(ctx, feOpts)
if err != nil {
@ -5798,6 +5808,22 @@ func validateFontFamily(fontFamily string) error {
return nil
}
func CheckOptionAlias(kwargs map[string]string, aliases ...string) (string, bool) {
for _, alias := range aliases {
if val, found := kwargs[alias]; found {
return val, found
}
}
return "", false
}
func validateSudoPwStore(config string) error {
if utilfn.ContainsStr([]string{"on", "off", "notimeout"}, config) {
return nil
}
return fmt.Errorf("%s is not a config option", config)
}
func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
@ -5856,13 +5882,13 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
}
if termthemeStr, found := pk.Kwargs["termtheme"]; found {
feOpts := clientData.FeOpts
if feOpts.TermTheme == nil {
feOpts.TermTheme = make(map[string]string)
if feOpts.TermThemeSettings == nil {
feOpts.TermThemeSettings = make(map[string]string)
}
if termthemeStr == "" {
delete(feOpts.TermTheme, "global")
delete(feOpts.TermThemeSettings, "root")
} else {
feOpts.TermTheme["global"] = termthemeStr
feOpts.TermThemeSettings["root"] = termthemeStr
}
err = sstore.UpdateClientFeOpts(ctx, feOpts)
if err != nil {
@ -5870,7 +5896,7 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
}
varsUpdated = append(varsUpdated, "termtheme")
}
if apiToken, found := pk.Kwargs["openaiapitoken"]; found {
if apiToken, found := CheckOptionAlias(pk.Kwargs, "openaiapitoken", "aiapitoken"); found {
err = validateOpenAIAPIToken(apiToken)
if err != nil {
return nil, err
@ -5884,10 +5910,10 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
aiOpts.APIToken = apiToken
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client openai api token: %v", err)
return nil, fmt.Errorf("error updating client ai api token: %v", err)
}
}
if aiModel, found := pk.Kwargs["openaimodel"]; found {
if aiModel, found := CheckOptionAlias(pk.Kwargs, "openaimodel", "aimodel"); found {
err = validateOpenAIModel(aiModel)
if err != nil {
return nil, err
@ -5901,16 +5927,16 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
aiOpts.Model = aiModel
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client openai model: %v", err)
return nil, fmt.Errorf("error updating client ai model: %v", err)
}
}
if maxTokensStr, found := pk.Kwargs["openaimaxtokens"]; found {
if maxTokensStr, found := CheckOptionAlias(pk.Kwargs, "openaimaxtokens", "aimaxtokens"); found {
maxTokens, err := strconv.Atoi(maxTokensStr)
if err != nil {
return nil, fmt.Errorf("error updating client openai maxtokens, invalid number: %v", err)
return nil, fmt.Errorf("error updating client ai maxtokens, invalid number: %v", err)
}
if maxTokens < 0 || maxTokens > 1000000 {
return nil, fmt.Errorf("error updating client openai maxtokens, out of range: %d", maxTokens)
return nil, fmt.Errorf("error updating client ai maxtokens, out of range: %d", maxTokens)
}
varsUpdated = append(varsUpdated, "openaimaxtokens")
aiOpts := clientData.OpenAIOpts
@ -5921,16 +5947,16 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
aiOpts.MaxTokens = maxTokens
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client openai maxtokens: %v", err)
return nil, fmt.Errorf("error updating client ai maxtokens: %v", err)
}
}
if maxChoicesStr, found := pk.Kwargs["openaimaxchoices"]; found {
if maxChoicesStr, found := CheckOptionAlias(pk.Kwargs, "openaimaxchoices", "aimaxchoices"); found {
maxChoices, err := strconv.Atoi(maxChoicesStr)
if err != nil {
return nil, fmt.Errorf("error updating client openai maxchoices, invalid number: %v", err)
return nil, fmt.Errorf("error updating client ai maxchoices, invalid number: %v", err)
}
if maxChoices < 0 || maxChoices > 10 {
return nil, fmt.Errorf("error updating client openai maxchoices, out of range: %d", maxChoices)
return nil, fmt.Errorf("error updating client ai maxchoices, out of range: %d", maxChoices)
}
varsUpdated = append(varsUpdated, "openaimaxchoices")
aiOpts := clientData.OpenAIOpts
@ -5941,10 +5967,10 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
aiOpts.MaxChoices = maxChoices
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client openai maxchoices: %v", err)
return nil, fmt.Errorf("error updating client ai maxchoices: %v", err)
}
}
if aiBaseURL, found := pk.Kwargs["openaibaseurl"]; found {
if aiBaseURL, found := CheckOptionAlias(pk.Kwargs, "openaibaseurl", "aibaseurl"); found {
aiOpts := clientData.OpenAIOpts
if aiOpts == nil {
aiOpts = &sstore.OpenAIOptsType{}
@ -5954,7 +5980,24 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
varsUpdated = append(varsUpdated, "openaibaseurl")
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client openai base url: %v", err)
return nil, fmt.Errorf("error updating client ai base url: %v", err)
}
}
if aiTimeoutStr, found := CheckOptionAlias(pk.Kwargs, "openaitimeout", "aitimeout"); found {
aiTimeout, err := strconv.ParseFloat(aiTimeoutStr, 64)
if err != nil {
return nil, fmt.Errorf("error updating client ai timeout, invalid number: %v", err)
}
aiOpts := clientData.OpenAIOpts
if aiOpts == nil {
aiOpts = &sstore.OpenAIOptsType{}
clientData.OpenAIOpts = aiOpts
}
aiOpts.Timeout = int(aiTimeout * 1000)
varsUpdated = append(varsUpdated, "openaitimeout")
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
if err != nil {
return nil, fmt.Errorf("error updating client ai timeout: %v", err)
}
}
if webglStr, found := pk.Kwargs["webgl"]; found {
@ -5967,8 +6010,60 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
}
varsUpdated = append(varsUpdated, "webgl")
}
if sudoPwStoreStr, found := pk.Kwargs["sudopwstore"]; found {
err := validateSudoPwStore(sudoPwStoreStr)
if err != nil {
return nil, fmt.Errorf("invalid sudo pw store, must be \"on\", \"off\", \"notimeout\": %v", err)
}
feOpts := clientData.FeOpts
feOpts.SudoPwStore = strings.ToLower(sudoPwStoreStr)
err = sstore.UpdateClientFeOpts(ctx, feOpts)
if err != nil {
return nil, fmt.Errorf("error updating client feopts: %v", err)
}
// clear all sudo pw if turning off
if feOpts.SudoPwStore == "off" {
for _, proc := range remote.GetRemoteMap() {
proc.ClearCachedSudoPw()
}
}
varsUpdated = append(varsUpdated, "sudopwstore")
}
if sudoPwTimeoutStr, found := pk.Kwargs["sudopwtimeout"]; found {
oldPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60 // ms to minutes
if oldPwTimeout == 0 {
oldPwTimeout = sstore.DefaultSudoTimeout
}
newSudoPwTimeout, err := resolveNonNegInt(sudoPwTimeoutStr, sstore.DefaultSudoTimeout)
if err != nil {
return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0: %v", err)
}
if newSudoPwTimeout == 0 {
return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0")
}
feOpts := clientData.FeOpts
feOpts.SudoPwTimeoutMs = newSudoPwTimeout * 60 * 1000 // minutes to ms
err = sstore.UpdateClientFeOpts(ctx, feOpts)
if err != nil {
return nil, fmt.Errorf("error updating client feopts: %v", err)
}
for _, proc := range remote.GetRemoteMap() {
proc.ChangeSudoTimeout(int64(newSudoPwTimeout - oldPwTimeout))
}
varsUpdated = append(varsUpdated, "sudopwtimeout")
}
if sudoPwClearOnSleepStr, found := pk.Kwargs["sudopwclearonsleep"]; found {
newSudoPwClearOnSleep := resolveBool(sudoPwClearOnSleepStr, true)
feOpts := clientData.FeOpts
feOpts.NoSudoPwClearOnSleep = !newSudoPwClearOnSleep
err = sstore.UpdateClientFeOpts(ctx, feOpts)
if err != nil {
return nil, fmt.Errorf("error updating client feopts: %v", err)
}
varsUpdated = append(varsUpdated, "sudopwclearonsleep")
}
if len(varsUpdated) == 0 {
return nil, fmt.Errorf("/client:set requires a value to set: %s", formatStrs([]string{"termfontsize", "termfontfamily", "openaiapitoken", "openaimodel", "openaibaseurl", "openaimaxtokens", "openaimaxchoices", "webgl"}, "or", false))
return nil, fmt.Errorf("/client:set requires a value to set: %s", formatStrs([]string{"termfontsize", "termfontfamily", "openaiapitoken", "openaimodel", "openaibaseurl", "openaimaxtokens", "openaimaxchoices", "openaitimeout", "webgl"}, "or", false))
}
clientData, err = sstore.EnsureClientData(ctx)
if err != nil {
@ -6008,11 +6103,12 @@ func ClientShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "termfontsize", clientData.FeOpts.TermFontSize))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.TermFontFamily))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.Theme))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaiapitoken", clientData.OpenAIOpts.APIToken))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaimodel", clientData.OpenAIOpts.Model))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "openaimaxtokens", clientData.OpenAIOpts.MaxTokens))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "openaimaxchoices", clientData.OpenAIOpts.MaxChoices))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaibaseurl", clientData.OpenAIOpts.BaseURL))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aiapitoken", clientData.OpenAIOpts.APIToken))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimodel", clientData.OpenAIOpts.Model))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxtokens", clientData.OpenAIOpts.MaxTokens))
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxchoices", clientData.OpenAIOpts.MaxChoices))
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aibaseurl", clientData.OpenAIOpts.BaseURL))
buf.WriteString(fmt.Sprintf(" %-15s %ss\n", "aitimeout", strconv.FormatFloat((float64(clientData.OpenAIOpts.Timeout)/1000.0), 'f', -1, 64)))
update := scbus.MakeUpdatePacket()
update.AddUpdate(sstore.InfoMsgType{
InfoTitle: fmt.Sprintf("client info"),

View File

@ -0,0 +1,102 @@
package configstore
import (
"log"
"os"
"path/filepath"
"sync"
"github.com/fsnotify/fsnotify"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
)
var instance *Watcher
var once sync.Once
type Watcher struct {
watcher *fsnotify.Watcher
mutex sync.Mutex
}
// GetWatcher returns the singleton instance of the Watcher
func GetWatcher() *Watcher {
once.Do(func() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("failed to create file watcher: %v", err)
return
}
instance = &Watcher{watcher: watcher}
log.Printf("started config watcher: %v\n", configDirAbsPath)
if err := instance.addPath(configDirAbsPath); err != nil {
log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err)
return
}
})
return instance
}
// addPath adds the specified path and all its subdirectories to the watcher
func (w *Watcher) addPath(path string) error {
return filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if err := w.watcher.Add(path); err != nil {
return err
}
log.Printf("added to watcher: %s", path)
}
return nil
})
}
func (w *Watcher) Start() {
for {
select {
case event, ok := <-w.watcher.Events:
if !ok {
return
}
w.handleEvent(event)
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Println("watcher error:", err)
}
}
}
func (w *Watcher) Close() {
w.mutex.Lock()
defer w.mutex.Unlock()
if w.watcher != nil {
w.watcher.Close()
w.watcher = nil
log.Println("file watcher closed.")
}
}
func (w *Watcher) handleEvent(event fsnotify.Event) {
config := make(ConfigReturn)
fileName, normalizedPath := getNameAndPath(event)
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Rename == fsnotify.Rename {
content, err := readFileContents(normalizedPath)
if err != nil {
log.Printf("error reading file %s: %v", normalizedPath, err)
return
}
config[fileName] = content
}
if event.Op&fsnotify.Remove == fsnotify.Remove {
config[fileName] = nil
}
update := scbus.MakeUpdatePacket()
update.AddUpdate(config)
scbus.MainUpdateBus.DoUpdate(update)
}

View File

@ -0,0 +1,83 @@
package configstore
import (
"encoding/json"
"errors"
"log"
"os"
"path"
"path/filepath"
"github.com/fsnotify/fsnotify"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
)
const ConfigReturnTypeStr = "termthemes"
const configDir = "config/terminal-themes/"
var configDirAbsPath = path.Join(scbase.GetWaveHomeDir(), configDir)
type ConfigReturn map[string]map[string]string
func (tt ConfigReturn) GetType() string {
return ConfigReturnTypeStr
}
func getNameAndPath(event fsnotify.Event) (string, string) {
filePath := event.Name
fileName := filepath.Base(filePath)
// Normalize the file path for consistency across platforms
normalizedPath := filepath.ToSlash(filePath)
return fileName, normalizedPath
}
// readFileContents reads and unmarshals the JSON content from a file.
func readFileContents(filePath string) (map[string]string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var content map[string]string
if err := json.Unmarshal(data, &content); err != nil {
return nil, err
}
return content, nil
}
// ScanConfigs reads all JSON files in the specified directory and its subdirectories.
func ScanConfigs() (ConfigReturn, error) {
config := make(ConfigReturn)
if _, err := os.Stat(configDirAbsPath); errors.Is(err, os.ErrNotExist) {
log.Printf("directory does not exist: %s", configDirAbsPath)
return ConfigReturn{}, nil
}
err := filepath.Walk(configDirAbsPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && filepath.Ext(info.Name()) == ".json" {
content, err := readFileContents(path)
if err != nil {
log.Printf("error reading file %s: %v", path, err)
return nil // continue walking despite error in reading file
}
// Use the relative path from the directory as the key to store themes
relPath, err := filepath.Rel(configDirAbsPath, path)
if err != nil {
log.Printf("error getting relative file path %s: %v", path, err)
return nil // continue walking despite error in getting relative path
}
config[relPath] = content
}
return nil
})
if err != nil {
return nil, err
}
return config, nil
}

View File

@ -2626,11 +2626,21 @@ func sendScreenUpdates(screens []*sstore.ScreenType) {
}
}
func (msh *MShellProc) startSudoPwClearChecker() {
func (msh *MShellProc) startSudoPwClearChecker(clientData *sstore.ClientData) {
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
sudoPwStore := clientData.FeOpts.SudoPwStore
for {
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
log.Printf("*error: cannot obtain client data in sudo pw loop. using fallback: %v", err)
} else {
sudoPwStore = clientData.FeOpts.SudoPwStore
}
shouldExit := false
msh.WithLock(func() {
if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline {
if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline && sudoPwStore != "notimeout" {
msh.sudoPw = nil
msh.sudoClearDeadline = 0
}
@ -2668,13 +2678,25 @@ func (msh *MShellProc) sendSudoPassword(sudoPk *packet.SudoRequestPacketType) er
}
rawSecret = []byte(guiResponse.Text)
}
//new
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
clientData, err := sstore.EnsureClientData(ctx)
if err != nil {
return fmt.Errorf("*error: cannot obtain client data: %v", err)
}
sudoPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60
if sudoPwTimeout == 0 {
// 0 maps to default
sudoPwTimeout = sstore.DefaultSudoTimeout
}
pwTimeoutDur := time.Duration(sudoPwTimeout) * time.Minute
msh.WithLock(func() {
msh.sudoPw = rawSecret
if msh.sudoClearDeadline == 0 {
go msh.startSudoPwClearChecker()
go msh.startSudoPwClearChecker(clientData)
}
msh.sudoClearDeadline = time.Now().Add(SudoTimeoutTime).Unix()
msh.sudoClearDeadline = time.Now().Add(pwTimeoutDur).Unix()
})
srvPrivKey, err := ecdh.P256().GenerateKey(rand.Reader)
@ -2767,6 +2789,15 @@ func (msh *MShellProc) ClearCachedSudoPw() {
})
}
func (msh *MShellProc) ChangeSudoTimeout(deltaTime int64) {
msh.WithLock(func() {
if msh.sudoClearDeadline != 0 {
updated := msh.sudoClearDeadline + deltaTime*60
msh.sudoClearDeadline = max(0, updated)
}
})
}
func (msh *MShellProc) ProcessPackets() {
defer msh.WithLock(func() {
if msh.Status == StatusConnected {

View File

@ -36,7 +36,7 @@ const WaveDirName = ".waveterm" // must match emain.ts
const WaveDevDirName = ".waveterm-dev" // must match emain.ts
const WaveAppPathVarName = "WAVETERM_APP_PATH"
const WaveAuthKeyFileName = "waveterm.authkey"
const MShellVersion = "v0.6.0" // must match base.MShellVersion
const MShellVersion = "v0.7.0" // must match base.MShellVersion
// initialized by InitialzeWaveAuthKey (called by main-server)
var WaveAuthKey string
@ -234,7 +234,7 @@ func GetScreensDir() string {
return sdir
}
func EnsureConfigDir() (string, error) {
func EnsureConfigDirs() (string, error) {
scHome := GetWaveHomeDir()
configDir := path.Join(scHome, "config")
err := ensureDir(configDir)
@ -250,6 +250,11 @@ func EnsureConfigDir() (string, error) {
keybindingsFileObj.WriteString("[]\n")
keybindingsFileObj.Close()
}
terminalThemesDir := path.Join(configDir, "terminal-themes")
err = ensureDir(terminalThemesDir)
if err != nil {
return "", err
}
return configDir, nil
}

View File

@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/mapqueue"
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
@ -165,6 +166,11 @@ func (ws *WSState) handleConnection() error {
connectUpdate.Remotes = remotes
// restore status indicators
connectUpdate.ScreenStatusIndicators, connectUpdate.ScreenNumRunningCommands = sstore.GetCurrentIndicatorState()
configs, err := configstore.ScanConfigs()
if err != nil {
return fmt.Errorf("getting configs: %w", err)
}
connectUpdate.TermThemes = &configs
mu := scbus.MakeUpdatePacket()
mu.AddUpdate(*connectUpdate)
err = ws.Shell.WriteJson(mu)

View File

@ -43,6 +43,7 @@ const DBWALFileNameBackup = "backup.waveterm.db-wal"
const MaxWebShareLineCount = 50
const MaxWebShareScreenCount = 3
const MaxLineStateSize = 4 * 1024 // 4k for now, can raise if needed
const DefaultSudoTimeout = 5
const DefaultSessionName = "default"
const LocalRemoteAlias = "local"
@ -232,6 +233,10 @@ type ClientWinSizeType struct {
FullScreen bool `json:"fullscreen,omitempty"`
}
type PowerMonitorEventType struct {
Status string `json:"status"`
}
type SidebarValueType struct {
Collapsed bool `json:"collapsed"`
Width int `json:"width"`
@ -250,10 +255,14 @@ type ClientOptsType struct {
}
type FeOptsType struct {
TermFontSize int `json:"termfontsize,omitempty"`
TermFontFamily string `json:"termfontfamily,omitempty"`
Theme string `json:"theme,omitempty"`
TermTheme map[string]string `json:"termtheme"`
TermFontSize int `json:"termfontsize,omitempty"`
TermFontFamily string `json:"termfontfamily,omitempty"`
Theme string `json:"theme,omitempty"`
TermThemeSettings map[string]string `json:"termthemesettings"`
SudoPwStore string `json:"sudopwstore,omitempty"`
SudoPwTimeoutMs int `json:"sudopwtimeoutms,omitempty"`
SudoPwTimeout int `json:"sudopwtimeout,omitempty"`
NoSudoPwClearOnSleep bool `json:"nosudopwclearonsleep,omitempty"`
}
type ReleaseInfoType struct {
@ -289,6 +298,8 @@ func (cdata *ClientData) Clean() *ClientData {
Model: cdata.OpenAIOpts.Model,
MaxTokens: cdata.OpenAIOpts.MaxTokens,
MaxChoices: cdata.OpenAIOpts.MaxChoices,
Timeout: cdata.OpenAIOpts.Timeout,
BaseURL: cdata.OpenAIOpts.BaseURL,
// omit API Token
}
if cdata.OpenAIOpts.APIToken != "" {
@ -736,6 +747,7 @@ type OpenAIOptsType struct {
BaseURL string `json:"baseurl,omitempty"`
MaxTokens int `json:"maxtokens,omitempty"`
MaxChoices int `json:"maxchoices,omitempty"`
Timeout int `json:"timeout,omitempty"`
}
const (

View File

@ -8,6 +8,7 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
)
@ -104,6 +105,7 @@ type ConnectUpdate struct {
ScreenStatusIndicators []*ScreenStatusIndicatorType `json:"screenstatusindicators,omitempty"`
ScreenNumRunningCommands []*ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
ActiveSessionId string `json:"activesessionid,omitempty"`
TermThemes *configstore.ConfigReturn `json:"termthemes,omitempty"`
}
func (ConnectUpdate) GetType() string {

View File

@ -119,7 +119,7 @@ func (ws *WSShell) ReadPump() {
defer func() {
ws.Conn.Close()
}()
ws.Conn.SetReadLimit(4096)
ws.Conn.SetReadLimit(64 * 1024)
ws.Conn.SetReadDeadline(time.Now().Add(readWait))
for {
_, message, err := ws.Conn.ReadMessage()

682
yarn.lock

File diff suppressed because it is too large Load Diff