diff --git a/src/app/clientsettings/clientsettings.tsx b/src/app/clientsettings/clientsettings.tsx index 48c7b0ea..dd772bc5 100644 --- a/src/app/clientsettings/clientsettings.tsx +++ b/src/app/clientsettings/clientsettings.tsx @@ -29,12 +29,12 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove @boundMethod handleChangeFontSize(fontSize: string): void { - let newFontSize = Number(fontSize); + const newFontSize = Number(fontSize); this.fontSizeDropdownActive.set(false); if (GlobalModel.termFontSize.get() == newFontSize) { return; } - let prtn = GlobalCommandRunner.setTermFontSize(newFontSize, false); + const prtn = GlobalCommandRunner.setTermFontSize(newFontSize, false); commandRtnHandler(prtn, this.errorMessage); } @@ -67,29 +67,29 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove commandRtnHandler(prtn, this.errorMessage); } - getFontSizes(): any { - let availableFontSizes: { label: string; value: number }[] = []; + getFontSizes(): DropdownItem[] { + const availableFontSizes: DropdownItem[] = []; for (let s = appconst.MinFontSize; s <= appconst.MaxFontSize; s++) { - availableFontSizes.push({ label: s + "px", value: s }); + availableFontSizes.push({ label: s + "px", value: String(s) }); } return availableFontSizes; } @boundMethod inlineUpdateOpenAIModel(newModel: string): void { - let prtn = GlobalCommandRunner.setClientOpenAISettings({ model: newModel }); + const prtn = GlobalCommandRunner.setClientOpenAISettings({ model: newModel }); commandRtnHandler(prtn, this.errorMessage); } @boundMethod inlineUpdateOpenAIToken(newToken: string): void { - let prtn = GlobalCommandRunner.setClientOpenAISettings({ apitoken: newToken }); + const prtn = GlobalCommandRunner.setClientOpenAISettings({ apitoken: newToken }); commandRtnHandler(prtn, this.errorMessage); } @boundMethod inlineUpdateOpenAIMaxTokens(newMaxTokensStr: string): void { - let prtn = GlobalCommandRunner.setClientOpenAISettings({ maxtokens: newMaxTokensStr }); + const prtn = GlobalCommandRunner.setClientOpenAISettings({ maxtokens: newMaxTokensStr }); commandRtnHandler(prtn, this.errorMessage); } @@ -105,19 +105,41 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove GlobalModel.clientSettingsViewModel.closeView(); } + @boundMethod + handleChangeShortcut(newShortcut: string): void { + const prtn = GlobalCommandRunner.setGlobalShortcut(newShortcut); + commandRtnHandler(prtn, this.errorMessage); + } + + getFKeys(): DropdownItem[] { + const opts: DropdownItem[] = []; + opts.push({ label: "Disabled", value: "" }); + const platform = GlobalModel.getPlatform(); + for (let i = 1; i <= 12; i++) { + const shortcut = (platform == "darwin" ? "Cmd" : "Alt") + "+F" + String(i); + opts.push({ label: shortcut, value: shortcut }); + } + return opts; + } + + getCurrentShortcut(): string { + const clientData = GlobalModel.clientData.get(); + return clientData?.clientopts?.globalshortcut ?? ""; + } + render() { - let isHidden = GlobalModel.activeMainView.get() != "clientsettings"; + const isHidden = GlobalModel.activeMainView.get() != "clientsettings"; if (isHidden) { return null; } - let cdata: ClientDataType = GlobalModel.clientData.get(); - let openAIOpts = cdata.openaiopts ?? {}; - let apiTokenStr = isBlank(openAIOpts.apitoken) ? "(not set)" : "********"; - let maxTokensStr = String( + const cdata: ClientDataType = GlobalModel.clientData.get(); + const openAIOpts = cdata.openaiopts ?? {}; + const apiTokenStr = isBlank(openAIOpts.apitoken) ? "(not set)" : "********"; + const maxTokensStr = String( openAIOpts.maxtokens == null || openAIOpts.maxtokens == 0 ? 1000 : openAIOpts.maxtokens ); - let curFontSize = GlobalModel.termFontSize.get(); + const curFontSize = GlobalModel.termFontSize.get(); return (
@@ -207,6 +229,17 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove />
+
+
Global Hotkey
+
+ +
+
diff --git a/src/app/common/elements/dropdown.tsx b/src/app/common/elements/dropdown.tsx index 0b9ea4c8..f7fef4f9 100644 --- a/src/app/common/elements/dropdown.tsx +++ b/src/app/common/elements/dropdown.tsx @@ -17,7 +17,7 @@ interface DropdownDecorationProps { interface DropdownProps { label?: string; - options: { value: string; label: string }[]; + options: DropdownItem[]; value?: string; className?: string; onChange: (value: string) => void; diff --git a/src/electron/emain.ts b/src/electron/emain.ts index a9ce352b..faf702d5 100644 --- a/src/electron/emain.ts +++ b/src/electron/emain.ts @@ -8,8 +8,9 @@ import fetch from "node-fetch"; import * as child_process from "node:child_process"; import { debounce } from "throttle-debounce"; import * as winston from "winston"; -import { sprintf } from "sprintf-js"; import * as util from "util"; +import * as waveutil from "../util/util"; +import { sprintf } from "sprintf-js"; import { handleJsonFetchResponse } from "@/util/util"; import { v4 as uuidv4 } from "uuid"; import { checkKeyPressed, adaptFromElectronKeyEvent, setKeyUtilPlatform } from "@/util/keyutil"; @@ -29,6 +30,7 @@ let instanceId = uuidv4(); let oldConsoleLog = console.log; let wasActive = true; let wasInFg = true; +let currentGlobalShortcut: string | null = null; checkPromptMigrate(); ensureDir(waveHome); @@ -412,7 +414,7 @@ function mainResizeHandler(e, win) { }); } -function calcBounds(clientData) { +function calcBounds(clientData: ClientDataType) { let primaryDisplay = electron.screen.getPrimaryDisplay(); let pdBounds = primaryDisplay.bounds; let size = { x: 100, y: 100, width: pdBounds.width - 200, height: pdBounds.height - 200 }; @@ -509,6 +511,12 @@ electron.ipcMain.on("open-external-link", async (_, url) => { } }); +electron.ipcMain.on("reregister-global-shortcut", (event, shortcut: string) => { + reregisterGlobalShortcut(shortcut); + event.returnValue = true; + return; +}); + electron.ipcMain.on("get-last-logs", async (event, numberOfLines) => { try { const logPath = path.join(getWaveHomeDir(), "wavesrv.log"); @@ -698,6 +706,34 @@ function runActiveTimer() { setTimeout(runActiveTimer, 60000); } +function reregisterGlobalShortcut(shortcut: string) { + if (shortcut == "") { + shortcut = null; + } + if (currentGlobalShortcut == shortcut) { + return; + } + if (!waveutil.isBlank(currentGlobalShortcut)) { + if (electron.globalShortcut.isRegistered(currentGlobalShortcut)) { + electron.globalShortcut.unregister(currentGlobalShortcut); + } + } + if (waveutil.isBlank(shortcut)) { + currentGlobalShortcut = null; + return; + } + let ok = electron.globalShortcut.register(shortcut, () => { + console.log("global shortcut triggered, showing window"); + MainWindow?.show(); + }); + console.log("registered global shortcut", shortcut, ok ? "ok" : "failed"); + if (!ok) { + currentGlobalShortcut = null; + console.log("failed to register global shortcut", shortcut); + } + currentGlobalShortcut = shortcut; +} + // ====== MAIN ====== // (async () => { diff --git a/src/electron/preload.js b/src/electron/preload.js index 229a76e0..e7be2ec5 100644 --- a/src/electron/preload.js +++ b/src/electron/preload.js @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld("api", { }, restartWaveSrv: () => ipcRenderer.sendSync("restart-server"), reloadWindow: () => ipcRenderer.sendSync("reload-window"), + reregisterGlobalShortcut: (shortcut) => ipcRenderer.sendSync("reregister-global-shortcut", shortcut), openExternalLink: (url) => ipcRenderer.send("open-external-link", url), onTCmd: (callback) => ipcRenderer.on("t-cmd", callback), onICmd: (callback) => ipcRenderer.on("i-cmd", callback), diff --git a/src/models/commandrunner.ts b/src/models/commandrunner.ts index eaf516af..19bb95f3 100644 --- a/src/models/commandrunner.ts +++ b/src/models/commandrunner.ts @@ -431,6 +431,10 @@ class CommandRunner { } GlobalModel.submitCommand("sidebar", "open", null, kwargs, false); } + + setGlobalShortcut(shortcut: string): Promise { + return GlobalModel.submitCommand("client", "setglobalshortcut", [shortcut], { nohist: "1" }, false); + } } export { CommandRunner }; diff --git a/src/models/model.ts b/src/models/model.ts index 3c65a658..26e7a567 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -55,6 +55,7 @@ type ElectronApi = { restartWaveSrv: () => boolean; reloadWindow: () => void; openExternalLink: (url: string) => void; + reregisterGlobalShortcut: (shortcut: string) => void; onTCmd: (callback: (mods: KeyModsType) => void) => void; onICmd: (callback: (mods: KeyModsType) => void) => void; onLCmd: (callback: (mods: KeyModsType) => void) => void; @@ -887,7 +888,7 @@ class Model { this.bookmarksModel.mergeBookmarks(update.bookmarks.bookmarks); } } else if (update.clientdata != null) { - this.clientData.set(update.clientdata); + this.setClientData(update.clientdata); } else if (update.cmdline != null) { this.inputModel.updateCmdLine(update.cmdline); } else if (update.openaicmdinfochat != null) { @@ -1095,16 +1096,25 @@ class Model { fetch(url, { method: "post", body: null, headers: fetchHeaders }) .then((resp) => handleJsonFetchResponse(url, resp)) .then((data) => { - mobx.action(() => { - const clientData: ClientDataType = data.data; - this.clientData.set(clientData); - })(); + const clientData: ClientDataType = data.data; + this.setClientData(clientData); }) .catch((err) => { this.errorHandler("calling get-client-data", err, true); }); } + setClientData(clientData: ClientDataType) { + mobx.action(() => { + this.clientData.set(clientData); + })(); + let shortcut = null; + if (clientData?.clientopts?.globalshortcutenabled) { + shortcut = clientData?.clientopts?.globalshortcut; + } + getApi().reregisterGlobalShortcut(shortcut); + } + submitCommandPacket(cmdPk: FeCmdPacketType, interactive: boolean): Promise { if (this.debugCmds > 0) { console.log("[cmd]", cmdPacketString(cmdPk)); @@ -1328,6 +1338,10 @@ class Model { } } + sendUserInput(userInputResponsePacket: UserInputResponsePacket) { + this.ws.pushMessage(userInputResponsePacket); + } + sendInputPacket(inputPacket: any) { this.ws.pushMessage(inputPacket); } diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index f8b46bd0..5d5922a4 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -292,6 +292,11 @@ declare global { userquery?: string; }; + type DropdownItem = { + label: string; + value: string; + }; + /** * Levels for the screen status indicator */ @@ -554,6 +559,8 @@ declare global { collapsed: boolean; width: number; }; + globalshortcut: string; + globalshortcutenabled: boolean; }; type ReleaseInfoType = { diff --git a/tsconfig.json b/tsconfig.json index f7fd46eb..a3904cf6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "include": ["src/**/*", "types/**/*"], - // "exclude": ["src/electron/emain.ts"], "compilerOptions": { "target": "es5", "module": "commonjs", diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 7bf42891..39449e26 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -224,6 +224,7 @@ func init() { registerCmdFn("client:accepttos", ClientAcceptTosCommand) registerCmdFn("client:setconfirmflag", ClientConfirmFlagCommand) registerCmdFn("client:setsidebar", ClientSetSidebarCommand) + registerCmdFn("client:setglobalshortcut", ClientSetGlobalShortcut) registerCmdFn("sidebar:open", SidebarOpenCommand) registerCmdFn("sidebar:close", SidebarCloseCommand) @@ -4990,6 +4991,28 @@ func ClientConfirmFlagCommand(ctx context.Context, pk *scpacket.FeCommandPacketT return update, nil } +func ClientSetGlobalShortcut(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { + clientData, err := sstore.EnsureClientData(ctx) + if err != nil { + return nil, fmt.Errorf("cannot retrieve client data: %v", err) + } + newShortcut := firstArg(pk) + if len(newShortcut) > 50 { + return nil, fmt.Errorf("invalid shortcut (maxlen = 50)") + } + clientOpts := clientData.ClientOpts + clientOpts.GlobalShortcut = newShortcut + clientOpts.GlobalShortcutEnabled = (newShortcut != "") + err = sstore.SetClientOpts(ctx, clientOpts) + if err != nil { + return nil, fmt.Errorf("error updating client data: %v", err) + } + clientData.ClientOpts = clientOpts + update := &sstore.ModelUpdate{} + sstore.AddUpdate(update, *clientData) + return update, nil +} + func ClientSetSidebarCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) { clientData, err := sstore.EnsureClientData(ctx) if err != nil { diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index e9df8e73..309140e9 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -283,11 +283,13 @@ type SidebarValueType struct { } type ClientOptsType struct { - NoTelemetry bool `json:"notelemetry,omitempty"` - NoReleaseCheck bool `json:"noreleasecheck,omitempty"` - AcceptedTos int64 `json:"acceptedtos,omitempty"` - ConfirmFlags map[string]bool `json:"confirmflags,omitempty"` - MainSidebar *SidebarValueType `json:"mainsidebar,omitempty"` + NoTelemetry bool `json:"notelemetry,omitempty"` + NoReleaseCheck bool `json:"noreleasecheck,omitempty"` + AcceptedTos int64 `json:"acceptedtos,omitempty"` + ConfirmFlags map[string]bool `json:"confirmflags,omitempty"` + MainSidebar *SidebarValueType `json:"mainsidebar,omitempty"` + GlobalShortcut string `json:"globalshortcut,omitempty"` + GlobalShortcutEnabled bool `json:"globalshortcutenabled,omitempty"` } type FeOptsType struct {