diff --git a/nethesis-phone-island-0.17.13.tgz b/nethesis-phone-island-0.17.13.tgz deleted file mode 100644 index 5729c402..00000000 Binary files a/nethesis-phone-island-0.17.13.tgz and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 1dc5aefe..5320e924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "koffi": "^2.15.0", + "uiohook-napi": "^1.5.4" }, "devDependencies": { "@date-fns/utc": "^1.1.1", @@ -28,7 +30,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.18.0", + "@nethesis/phone-island": "^0.18.1", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", @@ -5639,9 +5641,9 @@ } }, "node_modules/@nethesis/phone-island": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.18.0.tgz", - "integrity": "sha512-mfcbB5VnjGHHDejn81BNG+O+GzrVS0V0wbXbYziOw1lUae7q9CI76QpLAF9JnuoIBd9OqQ0ENn3wq0bwm84f9w==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@nethesis/phone-island/-/phone-island-0.18.1.tgz", + "integrity": "sha512-kqV7BnPvireD0078MDqqqWwUAc0Qqci7YfW78wu4Am9uDoSabM+mc+TGiIBlS+9dAj5Md53aBH99SUsPTpWAuA==", "dev": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -6045,37 +6047,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", @@ -20050,6 +20021,16 @@ "node": ">= 8" } }, + "node_modules/koffi": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.0.tgz", + "integrity": "sha512-174BTuWK7L42Om7nDWy9YOTXj6Dkm14veuFf5yhVS5VU6GjtOI1Wjf+K16Z0JvSuZ3/NpkVzFBjE1oKbthTIEA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://buymeacoffee.com/koromix" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -21227,6 +21208,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 +27860,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..85ec9265 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@nethesis/nethesis-brands-svg-icons": "github:nethesis/Font-Awesome#ns-brands", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/phone-island": "^0.18.0", + "@nethesis/phone-island": "^0.18.1", "@tailwindcss/forms": "^0.5.7", "@types/lodash": "^4.14.202", "@types/node": "^18.19.9", @@ -106,6 +106,8 @@ "dmg-license": "^1.0.11" }, "dependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "koffi": "^2.15.0", + "uiohook-napi": "^1.5.4" } } diff --git a/public/locales/en/translations.json b/public/locales/en/translations.json index 2323b719..5ed27deb 100644 --- a/public/locales/en/translations.json +++ b/public/locales/en/translations.json @@ -347,6 +347,7 @@ "IP Phone": "IP Phone", "Only nethlink": "Only Nethlink", "ShortcutToCall": "Shortcut to Call", + "CommandBarShortcut": "Shortcut for Command Bar", "ShortcutHelp": "Enter a keyboard shortcut to start a call after selecting some text.", "ShortcutHelpDesc": "Unsupported keys: \"Tab\", \"CapsLock\", \"NumLock\", \"Insert\", \"Escape\", \"Shift\".", "Clear and remove shortcut": "Clear and remove shortcut" @@ -376,8 +377,10 @@ "Theme": "Theme", "Settings": "Settings", "Shortcut": "Shortcut", - "Keyboard shortcut to call": "Keyboard shortcut to call", + "Keyboard shortcut to call": "Keyboard shortcut to Call", + "Keyboard shortcut for command bar": "Keyboard shortcut for Command bar", "Shortcut title description": "Choose a keyboard shortcut to dial any selected number. ", + "Command bar shortcut title description": "Choose a keyboard shortcut to open or hide the Command Bar. To trigger it, press the shortcut twice in a row.", "Shortcut subtitle description": "Avoid using system-reserved shortcuts.", "Shortcut body description": "Keys not supported: tab, capsLock, numLock, insert, esc, shift", "Preferred devices": "Audio and Video settings", @@ -931,6 +934,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 e54e413e..e2f0bff9 100644 --- a/public/locales/it/translations.json +++ b/public/locales/it/translations.json @@ -346,7 +346,8 @@ "Available presence": "Presence disponibili", "IP Phone": "Telefono IP", "Only nethlink": "Solo Nethlink", - "ShortcutToCall": "Scorciatoia per chiamare", + "ShortcutToCall": "Scorciatoia per Chiamare", + "CommandBarShortcut": "Scorciatoia per Command Bar", "ShortcutHelp": "Inserisci una scorciatoia da tastiera per far partire una chiamata dopo aver selezionato un testo.", "ShortcutHelpDesc": "Caratteri non consentiti: \"Tab\", \"CapsLock\", \"NumLock\", \"Insert\", \"Escape\", \"Shift\".", "Clear and remove shortcut": "Ripristina e rimuovi scorciatoia" @@ -377,7 +378,9 @@ "Settings": "Impostazioni", "Shortcut": "Scorciatoia", "Keyboard shortcut to call": "Scorciatoia per chiamare", + "Keyboard shortcut for command bar": "Scorciatoia per Command Bar", "Shortcut title description": "Scegli una combinazione di tasti per avviare una chiamata dopo aver selezionato un testo.", + "Command bar shortcut title description": "Scegli una combinazione di tasti per aprire o nascondere la Command Bar. Per attivarla, premi la scorciatoia due volte di seguito.", "Shortcut subtitle description": "Non usare scorciatoie già presenti nel sistema.", "Shortcut body description": "Tasti non consentiti: tab, capsLock, numLock, insert, esc, shift", "Preferred devices": "Impostazioni Audio e Video", @@ -931,6 +934,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..fabf69d1 --- /dev/null +++ b/src/main/classes/controllers/CommandBarController.ts @@ -0,0 +1,149 @@ +import { CommandBarWindow } from '../windows' +import { IPC_EVENTS } from '@shared/constants' +import { Log } from '@shared/utils/logger' +import { debouncer } from '@shared/utils/utils' +import { forceWindowFocus } from '@/lib/windowsFocus' +import { screen } from 'electron' + +export class CommandBarController { + static instance: CommandBarController + window: CommandBarWindow + private isVisible: boolean = false + private isShowingInProgress: boolean = false // Grace period to ignore blur during focus transition + private lastToggleTime: number = 0 // Throttle toggle calls + private originalSize = { width: 500, height: 80 } + + 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', () => { + // Ignore blur during the grace period after showing + if (!this.isShowingInProgress) { + this.hide() + } + }) + } + }) + } + + show() { + try { + const window = this.window.getWindow() + if (window && !this.isVisible) { + // Start grace period to ignore blur events during focus transition + this.isShowingInProgress = true + + // Restore original size if it was reset to [0,0] + window.setBounds({ + width: this.originalSize.width, + height: this.originalSize.height + }) + + 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 }) + + const isWindows = process.platform === 'win32' + + this.isVisible = true + + if (isWindows) { + // Windows: use native API to force focus + window.show() + window.setAlwaysOnTop(true, 'screen-saver') + setTimeout(() => { + // Use native Windows API to force foreground + forceWindowFocus(window) + window.focus() + window.webContents?.focus() + // Emit SHOW_COMMAND_BAR after focus operations complete + this.window.emit(IPC_EVENTS.SHOW_COMMAND_BAR) + // End grace period after focus is applied + setTimeout(() => { + this.isShowingInProgress = false + }, 350) + }, 50) + } else { + // macOS/Linux + window.show() + window.setAlwaysOnTop(true, 'screen-saver') + this.window.emit(IPC_EVENTS.SHOW_COMMAND_BAR) + setTimeout(() => { + window.focus() + window.webContents?.focus() + // End grace period after focus is applied + this.isShowingInProgress = false + }, 50) + } + } + } catch (e) { + this.isShowingInProgress = false + Log.warning('error during showing CommandBarWindow:', e) + } + } + + hide() { + const isMac = process.platform === 'darwin' + + try { + const window = this.window.getWindow() + if (window && this.isVisible) { + this.isVisible = false + // Reset size to [0,0] to avoid slowness/inconsistent state - same as PhoneIsland pattern + window.setBounds({ width: 0, height: 0 }) + + if (isMac) { + window.hide() + this.window.emit(IPC_EVENTS.HIDE_COMMAND_BAR) + } else { + debouncer( + 'hide-command-bar', + () => { + window.hide() + this.window.emit(IPC_EVENTS.HIDE_COMMAND_BAR) + }, + 100 + ) + } + } + } catch (e) { + Log.warning('error during hiding CommandBarWindow:', e) + } + } + + toggle() { + // Throttle toggle calls to prevent rapid open/close cycles + const now = Date.now() + if (now - this.lastToggleTime < 300) { + return + } + this.lastToggleTime = now + + 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..e7ab0453 --- /dev/null +++ b/src/main/classes/windows/CommandBarWindow.ts @@ -0,0 +1,43 @@ +import { PAGES } from '@shared/types' +import { BaseWindow } from './BaseWindow' + +export class CommandBarWindow extends BaseWindow { + constructor() { + super(PAGES.COMMANDBAR, { + width: 500, + height: 80, + show: false, + focusable: true, + 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: false, // Same as PhoneIsland - prevents border artifacts on Windows + 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/commandBarShortcut.ts b/src/main/lib/commandBarShortcut.ts new file mode 100644 index 00000000..8971707f --- /dev/null +++ b/src/main/lib/commandBarShortcut.ts @@ -0,0 +1,138 @@ +import { Log } from '@shared/utils/logger' + +export type CommandBarDoubleTapModifier = 'Ctrl' | 'Alt' | 'AltGr' | 'Cmd' + +let uiohookStarted = false +let lastModifierPress = 0 +let modifierPressedAlone = false +const DOUBLE_TAP_THRESHOLD_MS = 400 + +let currentModifier: CommandBarDoubleTapModifier | undefined +let keydownHandler: ((e: any) => void) | undefined +let keyupHandler: ((e: any) => void) | undefined + +function isModifierKeyEvent(e: any, UiohookKey: any, modifier: CommandBarDoubleTapModifier) { + switch (modifier) { + case 'Cmd': + return e.keycode === UiohookKey.Meta || e.keycode === UiohookKey.MetaRight + case 'Ctrl': + return e.keycode === UiohookKey.Ctrl || e.keycode === UiohookKey.CtrlRight + case 'Alt': + return e.keycode === UiohookKey.Alt || e.keycode === UiohookKey.AltRight + case 'AltGr': + // On most Linux layouts AltGr is the right Alt key, but it can also emit CtrlRight. + // Be permissive so the configured shortcut actually triggers. + return ( + e.keycode === UiohookKey.AltRight || + e.keycode === UiohookKey.CtrlRight + ) + } +} + +export function getDefaultCommandBarModifier(): CommandBarDoubleTapModifier { + return process.platform === 'darwin' ? 'Cmd' : 'Ctrl' +} + +export function isCommandBarDoubleTapShortcutStarted(): boolean { + return uiohookStarted +} + +export function stopCommandBarDoubleTapShortcut() { + if (!uiohookStarted) return + + try { + const { uIOhook } = require('uiohook-napi') + + if (keydownHandler) { + if (typeof uIOhook.off === 'function') { + uIOhook.off('keydown', keydownHandler) + } else if (typeof uIOhook.removeListener === 'function') { + uIOhook.removeListener('keydown', keydownHandler) + } + } + + if (keyupHandler) { + if (typeof uIOhook.off === 'function') { + uIOhook.off('keyup', keyupHandler) + } else if (typeof uIOhook.removeListener === 'function') { + uIOhook.removeListener('keyup', keyupHandler) + } + } + + uIOhook.stop() + Log.info('uIOhook stopped (Command Bar shortcut)') + } catch (e) { + Log.warning('Failed to stop uIOhook (Command Bar shortcut):', e) + } finally { + uiohookStarted = false + lastModifierPress = 0 + modifierPressedAlone = false + currentModifier = undefined + keydownHandler = undefined + keyupHandler = undefined + } +} + +export function startCommandBarDoubleTapShortcut( + modifier: CommandBarDoubleTapModifier, + onTrigger: () => void, +) { + // Allow changing modifier at runtime. + if (uiohookStarted && currentModifier === modifier) return + if (uiohookStarted && currentModifier !== modifier) { + stopCommandBarDoubleTapShortcut() + } + + try { + const { uIOhook, UiohookKey } = require('uiohook-napi') + + // On keydown: track if modifier is pressed, but invalidate if other keys are pressed + keydownHandler = (e: any) => { + if (isModifierKeyEvent(e, UiohookKey, modifier)) { + // Modifier pressed - mark as potentially alone + modifierPressedAlone = true + } else { + // Another key pressed - this is a combo, not a solo modifier tap + modifierPressedAlone = false + } + } + + // On keyup: check double-tap only if modifier was released alone + keyupHandler = (e: any) => { + if (!isModifierKeyEvent(e, UiohookKey, modifier)) return + + // Only count as a tap if no other keys were pressed + if (!modifierPressedAlone) { + modifierPressedAlone = false + return + } + + const now = Date.now() + if (now - lastModifierPress < DOUBLE_TAP_THRESHOLD_MS) { + try { + onTrigger() + } catch (err) { + Log.warning('Command Bar double-tap trigger failed:', err) + } + lastModifierPress = 0 + } else { + lastModifierPress = now + } + modifierPressedAlone = false + } + + uIOhook.on('keydown', keydownHandler) + uIOhook.on('keyup', keyupHandler) + uIOhook.start() + + uiohookStarted = true + currentModifier = modifier + Log.info(`Command Bar shortcut initialized (double-tap ${modifier})`) + } catch (e) { + uiohookStarted = false + currentModifier = undefined + keydownHandler = undefined + keyupHandler = undefined + Log.warning('Failed to initialize Command Bar shortcut (uiohook):', e) + } +} diff --git a/src/main/lib/ipcEvents.ts b/src/main/lib/ipcEvents.ts index 777030eb..46dcccfa 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' @@ -15,6 +16,12 @@ import { useLogin } from '@shared/useLogin' import { PhoneIslandWindow } from '@/classes/windows' import { ClientRequest, get } from 'http' import os from 'os' +import { + CommandBarDoubleTapModifier, + getDefaultCommandBarModifier, + startCommandBarDoubleTapShortcut, + stopCommandBarDoubleTapShortcut, +} from './commandBarShortcut' const { keyboard, Key } = require("@nut-tree-fork/nut-js"); @@ -57,6 +64,30 @@ export function once(event: IPC_EVENTS, callback: () => void) { callback() }) } + +function isUserLoggedIn(): boolean { + return !!store.store.account +} + +// Keep exactly one Command Bar shortcut active at a time. +let activeCommandBarAccelerator: string | undefined +let activeCommandBarLastTrigger = 0 + +export function disableCommandBarShortcuts() { + stopCommandBarDoubleTapShortcut() + + if (activeCommandBarAccelerator) { + try { + globalShortcut.unregister(activeCommandBarAccelerator) + } catch (e) { + Log.warning('Failed to unregister active Command Bar shortcut:', e) + } + } + + activeCommandBarAccelerator = undefined + activeCommandBarLastTrigger = 0 +} + export function registerIpcEvents() { let draggingWindows: OnDraggingWindow = {} @@ -280,14 +311,18 @@ export function registerIpcEvents() { const account = store.get('account') as Account setTimeout(() => { - // Include flag to indicate if audio warmup should run (only first time) - const shouldRunWarmup = !hasRunAudioWarmup + // Include flag to indicate if audio warmup should run + // Only run warmup if: not already run AND main device is NOT physical (i.e., nethlink or webrtc) + const deviceType = account.data?.default_device?.type + const isNethLinkDevice = deviceType !== 'physical' + const shouldRunWarmup = !hasRunAudioWarmup && isNethLinkDevice if (shouldRunWarmup) { hasRunAudioWarmup = true } Log.info('Send CHANGE_PREFERRED_DEVICES event with', { preferredDevices: account.preferredDevices, - shouldRunWarmup + shouldRunWarmup, + deviceType }) AccountController.instance.updatePreferredDevice(account.preferredDevices) PhoneIslandController.instance.window.emit(IPC_EVENTS.CHANGE_PREFERRED_DEVICES, { @@ -323,15 +358,32 @@ export function registerIpcEvents() { }) }) + // Track currently registered call shortcut to handle race conditions + // (renderer may update store via UPDATE_SHARED_STATE before CHANGE_SHORTCUT is processed) + let registeredCallShortcut: string | undefined = undefined + ipcMain.on(IPC_EVENTS.CHANGE_SHORTCUT, async (_, combo) => { - // unregister previous shortcut - await globalShortcut.unregisterAll(); + // Use tracked shortcut if available, otherwise fall back to store + const previousCombo = registeredCallShortcut || store.store.account?.shortcut + if (previousCombo) { + try { + globalShortcut.unregister(previousCombo) + Log.info('Unregistered previous call shortcut:', previousCombo) + } catch (e) { + Log.warning('Failed to unregister previous call shortcut:', e) + } + } + registeredCallShortcut = undefined - // save config to disk AccountController.instance.updateShortcut(combo) - // register shortcut - globalShortcut.register(combo, async () => { + if (!combo || combo.length === 0) { + Log.info('Call shortcut cleared') + return + } + + try { + const registered = globalShortcut.register(combo, async () => { // get selected text content const isMac = os.platform() === 'darwin' const isLinux = os.platform() === 'linux'; @@ -362,7 +414,16 @@ export function registerIpcEvents() { } else { Log.info('Selected text is not a valid number:', selectedText) } - }); + }) + if (registered) { + registeredCallShortcut = combo + Log.info('Call shortcut registered:', combo) + } else { + Log.warning('Failed to register call shortcut:', combo) + } + } catch (e) { + Log.warning('Failed to register call shortcut:', e) + } }) ipcMain.on(IPC_EVENTS.GET_NETHVOICE_CONFIG, async (e, account) => { @@ -462,4 +523,155 @@ export function registerIpcEvents() { Log.error('URL PARAM error', e) } }) + + ipcMain.on(IPC_EVENTS.TOGGLE_COMMAND_BAR, () => { + try { + if (!isUserLoggedIn()) return + CommandBarController.instance?.toggle() + } catch (e) { + Log.error('TOGGLE_COMMAND_BAR error', e) + } + }) + + ipcMain.on(IPC_EVENTS.SHOW_COMMAND_BAR, () => { + try { + if (!isUserLoggedIn()) return + 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) => { + if (!isUserLoggedIn()) { + disableCommandBarShortcuts() + return + } + + const rawCombo = typeof combo === 'string' ? combo.trim() : '' + const normalizedCombo = rawCombo.replace(/AltGraph/g, 'AltGr') + + const toggle = () => { + try { + if (!isUserLoggedIn()) return + CommandBarController.instance?.toggle() + } catch (e) { + Log.error('TOGGLE_COMMAND_BAR error', e) + } + } + + const allowedSoloModifiers: CommandBarDoubleTapModifier[] = ['Ctrl', 'Alt', 'AltGr', 'Cmd'] + const isSoloModifier = (value: string): value is CommandBarDoubleTapModifier => + allowedSoloModifiers.includes(value as CommandBarDoubleTapModifier) + + const isOnlyModifiersButMultiple = (value: string) => { + const parts = value.split('+').map((p) => p.trim()).filter(Boolean) + return parts.length > 1 && parts.every((p) => isSoloModifier(p)) + } + + const applyDefault = () => { + disableCommandBarShortcuts() + startCommandBarDoubleTapShortcut(getDefaultCommandBarModifier(), toggle) + } + + const clearCurrent = () => { + disableCommandBarShortcuts() + } + + // Snapshot current persisted value so we can restore on failure. + // Preserve distinction: undefined = never set, '' = explicitly cleared + const persistedCombo = store.store.account?.commandBarShortcut?.trim() + + clearCurrent() + + // Clear => disable shortcut completely + if (!normalizedCombo) { + AccountController.instance.updateCommandBarShortcut('') + Log.info('Command Bar shortcut cleared: shortcut disabled') + return + } + + // Reject modifier-only combos with multiple modifiers (e.g. Ctrl+Alt) + if (isOnlyModifiersButMultiple(normalizedCombo)) { + Log.warning('Invalid Command Bar shortcut (multiple modifiers only):', normalizedCombo) + AccountController.instance.updateCommandBarShortcut('') + applyDefault() + return + } + + // Modifier-only => use double-tap uiohook + if (isSoloModifier(normalizedCombo)) { + startCommandBarDoubleTapShortcut(normalizedCombo, toggle) + AccountController.instance.updateCommandBarShortcut(normalizedCombo) + Log.info('Command Bar shortcut changed (double-tap):', normalizedCombo) + return + } + + // Key combo => use Electron globalShortcut but require double-press. + // Electron triggers the callback on single press; we gate it to double within threshold. + let registered = false + try { + registered = globalShortcut.register(normalizedCombo, () => { + const now = Date.now() + if (now - activeCommandBarLastTrigger < 400) { + activeCommandBarLastTrigger = 0 + toggle() + } else { + activeCommandBarLastTrigger = now + } + }) + } catch (e) { + Log.warning('Failed to register Command Bar shortcut:', e) + registered = false + } + + if (registered) { + activeCommandBarAccelerator = normalizedCombo + activeCommandBarLastTrigger = 0 + AccountController.instance.updateCommandBarShortcut(normalizedCombo) + Log.info('Command Bar shortcut changed (double-press):', normalizedCombo) + return + } + + // Registration failed => restore persisted combo or default + Log.warning('Failed to register Command Bar shortcut:', normalizedCombo) + if (persistedCombo && persistedCombo.length > 0) { + // User had a custom shortcut - try to restore it + if (isSoloModifier(persistedCombo)) { + startCommandBarDoubleTapShortcut(persistedCombo, toggle) + } else { + try { + const restored = globalShortcut.register(persistedCombo, () => { + const now = Date.now() + if (now - activeCommandBarLastTrigger < 400) { + activeCommandBarLastTrigger = 0 + toggle() + } else { + activeCommandBarLastTrigger = now + } + }) + if (restored) { + activeCommandBarAccelerator = persistedCombo + } else { + applyDefault() + } + } catch (e) { + Log.warning('Failed to restore previous Command Bar shortcut:', e) + applyDefault() + } + } + } else if (persistedCombo === undefined) { + // Never set - apply default + applyDefault() + } + // If persistedCombo is '', user explicitly cleared it - don't apply any shortcut + }) } diff --git a/src/main/lib/windowsFocus.ts b/src/main/lib/windowsFocus.ts new file mode 100644 index 00000000..20a27139 --- /dev/null +++ b/src/main/lib/windowsFocus.ts @@ -0,0 +1,96 @@ +/** + * Native Windows API calls to force window focus. + * This is needed because Electron's focus() doesn't work reliably on Windows + * when another application has the foreground. + */ + +import { BrowserWindow } from 'electron' +import { Log } from '@shared/utils/logger' + +let user32: any = null +let kernel32: any = null + +function initWindowsApi() { + if (process.platform !== 'win32') return false + if (user32 && kernel32) return true + + try { + const koffi = require('koffi') + + user32 = koffi.load('user32.dll') + kernel32 = koffi.load('kernel32.dll') + + return true + } catch (e) { + Log.warning('Failed to load Windows native APIs:', e) + return false + } +} + +// Virtual key codes +const VK_MENU = 0x12 // Alt key +const KEYEVENTF_KEYUP = 0x0002 + +/** + * Force a window to the foreground on Windows. + * Uses native Win32 API calls to bypass Windows' focus stealing prevention. + */ +export function forceWindowFocus(window: BrowserWindow): boolean { + if (process.platform !== 'win32') return false + + if (!initWindowsApi()) return false + + try { + const koffi = require('koffi') + + // Define the functions we need + const GetCurrentThreadId = kernel32.func('uint32_t GetCurrentThreadId()') + const GetWindowThreadProcessId = user32.func('uint32_t GetWindowThreadProcessId(void* hwnd, uint32_t* lpdwProcessId)') + const AttachThreadInput = user32.func('bool AttachThreadInput(uint32_t idAttach, uint32_t idAttachTo, bool fAttach)') + const SetForegroundWindow = user32.func('bool SetForegroundWindow(void* hwnd)') + const BringWindowToTop = user32.func('bool BringWindowToTop(void* hwnd)') + const SetFocus = user32.func('void* SetFocus(void* hwnd)') + const GetForegroundWindow = user32.func('void* GetForegroundWindow()') + const ShowWindow = user32.func('bool ShowWindow(void* hwnd, int nCmdShow)') + const keybd_event = user32.func('void keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo)') + + // Get the native window handle + const hwnd = window.getNativeWindowHandle() + + // Get current thread ID + const currentThreadId = GetCurrentThreadId() + + // Get the thread ID of the foreground window + const foregroundHwnd = GetForegroundWindow() + const foregroundThreadId = GetWindowThreadProcessId(foregroundHwnd, null) + + // Simulate Alt key press/release - this is a classic Windows trick + // to allow SetForegroundWindow to work when another app has focus + keybd_event(VK_MENU, 0, 0, 0) // Alt down + keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) // Alt up + + // Attach our thread to the foreground thread's input + if (currentThreadId !== foregroundThreadId) { + AttachThreadInput(currentThreadId, foregroundThreadId, true) + } + + // Ensure window is shown (SW_SHOW = 5) + ShowWindow(hwnd, 5) + + // Now we can set our window as foreground + SetForegroundWindow(hwnd) + BringWindowToTop(hwnd) + SetFocus(hwnd) + + // Detach the threads + if (currentThreadId !== foregroundThreadId) { + AttachThreadInput(currentThreadId, foregroundThreadId, false) + } + + Log.info('Windows native focus applied successfully') + return true + } catch (e) { + Log.warning('Failed to apply Windows native focus:', e) + return false + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 235b88db..3ff5df93 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,8 @@ import { app, ipcMain, nativeTheme, powerMonitor, protocol, systemPreferences, dialog, shell, globalShortcut } from 'electron' -import { registerIpcEvents, isCallActive } from '@/lib/ipcEvents' +import { registerIpcEvents, isCallActive, disableCommandBarShortcuts } 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' @@ -13,6 +14,11 @@ import { delay, isDev } from '@shared/utils/utils' import { IPC_EVENTS, GIT_RELEASES_URL } from '@shared/constants' import { NetworkController } from './classes/controllers/NetworkController' import { AppController } from './classes/controllers/AppController' +import { + getDefaultCommandBarModifier, + startCommandBarDoubleTapShortcut, + stopCommandBarDoubleTapShortcut, +} from './lib/commandBarShortcut' import { store } from './lib/mainStore' import fs from 'fs' import path from 'path' @@ -74,11 +80,27 @@ function startup() { AccountController.instance.saveLoggedAccount(account, password) } store.saveToDisk() - createNethLink(showNethlink) + + // Create app windows only if we actually have a logged account. + if (store.store.account) { + createNethLink(showNethlink) + } else { + Log.info('LOGIN event ignored: no logged account in store') + } }) ipcMain.on(IPC_EVENTS.LOGOUT, async (_event) => { Log.info('logout from event') + + // Disable Command Bar when not logged in + try { + disableCommandBarShortcuts() + CommandBarController.instance?.hide() + await CommandBarController.instance?.safeQuit() + } catch (e) { + Log.warning('Failed to disable Command Bar on logout:', e) + } + await PhoneIslandController.instance.logout() NethLinkController.instance.logout() AccountController.instance.logout() @@ -374,6 +396,14 @@ function attachOnReadyProcess() { Log.info("Unregister all shortcuts") await globalShortcut.unregisterAll() + // Stop uiohook for command bar + stopCommandBarDoubleTapShortcut() + + // Quit command bar + if (CommandBarController.instance) { + await CommandBarController.instance.safeQuit() + } + Log.info('APP QUIT CORRECTLY') app.exit(); }) @@ -708,6 +738,25 @@ async function createNethLink(show: boolean = true) { checkForUpdate() const account = store.get('account') as Account if (account) { + new CommandBarController() + + // read command bar shortcut from config and set it to app + Log.info('Command Bar shortcut readed:', account.commandBarShortcut) + if (account.commandBarShortcut && account.commandBarShortcut?.length > 0) { + // User has a custom shortcut set + ipcMain.emit( + IPC_EVENTS.CHANGE_COMMAND_BAR_SHORTCUT, + undefined, + account.commandBarShortcut, + ) + } else if (account.commandBarShortcut === undefined) { + // Never set - apply default + startCommandBarDoubleTapShortcut(getDefaultCommandBarModifier(), () => { + CommandBarController.instance?.toggle() + }) + } + // If commandBarShortcut is '', user explicitly cleared it - don't apply any shortcut + // read shortcut from config and set it to app Log.info("Shortcut readed:", account.shortcut) if (account.shortcut && account.shortcut?.length > 0) { diff --git a/src/renderer/public/locales/en/translations.json b/src/renderer/public/locales/en/translations.json index d9a9dbd6..949bdbb1 100644 --- a/src/renderer/public/locales/en/translations.json +++ b/src/renderer/public/locales/en/translations.json @@ -347,6 +347,7 @@ "IP Phone": "IP Phone", "Only nethlink": "Only Nethlink", "ShortcutToCall": "Shortcut to Call", + "CommandBarShortcut": "Shortcut for Command Bar", "ShortcutHelp": "Enter a keyboard shortcut to start a call after selecting some text.", "ShortcutHelpDesc": "Unsupported keys: \"Tab\", \"CapsLock\", \"NumLock\", \"Insert\", \"Escape\", \"Shift\".", "Clear and remove shortcut": "Clear and remove shortcut" @@ -376,8 +377,10 @@ "Theme": "Theme", "Settings": "Settings", "Shortcut": "Shortcut", - "Keyboard shortcut to call": "Keyboard shortcut to call", + "Keyboard shortcut to call": "Keyboard shortcut to Call", + "Keyboard shortcut for command bar": "Keyboard shortcut for Command bar", "Shortcut title description": "Choose a keyboard shortcut to dial any selected number. ", + "Command bar shortcut title description": "Choose a keyboard shortcut to open or hide the Command Bar. To trigger it, press the shortcut twice in a row.", "Shortcut subtitle description": "Avoid using system-reserved shortcuts.", "Shortcut body description": "Keys not supported: tab, capsLock, numLock, insert, esc, shift", "Preferred devices": "Audio and Video settings", @@ -932,6 +935,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..215dc966 100644 --- a/src/renderer/public/locales/it/translations.json +++ b/src/renderer/public/locales/it/translations.json @@ -346,7 +346,8 @@ "Available presence": "Presence disponibili", "IP Phone": "Telefono IP", "Only nethlink": "Solo Nethlink", - "ShortcutToCall": "Scorciatoia per chiamare", + "ShortcutToCall": "Scorciatoia per Chiamare", + "CommandBarShortcut": "Scorciatoia per Command Bar", "ShortcutHelp": "Inserisci una scorciatoia da tastiera per far partire una chiamata dopo aver selezionato un testo.", "ShortcutHelpDesc": "Caratteri non consentiti: \"Tab\", \"CapsLock\", \"NumLock\", \"Insert\", \"Escape\", \"Shift\".", "Clear and remove shortcut": "Ripristina e rimuovi scorciatoia" @@ -377,7 +378,9 @@ "Settings": "Impostazioni", "Shortcut": "Scorciatoia", "Keyboard shortcut to call": "Scorciatoia per chiamare", + "Keyboard shortcut for command bar": "Scorciatoia per Command Bar", "Shortcut title description": "Scegli una combinazione di tasti per avviare una chiamata dopo aver selezionato un testo.", + "Command bar shortcut title description": "Scegli una combinazione di tasti per aprire o nascondere la Command Bar. Per attivarla, premi la scorciatoia due volte di seguito.", "Shortcut subtitle description": "Non usare scorciatoie già presenti nel sistema.", "Shortcut body description": "Tasti non consentiti: tab, capsLock, numLock, insert, esc, shift", "Preferred devices": "Impostazioni Audio e Video", @@ -932,6 +935,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/components/Modules/NethVoice/BaseModule/Navbar.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/Navbar.tsx index f8195770..1cd3f2ef 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/Navbar.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/Navbar.tsx @@ -10,6 +10,7 @@ import { SearchBox } from '../SearchResults/SearchBox' import { ProfileDialog } from './ProfileDialog' import { PresenceForwardDialog } from './ProfileDialog/PresenceSettings/PresenceForwardDialog' import { SettingsShortcutDialog } from './ProfileDialog/SettingsSettings/SettingsShortcutDialog' +import { SettingsCommandBarShortcutDialog } from './ProfileDialog/SettingsSettings/SettingsCommandBarShortcutDialog' import { SettingsDeviceDialog } from './ProfileDialog/SettingsSettings/SettingsDevicesDialog' import { SettingsIncomingCallsDialog } from './ProfileDialog/SettingsSettings/SettingsIncomingCallsDialog' @@ -23,6 +24,9 @@ export function Navbar({ onClickAccount }: NavbarProps): JSX.Element { const [operators] = useNethlinkData('operators') const [isForwardDialogOpen] = useNethlinkData('isForwardDialogOpen') const [isShortcutDialogOpen] = useNethlinkData('isShortcutDialogOpen') + const [isCommandBarShortcutDialogOpen] = useNethlinkData( + 'isCommandBarShortcutDialogOpen', + ) const [isDeviceDialogOpen] = useNethlinkData('isDeviceDialogOpen') const [isIncomingCallsDialogOpen] = useNethlinkData('isIncomingCallsDialogOpen') @@ -57,6 +61,7 @@ export function Navbar({ onClickAccount }: NavbarProps): JSX.Element { {isForwardDialogOpen && } {isShortcutDialogOpen && } + {isCommandBarShortcutDialogOpen && } {isDeviceDialogOpen && } {isIncomingCallsDialogOpen && } void }) => { + const MENU_WIDTH_PX = 300 + const MENU_SLIDE_PX = MENU_WIDTH_PX - 2 const { status } = useAccount() const [selectedMenu, setSelectedMenu] = useState( undefined, @@ -68,7 +70,7 @@ export const ProfileDialog = ({ useEffect(() => { if (selectedMenu) { - setX(250) + setX(MENU_SLIDE_PX) switch (selectedMenu) { case MenuItem.device: setDialogPageTitle(() => t('TopBar.Pair device')) @@ -109,11 +111,12 @@ export const ProfileDialog = ({
diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsBox.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsBox.tsx index fb3b102a..330f8826 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsBox.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsBox.tsx @@ -1,5 +1,6 @@ import { t } from 'i18next' import { + faPhone as PhoneIcon, faKeyboard as KeyboardIcon, faHeadphones as DevicesIcon, faPhoneVolume as IncomingCallsIcon, @@ -9,6 +10,9 @@ import { OptionElement } from '../OptionElement' export function SettingsBox({ onClose }: { onClose?: () => void }) { const [, setIsShortcutDialogOpen] = useNethlinkData('isShortcutDialogOpen') + const [, setIsCommandBarShortcutDialogOpen] = useNethlinkData( + 'isCommandBarShortcutDialogOpen', + ) const [, setIsDeviceDialogOpen] = useNethlinkData('isDeviceDialogOpen') const [, setIsIncomingCallsDialogOpen] = useNethlinkData('isIncomingCallsDialogOpen') @@ -16,13 +20,22 @@ export function SettingsBox({ onClose }: { onClose?: () => void }) {
{ setIsShortcutDialogOpen(true) if (onClose) onClose() }} /> + { + setIsCommandBarShortcutDialogOpen(true) + if (onClose) onClose() + }} + /> + ['Control', 'Alt', 'Meta', 'AltGraph'].includes(key) + + const normalizeKey = (key: string): string => { + switch (key) { + case 'Control': + return 'Ctrl' + case 'Meta': + return 'Cmd' + case 'AltGraph': + return 'AltGr' + case ' ': + return 'Space' + default: + return key.length === 1 ? key.toUpperCase() : key + } + } + + useEffect(() => { + setFocus('combo') + }, []) + + useEffect(() => { + // Use ?? to handle both undefined and empty string correctly + setCombo(account?.commandBarShortcut ?? '') + }, [account?.commandBarShortcut]) + + const schema: z.ZodType<{ combo: string }> = z.object({ + combo: z.string().trim(), + }) + + const { + register, + handleSubmit, + setFocus, + formState: { errors }, + } = useForm({ + defaultValues: { + combo: '', + }, + resolver: zodResolver(schema), + }) + + const allowedSoloModifiers = new Set(['Ctrl', 'Alt', 'AltGr', 'Cmd']) + const isValidCommandBarShortcut = (value: string) => { + const trimmed = value.trim() + if (!trimmed) return true // clear => allowed (restores default) + + const parts = trimmed + .split('+') + .map((p) => p.trim()) + .filter(Boolean) + + if (parts.length === 0) return true + + const nonModifiers = parts.filter((p) => !allowedSoloModifiers.has(p)) + const modifiers = parts.filter((p) => allowedSoloModifiers.has(p)) + + if (nonModifiers.length === 0) { + // Only modifiers: allow exactly one (double-tap) + return modifiers.length === 1 + } + + return true + } + + function handleCancel(e) { + e.preventDefault() + e.stopPropagation() + setIsCommandBarShortcutDialogOpen(false) + } + + function handleClearShortcut() { + setCombo('') + } + + async function submit(data) { + if (!isValidCommandBarShortcut(data.combo)) { + return + } + + const updatedAccount = { ...account!, commandBarShortcut: data.combo } + setAccount(() => updatedAccount) + window.electron.send(IPC_EVENTS.CHANGE_COMMAND_BAR_SHORTCUT, data.combo) + setIsCommandBarShortcutDialogOpen(false) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.preventDefault() + + const rawKey = e.key + if (ignoredKeys.has(rawKey)) return + + const newKeys = new Set(keysPressed) + + // AltGr detection on Linux can be tricky: + // - sometimes e.key is "AltGraph" + // - sometimes it's "Alt" with code "AltRight" + // - sometimes modifier flags are not set + const code = (e as any).code as string | undefined + const hasGetModifierState = typeof (e as any).getModifierState === 'function' + const modifierStateAltGraph = hasGetModifierState + ? (e as any).getModifierState('AltGraph') + : false + + const isAltGr = + rawKey === 'AltGraph' || + modifierStateAltGraph || + code === 'AltRight' || + // common layout: AltGr reported as Ctrl+Alt (AltRight) + (code === 'AltRight' && e.ctrlKey) + + if (isAltGr) { + newKeys.add('AltGr') + } else { + if (e.ctrlKey) newKeys.add('Ctrl') + if (e.altKey) newKeys.add('Alt') + } + + if (e.metaKey) newKeys.add('Cmd') + + if (!isModifierKey(rawKey)) { + newKeys.add(normalizeKey(rawKey)) + } + + setKeysPressed(newKeys) + + const orderedModifiers = ['Ctrl', 'Alt', 'AltGr', 'Cmd'] + const modifiers = orderedModifiers.filter((k) => newKeys.has(k)) + const others = [...newKeys].filter((k) => !orderedModifiers.includes(k as any)) + setCombo([...modifiers, ...others].join('+')) + } + + const handleKeyUp = () => { + setKeysPressed(new Set()) + } + + return ( + <> +
+ + setIsCommandBarShortcutDialogOpen(false)} + /> + +
+
+
+
+

+ {t('TopBar.Keyboard shortcut for command bar')} +

+ +

+ {t('TopBar.Command bar shortcut title description')}{' '} +

+ + { + +

{t('TopBar.Shortcut subtitle description')}

+
+ } + +
+
+ {}} + readOnly + autoFocus + /> + {!!combo && ( + + )} + +
+ + {!isValidCommandBarShortcut(combo) && ( +

+ {t('Common.This field is required')} +

+ )} + +

+ {t('TopBar.Shortcut body description')} +

+ +
+ + + +
+
+
+
+
+
+ + ) +} diff --git a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsShortcutDialog.tsx b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsShortcutDialog.tsx index 4c521498..a4723083 100644 --- a/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsShortcutDialog.tsx +++ b/src/renderer/src/components/Modules/NethVoice/BaseModule/ProfileDialog/SettingsSettings/SettingsShortcutDialog.tsx @@ -52,9 +52,8 @@ export function SettingsShortcutDialog() { }, []) useEffect(() => { - if (account?.shortcut) { - setCombo(account.shortcut) - } + // Use ?? to handle both undefined and empty string correctly + setCombo(account?.shortcut ?? '') }, [account?.shortcut]) const schema: z.ZodType<{ combo: string }> = z.object({ diff --git a/src/renderer/src/hooks/usePhoneIslandEventListeners.ts b/src/renderer/src/hooks/usePhoneIslandEventListeners.ts index 760b799d..1b9c8c28 100644 --- a/src/renderer/src/hooks/usePhoneIslandEventListeners.ts +++ b/src/renderer/src/hooks/usePhoneIslandEventListeners.ts @@ -6,11 +6,17 @@ import { PhoneIslandSizes, } from "@shared/types" import { Log } from "@shared/utils/logger" -import { useState } from "react" +import { useState, useRef, useCallback } from "react" import { t } from "i18next" import { sendNotification } from "@renderer/utils" import { useSharedState } from "@renderer/store" +// Track readiness state for both WebRTC and Socket +// Using module-level variables to persist across re-renders and ensure proper state tracking +let isWebRTCRegistered = false +let isSocketAuthorized = false +let hasTriggeredPhoneIslandReady = false + const defaultSize: PhoneIslandSizes = { sizes: { @@ -25,6 +31,34 @@ const defaultCall = { transferring: false } +// Function to check if both WebRTC and Socket are ready, and trigger PHONE_ISLAND_READY +const checkAndTriggerPhoneIslandReady = () => { + Log.info("checkAndTriggerPhoneIslandReady", { + isWebRTCRegistered, + isSocketAuthorized, + hasTriggeredPhoneIslandReady + }) + + if (isWebRTCRegistered && isSocketAuthorized && !hasTriggeredPhoneIslandReady) { + hasTriggeredPhoneIslandReady = true + Log.info("Both WebRTC and Socket are ready - sending PHONE_ISLAND_READY event") + window.electron.send(IPC_EVENTS.PHONE_ISLAND_READY) + } else if (!isWebRTCRegistered || !isSocketAuthorized) { + Log.info("Waiting for both WebRTC and Socket to be ready", { + waitingForWebRTC: !isWebRTCRegistered, + waitingForSocket: !isSocketAuthorized + }) + } +} + +// Reset the readiness state (called on logout or disconnection) +export const resetPhoneIslandReadyState = () => { + isWebRTCRegistered = false + isSocketAuthorized = false + hasTriggeredPhoneIslandReady = false + Log.info("Phone island ready state reset") +} + export const usePhoneIslandEventListener = () => { const [account] = useSharedState('account') const [connected, setConnected] = useSharedState('connection') @@ -249,15 +283,25 @@ export const usePhoneIslandEventListener = () => { window.api.logout() }), ...eventHandler(PHONE_ISLAND_EVENTS["phone-island-webrtc-registered"], () => { + Log.info("phone-island-webrtc-registered received") + isWebRTCRegistered = true + + // Request ringtone list from phone-island + Log.info("Requesting ringtone list from phone-island") + const ringtoneListEvent = new CustomEvent(PHONE_ISLAND_EVENTS['phone-island-ringing-tone-list'], {}) + window.dispatchEvent(ringtoneListEvent) + + // Check if both WebRTC and Socket are ready setTimeout(() => { - Log.info("phone-island-webrtc-registered", "send PHONE_ISLAND_READY event") - window.electron.send(IPC_EVENTS.PHONE_ISLAND_READY) - - // Request ringtone list from phone-island - Log.info("Requesting ringtone list from phone-island") - const ringtoneListEvent = new CustomEvent(PHONE_ISLAND_EVENTS['phone-island-ringing-tone-list'], {}) - window.dispatchEvent(ringtoneListEvent) - }, 500); + checkAndTriggerPhoneIslandReady() + }, 500) + }), + ...eventHandler(PHONE_ISLAND_EVENTS["phone-island-socket-authorized"], () => { + Log.info("phone-island-socket-authorized received") + isSocketAuthorized = true + + // Check if both WebRTC and Socket are ready + checkAndTriggerPhoneIslandReady() }), ...eventHandler(PHONE_ISLAND_EVENTS["phone-island-all-alerts-removed"]), ...eventHandler(PHONE_ISLAND_EVENTS["phone-island-fullscreen-entered"], () => { diff --git a/src/renderer/src/pages/CommandBarPage.tsx b/src/renderer/src/pages/CommandBarPage.tsx new file mode 100644 index 00000000..0678b3c4 --- /dev/null +++ b/src/renderer/src/pages/CommandBarPage.tsx @@ -0,0 +1,127 @@ +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, faSearch, 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('') + + // Focus with retry mechanism to handle race conditions + const focusInput = (attempt = 0) => { + inputRef.current?.focus() + // Verify focus was successful, retry if not (up to 3 attempts) + if (attempt < 3 && document.activeElement !== inputRef.current) { + setTimeout(() => focusInput(attempt + 1), 50) + } + } + + setTimeout(() => focusInput(), 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/PhoneIslandPage.tsx b/src/renderer/src/pages/PhoneIslandPage.tsx index 93e4f94f..fe0012d3 100644 --- a/src/renderer/src/pages/PhoneIslandPage.tsx +++ b/src/renderer/src/pages/PhoneIslandPage.tsx @@ -9,7 +9,7 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { ElectronDraggableWindow } from '@renderer/components/ElectronDraggableWindow' import { usePhoneIsland } from '@renderer/hooks/usePhoneIsland' import { PhoneIslandContainer } from '@renderer/components/pageComponents/phoneIsland/phoneIslandContainer' -import { usePhoneIslandEventListener } from '@renderer/hooks/usePhoneIslandEventListeners' +import { usePhoneIslandEventListener, resetPhoneIslandReadyState } from '@renderer/hooks/usePhoneIslandEventListeners' import { useInitialize } from '@renderer/hooks/useInitialize' import { useLoggedNethVoiceAPI } from '@renderer/hooks/useLoggedNethVoiceAPI' export function PhoneIslandPage() { @@ -318,6 +318,8 @@ export function PhoneIslandPage() { async function logout() { isOnLogout.current = true + // Reset phone island ready state to allow re-initialization on next login + resetPhoneIslandReadyState() if (deviceInformationObject.current) { await dispatchAndWait(PHONE_ISLAND_EVENTS['phone-island-call-end'], PHONE_ISLAND_EVENTS['phone-island-call-ended']) await dispatchAndWait(PHONE_ISLAND_EVENTS['phone-island-detach'], PHONE_ISLAND_EVENTS['phone-island-detached'], { 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/renderer/src/store.ts b/src/renderer/src/store.ts index d1bd7c49..25f9241b 100644 --- a/src/renderer/src/store.ts +++ b/src/renderer/src/store.ts @@ -138,6 +138,7 @@ export const useNethlinkData = createGlobalStateHook({ parkings: undefined, isForwardDialogOpen: false, isShortcutDialogOpen: false, + isCommandBarShortcutDialogOpen: false, isDeviceDialogOpen: false, isIncomingCallsDialogOpen: false, phonebookModule: { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8240d1d6..5af4baf4 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 @@ -219,6 +223,7 @@ export enum PHONE_ISLAND_EVENTS { 'phone-island-server-reloaded' = 'phone-island-server-reloaded', 'phone-island-server-disconnected' = 'phone-island-server-disconnected', 'phone-island-socket-connected' = 'phone-island-socket-connected', + 'phone-island-socket-authorized' = 'phone-island-socket-authorized', 'phone-island-socket-disconnected' = 'phone-island-socket-disconnected', 'phone-island-socket-reconnected' = 'phone-island-socket-reconnected', 'phone-island-socket-disconnected-popup-open' = 'phone-island-socket-disconnected-popup-open', diff --git a/src/shared/types.ts b/src/shared/types.ts index 2253ba65..c4005f3b 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 } @@ -451,6 +452,7 @@ export type NethLinkPageData = { showPhonebookSearchModule?: boolean, isForwardDialogOpen?: boolean, isShortcutDialogOpen?: boolean, + isCommandBarShortcutDialogOpen?: boolean, isDeviceDialogOpen?: boolean, isIncomingCallsDialogOpen?: boolean, showAddContactModule?: boolean,