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')}
+
+ }
+
+
+
+
+
+
+ >
+ )
+}
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,