From 7d8d90e02decfd3dfbc0630bc30d079b656f6224 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 23 Dec 2025 14:26:31 +0100 Subject: [PATCH] feat: added command bar --- package-lock.json | 58 ++++----- package.json | 3 +- public/locales/en/translations.json | 4 + public/locales/it/translations.json | 4 + .../classes/controllers/AccountController.ts | 16 +++ .../controllers/CommandBarController.ts | 80 ++++++++++++ src/main/classes/controllers/index.ts | 1 + src/main/classes/windows/CommandBarWindow.ts | 42 ++++++ src/main/classes/windows/index.ts | 1 + src/main/lib/ipcEvents.ts | 30 +++++ src/main/main.ts | 56 ++++++++ .../public/locales/en/translations.json | 4 + .../public/locales/it/translations.json | 4 + src/renderer/src/App.tsx | 6 +- src/renderer/src/pages/CommandBarPage.tsx | 120 ++++++++++++++++++ src/renderer/src/pages/index.ts | 1 + src/shared/constants.ts | 4 + src/shared/types.ts | 5 +- 18 files changed, 403 insertions(+), 36 deletions(-) create mode 100644 src/main/classes/controllers/CommandBarController.ts create mode 100644 src/main/classes/windows/CommandBarWindow.ts create mode 100644 src/renderer/src/pages/CommandBarPage.tsx diff --git a/package-lock.json b/package-lock.json index 1dc5aefe..96decc6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "uiohook-napi": "^1.5.4" }, "devDependencies": { "@date-fns/utc": "^1.1.1", @@ -6045,37 +6046,6 @@ "node": ">= 8" } }, - "node_modules/@nethesis/phone-island/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@nethesis/phone-island/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@nethesis/phone-island/node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -21227,6 +21197,17 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -27868,6 +27849,19 @@ "node": ">=14.17" } }, + "node_modules/uiohook-napi": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.4.tgz", + "integrity": "sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "4.x.x" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index 3c0378a5..6072e5cd 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "dmg-license": "^1.0.11" }, "dependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "uiohook-napi": "^1.5.4" } } diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index d9a9dbd6..23cd49a6 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -932,6 +932,10 @@ "update_available": "Update available for download", "download": "Download the update" }, + "CommandBar": { + "Placeholder": "Enter a phone number...", + "Call": "Call" + }, "Errors": { "browser_permissions": "Browser permissions error", "user_permissions": "Media permissions error", diff --git a/public/locales/it/translations.json b/public/locales/it/translations.json index da6447f8..f1eebd95 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -932,6 +932,10 @@ "update_available": "Aggiornamento disponibile per il download", "download": "Scarica l'aggiornamento" }, + "CommandBar": { + "Placeholder": "Inserisci un numero di telefono...", + "Call": "Chiama" + }, "Errors": { "browser_permissions": "Errore nei permessi del browser", "user_permissions": "Errore nei permessi dei media", diff --git a/src/main/classes/controllers/AccountController.ts b/src/main/classes/controllers/AccountController.ts index 6fd6de9f..512b6e02 100644 --- a/src/main/classes/controllers/AccountController.ts +++ b/src/main/classes/controllers/AccountController.ts @@ -273,6 +273,22 @@ export class AccountController { } } + async updateCommandBarShortcut(commandBarShortcut: any) { + if (store.store) { + const account = store.store.account + if (account) { + account.commandBarShortcut = commandBarShortcut + + store.set('account', account, true) + const auth = store.store.auth + + auth!.availableAccounts[getAccountUID(account)] = account + store.set('auth', auth, true) + } + store.saveToDisk() + } + } + getAccountPhoneIslandPosition(): { x: number; y: number } | undefined { return store.store.account?.phoneIslandPosition diff --git a/src/main/classes/controllers/CommandBarController.ts b/src/main/classes/controllers/CommandBarController.ts new file mode 100644 index 00000000..87a26f56 --- /dev/null +++ b/src/main/classes/controllers/CommandBarController.ts @@ -0,0 +1,80 @@ +import { CommandBarWindow } from '../windows' +import { IPC_EVENTS } from '@shared/constants' +import { Log } from '@shared/utils/logger' +import { screen } from 'electron' + +export class CommandBarController { + static instance: CommandBarController + window: CommandBarWindow + private isVisible: boolean = false + + constructor() { + CommandBarController.instance = this + this.window = new CommandBarWindow() + this.setupBlurListener() + } + + private setupBlurListener() { + this.window.addOnBuildListener(() => { + const window = this.window.getWindow() + if (window) { + window.on('blur', () => { + this.hide() + }) + } + }) + } + + show() { + try { + const window = this.window.getWindow() + if (window && !this.isVisible) { + const cursorPoint = screen.getCursorScreenPoint() + const currentDisplay = screen.getDisplayNearestPoint(cursorPoint) + const { x, y, width, height } = currentDisplay.workArea + const windowBounds = window.getBounds() + + const centerX = x + Math.round((width - windowBounds.width) / 2) + const centerY = y + Math.round(height * 0.3) + + window.setBounds({ x: centerX, y: centerY }) + window.show() + window.setAlwaysOnTop(true, 'screen-saver') + window.focus() + this.isVisible = true + this.window.emit(IPC_EVENTS.SHOW_COMMAND_BAR) + } + } catch (e) { + Log.warning('error during showing CommandBarWindow:', e) + } + } + + hide() { + try { + const window = this.window.getWindow() + if (window && this.isVisible) { + window.hide() + this.isVisible = false + this.window.emit(IPC_EVENTS.HIDE_COMMAND_BAR) + } + } catch (e) { + Log.warning('error during hiding CommandBarWindow:', e) + } + } + + toggle() { + if (this.isVisible) { + this.hide() + } else { + this.show() + } + } + + isOpen(): boolean { + return this.isVisible + } + + async safeQuit() { + await this.window.quit(true) + } +} diff --git a/src/main/classes/controllers/index.ts b/src/main/classes/controllers/index.ts index 4c91d6f0..fc8760ba 100644 --- a/src/main/classes/controllers/index.ts +++ b/src/main/classes/controllers/index.ts @@ -3,3 +3,4 @@ export * from './LoginController' export * from './PhoneIslandController' export * from './TrayController' export * from './DevToolsController' +export * from './CommandBarController' diff --git a/src/main/classes/windows/CommandBarWindow.ts b/src/main/classes/windows/CommandBarWindow.ts new file mode 100644 index 00000000..87cac618 --- /dev/null +++ b/src/main/classes/windows/CommandBarWindow.ts @@ -0,0 +1,42 @@ +import { PAGES } from '@shared/types' +import { BaseWindow } from './BaseWindow' + +export class CommandBarWindow extends BaseWindow { + constructor() { + super(PAGES.COMMANDBAR, { + width: 500, + height: 80, + show: false, + fullscreenable: false, + autoHideMenuBar: true, + closable: false, + alwaysOnTop: true, + minimizable: false, + maximizable: false, + movable: false, + resizable: false, + skipTaskbar: true, + roundedCorners: true, + parent: undefined, + transparent: true, + hiddenInMissionControl: true, + hasShadow: true, + center: true, + fullscreen: false, + enableLargerThanScreen: false, + frame: false, + thickFrame: false, + trafficLightPosition: { x: 0, y: 0 }, + webPreferences: { + nodeIntegration: true + } + }) + + this.addOnBuildListener(() => { + const window = this.getWindow() + if (window) { + window.setAlwaysOnTop(true, 'screen-saver') + } + }) + } +} diff --git a/src/main/classes/windows/index.ts b/src/main/classes/windows/index.ts index 4bde1066..7cc92b28 100644 --- a/src/main/classes/windows/index.ts +++ b/src/main/classes/windows/index.ts @@ -3,3 +3,4 @@ export * from './SplashScreenWindow' export * from './NethLinkWindow' export * from './PhoneIslandWindow' export * from './DevToolsWindow' +export * from './CommandBarWindow' diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 777030eb..1d94c7b6 100644 --- a/src/main/lib/ipcEvents.ts +++ b/src/main/lib/ipcEvents.ts @@ -1,6 +1,7 @@ import { AccountController, DevToolsController } from '@/classes/controllers' import { LoginController } from '@/classes/controllers/LoginController' import { PhoneIslandController } from '@/classes/controllers/PhoneIslandController' +import { CommandBarController } from '@/classes/controllers/CommandBarController' import { IPC_EVENTS } from '@shared/constants' import { Account, OnDraggingWindow, PAGES } from '@shared/types' import { BrowserWindow, app, ipcMain, screen, shell, desktopCapturer, globalShortcut, clipboard } from 'electron' @@ -462,4 +463,33 @@ export function registerIpcEvents() { Log.error('URL PARAM error', e) } }) + + ipcMain.on(IPC_EVENTS.TOGGLE_COMMAND_BAR, () => { + try { + CommandBarController.instance?.toggle() + } catch (e) { + Log.error('TOGGLE_COMMAND_BAR error', e) + } + }) + + ipcMain.on(IPC_EVENTS.SHOW_COMMAND_BAR, () => { + try { + CommandBarController.instance?.show() + } catch (e) { + Log.error('SHOW_COMMAND_BAR error', e) + } + }) + + ipcMain.on(IPC_EVENTS.HIDE_COMMAND_BAR, () => { + try { + CommandBarController.instance?.hide() + } catch (e) { + Log.error('HIDE_COMMAND_BAR error', e) + } + }) + + ipcMain.on(IPC_EVENTS.CHANGE_COMMAND_BAR_SHORTCUT, async (_, combo) => { + AccountController.instance.updateCommandBarShortcut(combo) + Log.info('Command Bar shortcut changed to:', combo) + }) } diff --git a/src/main/main.ts b/src/main/main.ts index 235b88db..2e30acfe 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,6 +2,7 @@ import { app, ipcMain, nativeTheme, powerMonitor, protocol, systemPreferences, d import { registerIpcEvents, isCallActive } from '@/lib/ipcEvents' import { AccountController } from './classes/controllers' import { PhoneIslandController } from './classes/controllers/PhoneIslandController' +import { CommandBarController } from './classes/controllers/CommandBarController' import { Account, AuthAppData, AvailableThemes } from '@shared/types' import { TrayController } from './classes/controllers/TrayController' import { LoginController } from './classes/controllers/LoginController' @@ -374,6 +375,22 @@ function attachOnReadyProcess() { Log.info("Unregister all shortcuts") await globalShortcut.unregisterAll() + // Stop uiohook for command bar + if (uiohookStarted) { + try { + const { uIOhook } = require('uiohook-napi') + uIOhook.stop() + Log.info('uIOhook stopped') + } catch (e) { + Log.warning('Failed to stop uIOhook:', e) + } + } + + // Quit command bar + if (CommandBarController.instance) { + await CommandBarController.instance.safeQuit() + } + Log.info('APP QUIT CORRECTLY') app.exit(); }) @@ -705,6 +722,8 @@ async function createNethLink(show: boolean = true) { NethLinkController.instance.show() await delay(1000) new PhoneIslandController() + new CommandBarController() + initCommandBarShortcut() checkForUpdate() const account = store.get('account') as Account if (account) { @@ -716,6 +735,43 @@ async function createNethLink(show: boolean = true) { } } +let uiohookStarted = false +let lastModifierPress = 0 +const DOUBLE_TAP_THRESHOLD = 400 + +function initCommandBarShortcut() { + if (uiohookStarted) return + + try { + const { uIOhook, UiohookKey } = require('uiohook-napi') + + uIOhook.on('keydown', (e: any) => { + const isMac = process.platform === 'darwin' + const isModifierKey = isMac + ? e.keycode === UiohookKey.Meta || e.keycode === UiohookKey.MetaRight + : e.keycode === UiohookKey.Ctrl || e.keycode === UiohookKey.CtrlRight + + if (isModifierKey) { + const now = Date.now() + if (now - lastModifierPress < DOUBLE_TAP_THRESHOLD) { + if (CommandBarController.instance) { + CommandBarController.instance.toggle() + } + lastModifierPress = 0 + } else { + lastModifierPress = now + } + } + }) + + uIOhook.start() + uiohookStarted = true + Log.info('Command Bar shortcut initialized (double-tap Cmd/Ctrl)') + } catch (e) { + Log.warning('Failed to initialize Command Bar shortcut:', e) + } +} + async function checkForUpdate() { Log.info('Current app version:', app.getVersion(), 'check for updates...') const latestVersionData = await NetworkController.instance.get(GIT_RELEASES_URL) diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index d9a9dbd6..23cd49a6 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -932,6 +932,10 @@ "update_available": "Update available for download", "download": "Download the update" }, + "CommandBar": { + "Placeholder": "Enter a phone number...", + "Call": "Call" + }, "Errors": { "browser_permissions": "Browser permissions error", "user_permissions": "Media permissions error", diff --git a/src/renderer/public/locales/it/translations.json b/src/renderer/public/locales/it/translations.json index da6447f8..f1eebd95 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -932,6 +932,10 @@ "update_available": "Aggiornamento disponibile per il download", "download": "Scarica l'aggiornamento" }, + "CommandBar": { + "Placeholder": "Inserisci un numero di telefono...", + "Call": "Chiama" + }, "Errors": { "browser_permissions": "Errore nei permessi del browser", "user_permissions": "Errore nei permessi dei media", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2324b66c..99d7e008 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,6 +1,6 @@ import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom' import { useInitialize } from '@/hooks/useInitialize' -import { LoginPage, PhoneIslandPage, SplashScreenPage, NethLinkPage } from '@/pages' +import { LoginPage, PhoneIslandPage, SplashScreenPage, NethLinkPage, CommandBarPage } from '@/pages' import { loadI18n } from './lib/i18n' import { Log } from '@shared/utils/logger' import { useEffect, useState } from 'react' @@ -101,6 +101,10 @@ const RequestStateComponent = () => { { path: PAGES.DEVTOOLS, element: + }, + { + path: PAGES.COMMANDBAR, + element: } ] } diff --git a/src/renderer/src/pages/CommandBarPage.tsx b/src/renderer/src/pages/CommandBarPage.tsx new file mode 100644 index 00000000..b0880b9a --- /dev/null +++ b/src/renderer/src/pages/CommandBarPage.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from 'react' +import { IPC_EVENTS } from '@shared/constants' +import { useSharedState } from '@renderer/store' +import { useTranslation } from 'react-i18next' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPhone, faXmark } from '@fortawesome/free-solid-svg-icons' +import classNames from 'classnames' +import { parseThemeToClassName } from '@renderer/utils' +import { TextInput } from '@renderer/components/Nethesis' + +export function CommandBarPage() { + const { t } = useTranslation() + const [theme] = useSharedState('theme') + const [phoneNumber, setPhoneNumber] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + window.electron.receive(IPC_EVENTS.SHOW_COMMAND_BAR, () => { + setPhoneNumber('') + setTimeout(() => { + inputRef.current?.focus() + }, 50) + }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, []) + + const handleCall = () => { + const trimmedNumber = phoneNumber.trim() + if (trimmedNumber) { + const prefixMatch = trimmedNumber.match(/^[*#+]+/) + const prefix = prefixMatch ? prefixMatch[0] : '' + const sanitized = trimmedNumber.replace(/[^\d]/g, '') + const number = prefix + sanitized + + const isValidNumber = /^([*#+]?)(\d{2,})$/.test(number) + if (isValidNumber) { + window.electron.send(IPC_EVENTS.EMIT_START_CALL, number) + window.electron.send(IPC_EVENTS.HIDE_COMMAND_BAR) + } + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCall() + } + } + + const handleClear = () => { + setPhoneNumber('') + inputRef.current?.focus() + } + + const themeClass = parseThemeToClassName(theme) + + return ( +
+
+ setPhoneNumber(e.target.value)} + onKeyDown={handleKeyPress} + placeholder={t('CommandBar.Placeholder') || ''} + className="flex-1 dark:text-titleDark text-titleLight [&_input]:focus:ring-0 [&_input]:focus:border-gray-300 dark:[&_input]:focus:border-gray-600" + autoFocus + /> + + {phoneNumber && ( + + )} + + +
+
+ ) +} diff --git a/src/renderer/src/pages/index.ts b/src/renderer/src/pages/index.ts index abb0620b..1a8c2f1b 100644 --- a/src/renderer/src/pages/index.ts +++ b/src/renderer/src/pages/index.ts @@ -2,3 +2,4 @@ export * from './LoginPage' export * from './NethLinkPage' export * from './PhoneIslandPage' export * from './SplashScreenPage' +export * from './CommandBarPage' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8240d1d6..89054bab 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -101,6 +101,10 @@ export enum IPC_EVENTS { PLAY_RINGTONE_PREVIEW = "PLAY_RINGTONE_PREVIEW", STOP_RINGTONE_PREVIEW = "STOP_RINGTONE_PREVIEW", AUDIO_PLAYER_CLOSED = "AUDIO_PLAYER_CLOSED", + TOGGLE_COMMAND_BAR = "TOGGLE_COMMAND_BAR", + SHOW_COMMAND_BAR = "SHOW_COMMAND_BAR", + HIDE_COMMAND_BAR = "HIDE_COMMAND_BAR", + CHANGE_COMMAND_BAR_SHORTCUT = "CHANGE_COMMAND_BAR_SHORTCUT", } //PHONE ISLAND EVENTS diff --git a/src/shared/types.ts b/src/shared/types.ts index 2253ba65..e821dc02 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -8,8 +8,8 @@ export enum PAGES { LOGIN = "Login", PHONEISLAND = "phoneislandpage", NETHLINK = "NethLink", - DEVTOOLS = "devtoolspage" - + DEVTOOLS = "devtoolspage", + COMMANDBAR = "commandbarpage" } export type StateType = [(T | undefined), (value: T | undefined) => void] @@ -33,6 +33,7 @@ export type Account = { // eslint-disable-next-line @typescript-eslint/no-explicit-any data?: AccountData, shortcut?: string + commandBarShortcut?: string preferredDevices?: PreferredDevices apiBasePath?: string // Store which API path works for this account }