diff --git a/.gitignore b/.gitignore index 799ab6be0f..0d0591a3c4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ coderabbit-update-*/ # mastermind (local knowledge base) .knowledge/ +# OpenWolf local tooling state +.wolf/ + # MSI test harness artifacts scripts/msi-test/logs/ scripts/msi-test/.known_hosts @@ -45,6 +48,4 @@ scripts/msi-test/.known_hosts # dylib artifacts *.dylib -# legacy agent docs (consolidated into CLAUDE.md) -AGENTS.md -src/outlookCalendar/AGENTS.md +.gitnexus diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..6a828c2bf1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,125 @@ +# Agent Instructions + +These instructions apply to the repository root. More specific `AGENTS.md` +files in subdirectories override or extend this file. + +`CLAUDE.md` is the historical project guide. When it changes, review it and +carry forward only durable repo guidance here; do not blindly copy +Claude-specific, stale, or unavailable-tool instructions. + +## Project Basics + +- TypeScript codebase. Use TypeScript for new code unless explicitly told + otherwise. +- Run root commands from the repository root. Do not run `yarn build` inside + workspace directories; it creates incorrect output structures. +- Common commands: + +```sh +yarn install && yarn start +yarn build +yarn lint && yarn test +yarn workspaces:build +``` + +- After building `desktop-release-action`, remove + `workspaces/desktop-release-action/dist/dist`; the action only needs + `workspaces/desktop-release-action/dist/index.js`. + +## Patches And Builds + +- Do not confuse the two patch systems: + - Yarn patch protocol: `.yarn/patches/`, currently for `@ewsjs/xhr`. + - `patch-package`: `patches/`, currently for `@kayahr/jest-electron-runner`. +- Never add `@ewsjs/xhr` patches to `patches/`; that creates CI conflicts. +- Windows builds must include all architectures: `x64`, `ia32`, and `arm64`. +- Code signing uses Google Cloud KMS in two phases: build packages without + signing, then sign built packages with `jsign`. + +## UI Work + +- Use Fuselage components from `@rocket.chat/fuselage` for UI work unless the + design requires something Fuselage does not provide. +- Check `Theme.d.ts` for valid color tokens before using Fuselage colors. +- Verify library props, APIs, and tokens against official docs or local + `.d.ts` files instead of assuming. + +## Testing + +- Renderer specs use `*.spec.ts` / `*.spec.tsx`. +- Main-process specs use `*.main.spec.ts`. +- Renderer specs must live in a Jest-matched nested path, for example + `src///*.spec.ts(x)` or + `src//renderer.spec.ts(x)`. Flat `src//*.spec.ts` files are + not discovered by the current `testMatch`. +- Verify new specs with `yarn test --listTests --runTestsByPath ` when + discovery is uncertain. +- Tests run on Windows, macOS, and Linux CI. Keep platform behavior defensive. +- Prefer optional chaining and fallbacks for platform-specific APIs. Only mock + Linux-only APIs like `process.getuid()` when defensive coding is not enough. + +## QA Flow Authoring + +When creating or updating QA assets under `qa/`, read these first: + +- `skills/desktop-qa-flows/SKILL.md` when the task is for a Desktop PR, branch, + or release-candidate QA pass. This file is plain Markdown and can be used by + any agent, including Codex, Claude, Hermes, Cursor, and GitHub agents, when + explicitly pointed to it. +- `qa/README.md` +- `qa/AGENTS.md` +- `qa/flow-template.md` + +QA flows must be executable by a QA engineer or visual agent that knows nothing +about the feature. Do not guess where UI lives. Derive every user-facing step +from the implementation: changed React components, Fuselage icons, i18n labels, +menu definitions, modal buttons, platform branches, tests, and helper pages. + +For branch-specific QA packs, lock the comparison range before deriving flows: +record the base branch, head branch or commit, and whether the whole range was +reviewed. Classify changed Desktop surfaces by user-visible risk, then turn each +risk into a falsifiable hypothesis the flow proves or disproves. Prefer the +smallest useful proof: existing tests, targeted tests, local UI repro, OS-level +repro, or code-path proof when runtime validation is not practical. + +Write the visible path directly in the flow step `Action` cell. Include screen +region, relative position, icon shape, nearby UI, visible labels after +interaction, and the visual confirmation state. If a label only appears as a +tooltip or after clicking a menu, describe the visible anchor first. + +Do not create separate navigation sections or helper navigation files for basic +UI discovery. Validate QA packs with: + +```sh +node qa/scripts/validate-flows.mjs qa/ +node qa/scripts/export-qase-csv.mjs qa/ +``` + +## Code Style + +- Use React functional components with hooks. +- Redux actions follow FSA shape. +- File naming: camelCase for files, PascalCase for components. +- Prefer clear names over unnecessary comments. +- Prefer editing existing files over creating new abstractions unless the new + abstraction removes real complexity or matches an existing pattern. + +## Git And Verification + +- Never commit or push without explicit user permission. +- Never commit directly to `master` or `dev`. +- Read-only git operations are fine. +- Show what will be committed before committing. +- Verify work with the narrowest meaningful checks first, then broader checks + when risk or shared behavior justifies it. +- If GitNexus tooling is available, use the GitNexus section in `CLAUDE.md` for + impact analysis and affected-scope checks. If it is unavailable, do not block + progress solely on that tool; compensate with local code search, tests, and + careful review. + +## Writing + +- Avoid subjective descriptors like "smart" or "excellent". +- Do not invent metrics, user counts, or time estimates. +- PR descriptions should use straightforward language focused on what changed + and why. diff --git a/CLAUDE.md b/CLAUDE.md index 9ebb4325dc..3fa92d4ab1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,9 +50,46 @@ This prevents MSI build failures from KMS CNG provider conflicts. - `*.spec.ts` — Renderer process tests - `*.main.spec.ts` — Main process tests +- Renderer specs must live in a Jest-matched nested path, e.g. `src///*.spec.ts(x)` or `src//renderer.spec.ts(x)`. Flat `src//*.spec.ts` files are not discovered by current `testMatch`; verify new specs with `yarn test --listTests --runTestsByPath `. - Uses `@kayahr/jest-electron-runner` for Electron environment simulation - Tests run on Windows, macOS, AND Linux CI — always verify cross-platform +## QA Flow Authoring + +When creating or updating QA flows under `qa/`, read `qa/README.md`, +`qa/AGENTS.md`, and `qa/flow-template.md` first. QA steps must be +self-contained and visually findable for a tester or visual agent that knows +nothing about the feature. + +- For Desktop PR, branch, or release-candidate QA passes, use + `skills/desktop-qa-flows/SKILL.md` as the workflow entrypoint. The skill + decides whether to update existing flows, add new flows, or create a new + `qa//` pack based on changed user-visible risk. It is plain + Markdown and can be used by any agent, including Codex, Claude, Hermes, Cursor, + and GitHub agents, when explicitly pointed to it. +- Derive tester-facing steps from the implementation, not product intuition. + Inspect changed React components, Fuselage icons, i18n labels, menu + definitions, modal buttons, platform branches, tests, and helper pages. +- For branch-specific QA packs, lock the exact comparison range first: base + branch, head branch or commit, and whether the whole requested range was + reviewed. Do not claim complete QA coverage for a partial review. +- Convert risky Desktop changes into falsifiable user-visible hypotheses before + writing flows. Use Desktop risk surfaces such as Electron main process, + protocol handlers, OS default handlers, settings UI, menus, modals, + packaging/installers, startup, shortcuts, workspace routing, i18n, and layout. +- Put the visible path directly in the `Action` cell. Do not create separate + navigation sections or ask testers to open another file for basic UI + discovery. +- Describe screen region, relative position, icon shape, nearby UI, visible + text after interaction, and the confirmation state. If a tooltip or menu title + appears only after hover/click, describe the visible anchor first. +- Prefer the smallest useful proof for the hypothesis: existing tests, targeted + tests, local UI repro, OS-level repro, or code-path proof when runtime + validation is not practical. +- For Qase compatibility, keep the flow table columns aligned with + `qa/flow-template.md` and run `node qa/scripts/validate-flows.mjs qa/` + plus `node qa/scripts/export-qase-csv.mjs qa/` after changes. + ### Cross-Platform Compatibility Use optional chaining with fallbacks for platform-specific APIs: @@ -103,3 +140,47 @@ git worktree add ../Rocket.Chat.Electron-worktrees/feature-name -b new-branch ma - Use measurable descriptions: "reduced memory usage", "improved by X%" - **Never invent metrics** — no estimated time spent, no speculated user counts. Only include numbers from actual logs, error messages, or documented sources. - PR descriptions: straightforward language, focus on what changed and why + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **Rocket.Chat.Electron** (4593 symbols, 7029 relationships, 103 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/Rocket.Chat.Electron/context` | Codebase overview, check index freshness | +| `gitnexus://repo/Rocket.Chat.Electron/clusters` | All functional areas | +| `gitnexus://repo/Rocket.Chat.Electron/processes` | All execution flows | +| `gitnexus://repo/Rocket.Chat.Electron/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/build/RocketChatDefaultAppAssociations.xml b/build/RocketChatDefaultAppAssociations.xml new file mode 100644 index 0000000000..20e991cc54 --- /dev/null +++ b/build/RocketChatDefaultAppAssociations.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/build/installer.nsh b/build/installer.nsh index e34830e331..38848e1159 100644 --- a/build/installer.nsh +++ b/build/installer.nsh @@ -29,12 +29,67 @@ ${EndIf} !insertMacro disableAutoUpdates Delete "$SMSTARTUP\Rocket.Chat+.lnk" + !insertMacro registerTelephonyCapabilities !macroend !macro customUnInstall ${IfNot} ${Silent} Delete "$SMSTARTUP\Rocket.Chat.lnk" ${EndIf} + !insertMacro unregisterTelephonyCapabilities +!macroend + +; Register Rocket.Chat in RegisteredApplications + Capabilities\URLAssociations so +; the Windows 11 Default Apps picker exposes it as a candidate for tel/callto/sip +; and the `ms-settings:defaultapps?registeredApp{User|Machine}=Rocket.Chat` deep +; link lands on the app-specific page. +!macro registerTelephonyCapabilities + ${If} $installMode == "all" + !insertMacro writeTelephonyCapabilities HKLM + ${Else} + !insertMacro writeTelephonyCapabilities HKCU + ${EndIf} +!macroend + +!macro writeTelephonyCapabilities ROOT + ; Per-scheme ProgIDs that the picker references through URLAssociations. + WriteRegStr ${ROOT} "Software\Classes\RocketChat.tel" "" "URL:Rocket.Chat Telephony" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.tel" "URL Protocol" "" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.tel\DefaultIcon" "" "$INSTDIR\Rocket.Chat.exe,0" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.tel\shell\open\command" "" '"$INSTDIR\Rocket.Chat.exe" "%1"' + + WriteRegStr ${ROOT} "Software\Classes\RocketChat.callto" "" "URL:Rocket.Chat Telephony" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.callto" "URL Protocol" "" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.callto\DefaultIcon" "" "$INSTDIR\Rocket.Chat.exe,0" + WriteRegStr ${ROOT} "Software\Classes\RocketChat.callto\shell\open\command" "" '"$INSTDIR\Rocket.Chat.exe" "%1"' + + ; Capabilities surface consumed by Windows 11 Default Apps. + WriteRegStr ${ROOT} "Software\Rocket.Chat\Capabilities" "ApplicationName" "Rocket.Chat" + WriteRegStr ${ROOT} "Software\Rocket.Chat\Capabilities" "ApplicationDescription" "Rocket.Chat Desktop" + WriteRegStr ${ROOT} "Software\Rocket.Chat\Capabilities" "ApplicationIcon" "$INSTDIR\Rocket.Chat.exe,0" + WriteRegStr ${ROOT} "Software\Rocket.Chat\Capabilities\URLAssociations" "tel" "RocketChat.tel" + WriteRegStr ${ROOT} "Software\Rocket.Chat\Capabilities\URLAssociations" "callto" "RocketChat.callto" + + ; Entry point picked up by Default Apps and the ms-settings deep link. + WriteRegStr ${ROOT} "Software\RegisteredApplications" "Rocket.Chat" "Software\Rocket.Chat\Capabilities" +!macroend + +!macro unregisterTelephonyCapabilities + ${If} $installMode == "all" + !insertMacro deleteTelephonyCapabilities HKLM + ${Else} + !insertMacro deleteTelephonyCapabilities HKCU + ${EndIf} +!macroend + +!macro deleteTelephonyCapabilities ROOT + DeleteRegValue ${ROOT} "Software\RegisteredApplications" "Rocket.Chat" + DeleteRegKey ${ROOT} "Software\Rocket.Chat\Capabilities" + DeleteRegKey /ifempty ${ROOT} "Software\Rocket.Chat" + DeleteRegKey ${ROOT} "Software\Classes\RocketChat.tel" + DeleteRegKey ${ROOT} "Software\Classes\RocketChat.callto" + ; Prior versions may have created RocketChat.sip; clean it up just in case. + DeleteRegKey ${ROOT} "Software\Classes\RocketChat.sip" !macroend !macro disableAutoUpdates diff --git a/build/msiProjectCreated.js b/build/msiProjectCreated.js index 69f9103728..d53ca29651 100644 --- a/build/msiProjectCreated.js +++ b/build/msiProjectCreated.js @@ -93,6 +93,194 @@ exports.default = async function msiProjectCreated(projectFile) { Err.Raise Err.Number, "WriteUpdateJson", "Failed to write " & filePath & ": " & Err.Description End If ]]> + + + + + + + + + + + "\\" Then installDir = installDir & "\\" + + xmlPath = installDir & "resources\\RocketChatDefaultAppAssociations.xml" + policyKey = "HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\System\\DefaultAssociationsConfiguration" + sentinelKey = "HKLM\\SOFTWARE\\Rocket.Chat\\InstallState\\WroteDefaultAssociationsPolicy" + + shell.RegWrite policyKey, xmlPath, "REG_SZ" + If Err.Number <> 0 Then + writeErr = Err.Description + Err.Clear + Err.Raise 1, "WriteDefaultAssociationsPolicy", "Failed to write " & policyKey & ": " & writeErr + End If + + shell.RegWrite sentinelKey, "1", "REG_SZ" + If Err.Number <> 0 Then + writeErr = Err.Description + Err.Clear + Err.Raise 1, "WriteDefaultAssociationsPolicy", "Failed to write " & sentinelKey & ": " & writeErr + End If + ]]> + + + + 0 Then + If Right(installDir, 1) <> "\\" Then installDir = installDir & "\\" + expectedXmlPath = installDir & "resources\\RocketChatDefaultAppAssociations.xml" + policyKey = "HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\System\\DefaultAssociationsConfiguration" + sentinelKey = "HKLM\\SOFTWARE\\Rocket.Chat\\InstallState\\WroteDefaultAssociationsPolicy" + + sentinelValue = "" + sentinelValue = shell.RegRead(sentinelKey) + Err.Clear + + If sentinelValue = "1" Then + currentValue = "" + currentValue = shell.RegRead(policyKey) + Err.Clear + + If currentValue = expectedXmlPath Then + shell.RegDelete policyKey + Err.Clear + End If + + shell.RegDelete sentinelKey + Err.Clear + End If + End If + ]]> + + + + + + + + "\\" Then installDir = installDir & "\\" + + exePath = Chr(34) & installDir & "Rocket.Chat.exe" & Chr(34) & " " & Chr(34) & "%1" & Chr(34) + + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.tel\\", "URL:Rocket.Chat Telephony", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.tel\\URL Protocol", "", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.tel\\DefaultIcon\\", installDir & "Rocket.Chat.exe,0", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.tel\\shell\\open\\command\\", exePath, "REG_SZ" + + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.callto\\", "URL:Rocket.Chat Telephony", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.callto\\URL Protocol", "", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.callto\\DefaultIcon\\", installDir & "Rocket.Chat.exe,0", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Classes\\RocketChat.callto\\shell\\open\\command\\", exePath, "REG_SZ" + + shell.RegWrite "HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\ApplicationName", "Rocket.Chat", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\ApplicationDescription", "Rocket.Chat Desktop", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\ApplicationIcon", installDir & "Rocket.Chat.exe,0", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\URLAssociations\\tel", "RocketChat.tel", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\URLAssociations\\callto", "RocketChat.callto", "REG_SZ" + shell.RegWrite "HKLM\\SOFTWARE\\RegisteredApplications\\Rocket.Chat", "Software\\Rocket.Chat\\Capabilities", "REG_SZ" + ]]> + + + + `; // -- 2. Scheduling entries (only during install, not uninstall) -- @@ -105,9 +293,32 @@ exports.default = async function msiProjectCreated(projectFile) { const installCondition = 'DISABLE_AUTO_UPDATES = "1" AND NOT Installed AND NOT REMOVE~="ALL"'; + const setDefaultAssocInstallCondition = + 'SET_DEFAULT_ASSOCIATIONS = "1" AND NOT Installed AND NOT REMOVE~="ALL"'; + + // Skip cleanup during a major upgrade — when the old MSI's uninstall + // sequence runs as part of RemoveExistingProducts, UPGRADINGPRODUCTCODE + // is populated with the new product's code. Wiping the policy mid-upgrade + // would leave a clean install of the new MSI without the policy (the new + // install only re-writes when SET_DEFAULT_ASSOCIATIONS=1 is passed again, + // which admins typically forget on upgrade). + const setDefaultAssocUninstallCondition = + 'REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""'; + const telephonyUninstallCondition = + 'REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""'; + const telephonyInstallCondition = 'NOT REMOVE~="ALL"'; + const sequenceEntries = ` ${installCondition} - ${installCondition}`; + ${installCondition} + ${setDefaultAssocInstallCondition} + ${setDefaultAssocInstallCondition} + ${telephonyInstallCondition} + ${telephonyInstallCondition} + ${setDefaultAssocUninstallCondition} + ${setDefaultAssocUninstallCondition} + ${telephonyUninstallCondition} + ${telephonyUninstallCondition}`; // -- 3. Inject into the WiX XML -- @@ -150,5 +361,53 @@ exports.default = async function msiProjectCreated(projectFile) { ); } + if (!xml.includes('SET_DEFAULT_ASSOCIATIONS')) { + throw new Error( + `msiProjectCreated: failed to inject SET_DEFAULT_ASSOCIATIONS into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + + if (!xml.includes('WriteDefaultAssociationsPolicy')) { + throw new Error( + `msiProjectCreated: failed to inject WriteDefaultAssociationsPolicy custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + + if (!xml.includes('CleanupDefaultAssociationsPolicy')) { + throw new Error( + `msiProjectCreated: failed to inject CleanupDefaultAssociationsPolicy custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + + if (!xml.includes('SetWriteDefaultAssociationsPolicyData')) { + throw new Error( + `msiProjectCreated: failed to inject SetWriteDefaultAssociationsPolicyData custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + + if (!xml.includes('SetCleanupDefaultAssociationsPolicyData')) { + throw new Error( + `msiProjectCreated: failed to inject SetCleanupDefaultAssociationsPolicyData custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + if (!xml.includes('WriteTelephonyCapabilities')) { + throw new Error( + `msiProjectCreated: failed to inject WriteTelephonyCapabilities custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + + if (!xml.includes('CleanupTelephonyCapabilities')) { + throw new Error( + `msiProjectCreated: failed to inject CleanupTelephonyCapabilities custom action into WiX project. ` + + `The generated .wxs structure may have changed — check ${projectFile}` + ); + } + await fs.promises.writeFile(projectFile, xml, 'utf8'); }; diff --git a/docs/enterprise-deployment.md b/docs/enterprise-deployment.md index 937c119252..2ac1d3d1a9 100644 --- a/docs/enterprise-deployment.md +++ b/docs/enterprise-deployment.md @@ -47,6 +47,24 @@ updates on its own. The property is applied during install. On uninstall, `update.json` is removed together with the rest of the installation directory. +### `SET_DEFAULT_ASSOCIATIONS` + +Makes Rocket.Chat the default `tel:` / `callto:` handler on +unmanaged machines by writing the GPO-equivalent policy registry +value at install time. + +```cmd +msiexec /i rocketchat--win-x64.msi SET_DEFAULT_ASSOCIATIONS=1 /qn +``` + +Full details — including the bundled XML, GPO / Intune / DISM +alternatives, precedence rules, and client-side verification — live in +[`windows-default-app-associations.md`](./windows-default-app-associations.md). + +`SET_DEFAULT_ASSOCIATIONS` only wires Windows protocol defaults for +`tel:`/`callto:`. It does not enable Rocket.Chat telephony by itself; +admins must still enable telephony via overridden Rocket.Chat settings. + ## SCCM / MECM deployment The MSI runs correctly under `NT AUTHORITY\SYSTEM`. Typical deployment @@ -89,3 +107,21 @@ It should contain: "autoUpdate": false } ``` + +## Default app associations (tel:/callto:) + +Windows blocks programmatic per-user default-handler registration, so +making Rocket.Chat the default for `tel:` and `callto:` requires a +policy-channel rollout (GPO, Intune, DISM) or the +`SET_DEFAULT_ASSOCIATIONS=1` MSI flag above for unmanaged machines. + +After deployment, users or support staff can verify the effective +handler in **Settings → Voice & Video → Telephony → Diagnostics**. +The diagnostics distinguish between install registration problems and +per-user default-app choices; when the user choice is missing or points +to another app, the affected row includes an action to open Windows +Default Apps. + +See [`windows-default-app-associations.md`](./windows-default-app-associations.md) +for the bundled XML, every supported channel, precedence rules, and +verification steps. diff --git a/docs/windows-default-app-associations.md b/docs/windows-default-app-associations.md new file mode 100644 index 0000000000..9b8a62697b --- /dev/null +++ b/docs/windows-default-app-associations.md @@ -0,0 +1,173 @@ +# Windows default app associations (tel:/callto:) + +This document covers how to make Rocket.Chat the default handler for +`tel:` and `callto:` links on Windows 10 / 11 fleets at scale. + +It is intentionally self-contained so it can be shared with a Windows +administrator without surrounding context. For other enterprise +deployment topics (MSI vs NSIS choice, `DISABLE_AUTO_UPDATES`, +SCCM/MECM, troubleshooting), see +[`enterprise-deployment.md`](./enterprise-deployment.md). + +## Why this needs admin involvement + +Windows 10 1803+ protects per-user file/protocol defaults with a SHA256 +"UserChoice" hash bound to the user SID + scheme + ProgId, and the +User Choice Protection Driver (UCPD) introduced in March 2024 blocks +all user-mode writes to those keys. As a result, **no installer or app +— Rocket.Chat included — can set itself as the default `tel:` or +`callto:` handler without the user picking it from Settings → Default +Apps**. + +Microsoft's officially supported automation path is the per-machine +policy registry value: + +``` +HKLM\SOFTWARE\Policies\Microsoft\Windows\System!DefaultAssociationsConfiguration +``` + +which Windows reads at user logon and applies to the UserChoice keys +on the user's behalf. This is the same value the +**"Set a default associations configuration file"** Group Policy and +the Intune `ApplicationDefaults` CSP set. + +## What we ship + +The installer drops a ready-made XML at: + +``` +%ProgramFiles%\Rocket.Chat\resources\RocketChatDefaultAppAssociations.xml +``` + +containing: + +```xml + + + + + +``` + +The `RocketChat.tel` and `RocketChat.callto` ProgIDs are the same ones +the installer registers under `HKLM\SOFTWARE\Classes\` on per-machine +MSI installs, so the XML is ready to consume as-is. + +Important: `SET_DEFAULT_ASSOCIATIONS` only wires Windows defaults for +`tel:`/`callto:`. It does not enable Rocket.Chat telephony by itself; +admins still need to enable telephony through overridden Rocket.Chat settings. + +## How to apply it + +Four channels deliver the same XML to a fleet. Pick whichever matches +your environment; do not stack them. + +### 1. MSI public property `SET_DEFAULT_ASSOCIATIONS=1` (unmanaged machines) + +For installs that are not centrally managed by Active Directory or +Intune, pass the property when running the MSI: + +```cmd +msiexec /i rocketchat--win-x64.msi SET_DEFAULT_ASSOCIATIONS=1 /qn +``` + +When set, the installer: + +- Writes + `HKLM\SOFTWARE\Policies\Microsoft\Windows\System!DefaultAssociationsConfiguration` + = the install-dir XML path. +- Writes a sentinel + `HKLM\SOFTWARE\Rocket.Chat\InstallState!WroteDefaultAssociationsPolicy = "1"`. + +On uninstall the policy value is removed only if the sentinel says we +wrote it AND the value still points at our XML. Other values under the +`System` policy key are left untouched. Major upgrades skip cleanup so +the policy survives version bumps. + +Caveats: + +- The per-user NSIS installer (`rocketchat--win-.exe`) + does **not** expose this property — it is MSI-only. +- The policy is read by Windows at user logon. Existing profiles keep + their current default until the next logon; new profiles pick up + Rocket.Chat immediately. + +### 2. Group Policy (Active Directory) + +1. Group Policy Management → edit your target GPO. +2. **Computer Configuration → Administrative Templates → Windows + Components → File Explorer → "Set a default associations + configuration file"**. +3. Set the policy to **Enabled** and point it at the XML — either the + bundled path on each machine, or a UNC share with the same + contents. +4. `gpupdate /force` on a client, log out / log back in. The + in-app diagnostics panel + (Settings → Voice & Video → Telephony → Diagnostics) should show + `isDefault.tel` and `isDefault.callto` as **pass**. + +The registry equivalent (handy for one-off testing): + +```cmd +reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\System" /v DefaultAssociationsConfiguration /t REG_SZ /d "%ProgramFiles%\Rocket.Chat\resources\RocketChatDefaultAppAssociations.xml" /f +``` + +### 3. Intune / MDM `ApplicationDefaults` CSP + +For workgroup / Intune-only fleets: + +- OMA-URI: `./Vendor/MSFT/Policy/Config/ApplicationDefaults/DefaultAssociationsConfiguration` +- Data type: **String** +- Value: Base64-encoded contents of + `RocketChatDefaultAppAssociations.xml` + +### 4. DISM (image deployment) + +For MDT / SCCM image builds: + +```cmd +dism /Online /Import-DefaultAppAssociations:"%ProgramFiles%\Rocket.Chat\resources\RocketChatDefaultAppAssociations.xml" +``` + +Applies to **new** user profiles created after the import; existing +profiles are not modified. + +## Precedence + +GPO and MDM policy refreshes overwrite any value the installer wrote +via `SET_DEFAULT_ASSOCIATIONS=1`. If your environment uses both, treat +the installer flag as a fallback for unmanaged machines only and rely +on the GPO/CSP for managed ones. + +## Verification on a client + +```cmd +reg query "HKLM\SOFTWARE\Policies\Microsoft\Windows\System" /v DefaultAssociationsConfiguration +``` + +should print the XML path. Rocket.Chat's in-app diagnostics then +validate the user's effective protocol choice, not only the installer +registration. On Windows, the `isDefault.tel` and `isDefault.callto` +checks read the user's +`HKCU\Software\Microsoft\Windows\Shell\Associations\URLAssociations\\UserChoice!ProgId` +value and fall back to `UserChoiceLatest\ProgId` when present. This +detects cases where another app is now the active `tel:` or `callto:` +handler even though Rocket.Chat is still correctly registered in +`RegisteredApplications`, `Capabilities\URLAssociations`, and its +ProgIDs. + +In Rocket.Chat: + +1. Open **Settings → Voice & Video → Telephony**. +2. Expand **Diagnostics**. +3. `isDefault.tel` and `isDefault.callto` should both report **pass**. + If either check fails because Windows has no user choice or another + app owns the scheme, the diagnostics row shows an **Open settings** + action that opens the Default Apps page so the user can pick + Rocket.Chat. +4. The Windows registration checks (`windows.registeredApp`, + `windows.capabilities.*`, and `windows.progid.*`) should also pass. + These checks confirm the installer registration that makes + Rocket.Chat available in the Windows Default Apps picker; failures + here indicate an install or registry problem rather than a user + default-app choice. diff --git a/electron-builder.json b/electron-builder.json index 10228961c6..7e098753e2 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -1,11 +1,18 @@ { "files": ["app/**/*", "package.json"], - "extraResources": ["build/icon.ico", "servers.json"], + "extraResources": [ + "build/icon.ico", + { + "from": "build/RocketChatDefaultAppAssociations.xml", + "to": "RocketChatDefaultAppAssociations.xml" + }, + "servers.json" + ], "appId": "chat.rocket", - "protocols": { - "name": "Rocket.Chat", - "schemes": ["rocketchat"] - }, + "protocols": [ + { "name": "Rocket.Chat", "schemes": ["rocketchat"] }, + { "name": "Rocket.Chat Telephony", "schemes": ["callto", "tel"] } + ], "afterPack": "./build/afterPack.js", "mac": { "category": "public.app-category.productivity", @@ -127,7 +134,8 @@ "Name": "Rocket.Chat", "Comment": "Official Rocket.Chat Desktop Client", "GenericName": "Rocket.Chat", - "Categories": "GNOME;GTK;Network;InstantMessaging" + "Categories": "GNOME;GTK;Network;InstantMessaging", + "MimeType": "x-scheme-handler/rocketchat;x-scheme-handler/callto;x-scheme-handler/tel;" } }, "artifactName": "rocketchat-${version}-${os}-${arch}.${ext}" diff --git a/package.json b/package.json index 27f792470d..c1f8450ecb 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,9 @@ "@rollup/plugin-json": "~6.1.0", "@rollup/plugin-node-resolve": "~15.2.3", "@rollup/plugin-replace": "~5.0.5", + "@testing-library/dom": "~10.4.1", + "@testing-library/jest-dom": "~6.9.1", + "@testing-library/react": "~16.3.2", "@types/archiver": "~7.0.0", "@types/dompurify": "~3.2.0", "@types/electron-devtools-installer": "~2.2.5", diff --git a/qa/AGENTS.md b/qa/AGENTS.md new file mode 100644 index 0000000000..4fa27e0c60 --- /dev/null +++ b/qa/AGENTS.md @@ -0,0 +1,134 @@ +# QA Agent Instructions + +These instructions apply to everything under `qa/`. + +## Purpose + +QA packs must be usable by both humans and agents. Write flows so a tester with +no feature context can follow them, while an automation agent can identify the +same preconditions, actions, expected results, and evidence. + +## Before Creating Or Editing A Pack + +- Inspect the feature surface first: changed files, UI components, Fuselage + icons, i18n labels, menu definitions, modal buttons, docs, tests, helper + pages, scripts, and platform-specific behavior. +- For branch-specific packs, lock the comparison range before authoring: + default/base branch, head branch or commit, and whether the complete requested + range was reviewed. +- Classify changed Desktop surfaces by user-visible risk: Electron main process, + protocol handlers, OS default handlers, settings UI, menus, modals, + packaging/installers, startup, shortcuts, workspace routing, i18n, and layout. +- Turn each risky change into a falsifiable hypothesis. A good hypothesis names + the user action, expected behavior, failure mode, platform, and proof needed. +- Extract the tester-facing steps from the implementation. Do not guess where + the feature lives, which label appears, or which control opens the next view. +- Reuse the existing pack shape from `qa/telephony-deeplink/` unless the feature + has a concrete reason to differ. +- Keep QA artifacts under `qa//`; do not put executable QA assets + in `docs/`. +- Do not change app behavior as part of a QA-only task. + +## Pack Rules + +- Create one folder per feature or release area: `qa//`. +- Include a pack `README.md` with prerequisites, smoke order, evidence format, + and folder map. +- Put one scenario per flow file under `flows/`. +- Use numeric flow filenames so humans can run them in order. +- Add `test-links.html` or similar static helpers when testers need clickable + protocol links, deep links, downloads, or browser-driven inputs. +- Add scripts only when they make a repeated check safer or less ambiguous. + +## Flow Rules + +Every flow must include: + +- YAML frontmatter with `id`, `title`, `platforms`, `priority`, `requires`, + `test_links`, `expected_result`, and a `qase` block. +- For new branch-derived flows, a `## Review Basis` section naming the changed + surface, user-visible risk, hypothesis, and smallest useful proof. +- A `## Steps` table with `Step`, `Action`, `Test data`, `Expected result`, + and `Agent action`. +- A `## Evidence` section. +- A `## Failure Signals` section. + +Keep steps concrete and self-contained. A tester should be able to execute the +step table without opening another file or knowing the feature. Include exact +links, commands, menu names, icon location, tab names, section names, and +expected UI text when they are stable. + +Write action text for visual execution. Describe screen region, relative +position, icon shape, visible text after interaction, and the visual +confirmation state. A VLM or a human looking at the app should be able to find +the control without knowing tooltip text that only appears after hover/click. + +Use the implementation as the source of truth for visible steps. For Rocket.Chat +Desktop UI, check the React component tree, Fuselage icon names, translation +keys, menu action definitions, modal button labels, and platform guards. For +browser helpers, inspect the committed HTML. For OS behavior, inspect the branch +code/tests that determine which prompt, settings button, registry/default-app +state, or desktop integration is expected. + +Use the smallest useful proof for the flow's hypothesis. Prefer existing tests +or targeted tests when they directly cover the behavior. Use local UI repros for +rendering and workflow risks, OS-level repros for protocol/default-handler +behavior, and code-path proof only when runtime validation is too expensive or +requires unavailable infrastructure. + +Do not write separate navigation sections for basic UI discovery. Do not point +to another file for basic UI navigation. Put the visually findable path directly +in the `Action` cell where the tester needs it. + +Qase rules: + +- Use `qa/flow-template.md` as the schema source. +- Keep repo source IDs like `TEL-QA-001` in `id`; do not copy them into Qase's + generated case ID column. +- Put Qase import metadata under `qase`. Leave `qase.qase_id: null` for new + imports and fill it only when intentionally updating an existing Qase case. +- Use Qase workspace slugs for dropdown fields. If unsure, keep the existing + pack value and note that the workspace owner must confirm it before import. + +## Script Rules + +- Scripts should print a concise pass/fail summary. +- Scripts should echo or document the OS commands they rely on. +- Prefer read-only checks for registry, desktop files, protocol handlers, logs, + and package contents. +- Keep exporters deterministic and dependency-light. The QA scripts should use + Node built-ins plus existing project dependencies only. +- If a script mutates OS state, put the mutation behind an explicit flag and + document cleanup in the matching flow. + +## Results And Evidence + +- Classify findings as `confirmed` only when reproduced with evidence. +- Classify findings as `suspected` when the code path is credible but the + behavior was not fully reproduced. +- Classify findings as `blocked` when platform, permissions, environment, or + build access prevents validation. +- Report whether the whole requested comparison range was checked. Do not claim + full QA for a partial surface review. +- Do not commit run-specific screenshots, logs, copied diagnostics JSON, or + machine-specific result files unless the user explicitly asks. +- It is fine to commit `results/README.md` and placeholder guidance. +- Tell testers what evidence to capture in each flow. + +## Validation + +After changing QA packs: + +- Run `yarn lint`. +- Run `node qa/scripts/validate-flows.mjs qa/`. +- Run `node qa/scripts/export-qase-csv.mjs qa/` when Qase compatibility + changes. +- For HTML helpers, confirm the expected links or inputs are present in the + file. + +## Safety + +- Do not install packages or download tools just to write QA flows. +- Do not alter OS protocol/default-app settings during documentation work. +- Keep branch-specific QA packs specific; avoid turning them into generic + product documentation unless requested. diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000000..0e79a75d25 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,169 @@ +# QA Packs + +This folder contains structured QA packs for feature branches and release +checks. A QA pack is more than documentation: it can include manual flows, +static click targets, helper scripts, and result-capture guidance. + +Use `qa//` for each feature or release area. Keep the slug short, +lowercase, and specific, for example `qa/telephony-deeplink/`. + +## Pack Structure + +| Path | Required | Purpose | +| --- | --- | --- | +| `README.md` | Yes | Entry point, prerequisites, smoke order, result format | +| `flows/` | Yes | One Markdown file per scenario | +| `test-links.html` | When useful | Static browser page for protocol/deep-link/manual click targets | +| `scripts/` | Optional | Small helper scripts for repeatable environment checks | +| `results/` | Optional | Local evidence notes; do not commit run-specific artifacts by default | + +## Flow Files + +Name flows with a numeric order and short slug: + +```text +flows/01-settings-discovery.md +flows/02-enable-disable-gating.md +flows/10-windows-default-apps.md +``` + +Each flow must be readable by a tester who knows nothing about the feature and +structured enough for an agent to reproduce. Use YAML frontmatter followed by +standard sections. + +Before writing steps, inspect the feature implementation. The flow should be +derived from the UI that will actually appear, not from memory or product +intuition. Check the changed components, i18n strings, menu definitions, modal +buttons, icons, platform branches, tests, and any helper pages. If the UI is not +clear from code, stop and inspect more context before writing the flow. + +For branch-specific QA packs, record the exact comparison range before deriving +flows: base branch, head branch or commit, and whether the whole range was +reviewed. Classify changed Desktop surfaces by user-visible risk, then write a +falsifiable hypothesis for each flow. The hypothesis should be provable by the +smallest useful proof: existing tests, targeted tests, local UI repro, OS-level +repro, or code-path proof when runtime validation is not practical. + +Required frontmatter keys: + +```yaml +--- +id: FEATURE-QA-001 +title: Human-readable title +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Feature area + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build] +test_links: [] +expected_result: One-sentence pass condition. +--- +``` + +Required body sections: + +- `# ` +- `## Review Basis` for new branch-derived flows, with changed surface, + user-visible risk, hypothesis, and smallest useful proof +- `## Steps` with a table containing `Step`, `Action`, `Test data`, + `Expected result`, and `Agent action` +- `## Evidence` +- `## Failure Signals` + +Use `priority: smoke` for the shortest release gate, `priority: release` for +platform-critical coverage, and `priority: high` or `medium` for broader +regression coverage. + +Keep Qase fields under the `qase` block. `qase.priority`, `qase.severity`, +`qase.status`, and `qase.automation` must use slugs configured in the target +Qase workspace. Leave `qase.qase_id` empty until a case already exists in Qase; +Qase owns generated case IDs, while the repo owns `FEATURE-QA-###` source IDs. + +The steps table maps directly to Qase classic steps: + +- `Action` -> `steps_actions` +- `Test data` and `Agent action` -> `steps_data` +- `Expected result` -> `steps_results` + +For new UI, do not assume QA knows the app. The step itself must explain how to +reach the feature from visible UI. Write steps as if a visual agent will execute +them from a screenshot. Include the screen region, relative position, icon +shape, nearby UI, visible label after the click, and visual confirmation that +the tester is in the right place. + +Do not use hidden labels as the primary instruction. If a menu title or tooltip +only appears after hover/click, first describe the visible anchor that lets the +tester find it. + +Example: + +```text +In the left vertical server list, click the three-dots/kebab button near the +bottom edge, below the server buttons. In the menu that opens, click Settings. +On the Settings page, click the Voice & Video tab near the top, then scroll or +scan for the Telephony section heading. +``` + +Bad examples: + +```text +Open Settings. +Open Telephony settings. +Use a separate navigation file to enable Telephony. +Click a tooltip-only menu title without describing the visible icon. +``` + +## Test Link Pages + +Add a static HTML file when QA needs clickable browser actions, protocol links, +deep links, downloads, or copyable sample data. The HTML must work from disk +without a dev server and should label every link with its purpose and expected +result. + +## Helper Scripts + +Scripts should be small, deterministic, and safe by default. Prefer read-only +checks. If a script changes OS or app state, the flow must explicitly say so and +describe how to undo or verify the change. + +Common scripts: + +- `node qa/scripts/validate-flows.mjs qa/<pack>` validates the local source + format before review or export. +- `node qa/scripts/export-qase-csv.mjs qa/<pack>` writes + `qa/<pack>/exports/qase-import.csv` for Qase source type `Qase.io`. + +## Results + +Use this result format in a pack's `results/` folder, a release issue, or a PR +comment: + +```text +Flow ID: +Platform: +Build: +Review range: +Coverage: Full requested range | Partial surface review +Result: Pass | Fail | Blocked +Finding status: confirmed | suspected | blocked | none +Evidence: +Notes: +``` + +Use `confirmed` only when the behavior was reproduced with evidence. Use +`suspected` when the code path is credible but not fully reproduced. Use +`blocked` when platform, permissions, environment, or build access prevents +validation. + +Do not commit screenshots, logs, diagnostics JSON, or machine-specific results +unless a release owner explicitly asks for them. + +## Current Packs + +- `qa/telephony-deeplink/` covers telephony `tel:` / `callto:` links, settings, + diagnostics, workspace selection, default handlers, and installer policy. diff --git a/qa/flow-template.md b/qa/flow-template.md new file mode 100644 index 0000000000..58c5622a19 --- /dev/null +++ b/qa/flow-template.md @@ -0,0 +1,47 @@ +--- +id: FEATURE-QA-001 +title: Flow title +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Feature area + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build] +test_links: [] +expected_result: One-sentence pass condition. +--- + +# Flow Title + +## Review Basis + +- Comparison range: Base branch and head branch or commit used to derive this + branch-specific flow, or `not branch-derived`. +- Changed surface: Code, UI, platform, script, installer, or integration surface + this flow covers. +- User-visible risk: What a customer could notice, hit, or be blocked by. +- Hypothesis: Falsifiable statement this flow proves or disproves. +- Smallest useful proof: Existing test, targeted test, local UI repro, OS-level + repro, or code-path proof used to justify this manual flow. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Start from a clear, observable state and include the visually findable path to reach this feature, using labels/icons/regions verified from the implementation. | Required workspace, account, build, or link input. | State is ready for the next action. | Establish the same precondition using selectors, visible text, or app state. | +| 2 | Perform the user-visible action with screen region, relative position, icon shape, nearby UI, visible labels, menu item, tab, and section names taken from code/i18n. | Input values, URLs, protocol links, or toggles used in the step. | The expected UI or system behavior occurs. | Reproduce the same action with automation available to the agent. | +| 3 | Verify the result using visible UI state or a concrete artifact. | Observed state, command output, copied diagnostics, or captured evidence. | The flow's expected result is satisfied. | Inspect the relevant UI, file, command output, app state, or exported artifact. | + +## Evidence + +- Screenshot, copied diagnostics, command output, log path, or short note. + +## Failure Signals + +- Unexpected UI state. +- Missing or incorrect result. +- Crash, hang, or unrecoverable error. diff --git a/qa/scripts/export-qase-csv.mjs b/qa/scripts/export-qase-csv.mjs new file mode 100644 index 0000000000..73fe6dc3a8 --- /dev/null +++ b/qa/scripts/export-qase-csv.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import YAML from 'yaml'; + +const HEADERS = [ + 'v2.id', + 'suite_id', + 'suite', + 'suite_without_cases', + 'title', + 'description', + 'preconditions', + 'postconditions', + 'severity', + 'priority', + 'status', + 'automation', + 'steps_type', + 'tags', + 'steps_actions', + 'steps_data', + 'steps_results', +]; + +const STEP_COLUMNS = [ + 'Step', + 'Action', + 'Test data', + 'Expected result', + 'Agent action', +]; + +const packPath = process.argv[2]; + +if (!packPath) { + console.error('Usage: node qa/scripts/export-qase-csv.mjs qa/<pack>'); + process.exit(1); +} + +const root = process.cwd(); +const absolutePackPath = path.resolve(root, packPath); +const flowsPath = path.join(absolutePackPath, 'flows'); +const exportsPath = path.join(absolutePackPath, 'exports'); +const outputPath = path.join(exportsPath, 'qase-import.csv'); + +const csvEscape = (value) => { + const stringValue = value == null ? '' : String(value); + return `"${stringValue.replaceAll('"', '""')}"`; +}; + +const stepEscape = (value) => String(value ?? '').replaceAll('"', '""'); + +const encodeStepLines = (values) => + values + .map((value, index) => `${index + 1}. "${stepEscape(value)}"`) + .join('\n'); + +const extractSection = (content, heading) => { + const lines = content.split('\n'); + const start = lines.findIndex((line) => line === `## ${heading}`); + + if (start === -1) { + return ''; + } + + const end = lines.findIndex( + (line, index) => index > start && line.startsWith('## ') + ); + + return lines + .slice(start + 1, end === -1 ? undefined : end) + .join('\n') + .trim(); +}; + +const parseFlow = (filePath) => { + const content = fs.readFileSync(filePath, 'utf8'); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (!frontmatterMatch) { + throw new Error(`${filePath}: missing YAML frontmatter`); + } + + const frontmatter = YAML.parse(frontmatterMatch[1]); + const lines = content.split('\n'); + const tableStart = lines.findIndex( + (line) => line.trim() === `| ${STEP_COLUMNS.join(' | ')} |` + ); + + if (tableStart === -1) { + throw new Error(`${filePath}: missing Qase-compatible steps table`); + } + + const steps = []; + + for (const line of lines.slice(tableStart + 2)) { + if (!line.startsWith('|')) { + break; + } + + const cells = line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); + + if (/^\d+$/.test(cells[0])) { + steps.push({ + action: cells[1], + data: [cells[2], cells[4] && `Agent: ${cells[4]}`] + .filter(Boolean) + .join('\n'), + expected: cells[3], + }); + } + } + + return { content, frontmatter, steps }; +}; + +const flowFiles = fs + .readdirSync(flowsPath) + .filter((file) => file.endsWith('.md')) + .sort(); + +const flows = flowFiles.map((file) => parseFlow(path.join(flowsPath, file))); +const suites = [ + ...new Set( + flows.map(({ frontmatter }) => frontmatter.qase?.suite).filter(Boolean) + ), +]; +const suiteIds = new Map( + suites.map((suite, index) => [suite, String(index + 1)]) +); +const rows = []; + +for (const suite of suites) { + rows.push({ + suite_id: suiteIds.get(suite), + suite, + suite_without_cases: '1', + }); +} + +for (const { content, frontmatter, steps } of flows) { + const qase = frontmatter.qase ?? {}; + const tags = [ + frontmatter.id, + 'source:repo-qa', + ...(frontmatter.platforms ?? []).map((platform) => `platform:${platform}`), + ...(frontmatter.requires ?? []).map( + (requirement) => `requires:${requirement}` + ), + ]; + + rows.push({ + 'v2.id': qase.qase_id ?? '', + 'suite_id': suiteIds.get(qase.suite) ?? '', + 'suite': qase.suite ?? '', + 'title': frontmatter.title, + 'description': [ + `Source flow: ${frontmatter.id}`, + frontmatter.expected_result, + ] + .filter(Boolean) + .join('\n\n'), + 'preconditions': (frontmatter.requires ?? []).join('\n'), + 'postconditions': extractSection(content, 'Evidence'), + 'severity': qase.severity, + 'priority': qase.priority, + 'status': qase.status, + 'automation': qase.automation, + 'steps_type': 'classic', + 'tags': tags.join(','), + 'steps_actions': encodeStepLines(steps.map((step) => step.action)), + 'steps_data': encodeStepLines(steps.map((step) => step.data)), + 'steps_results': encodeStepLines(steps.map((step) => step.expected)), + }); +} + +fs.mkdirSync(exportsPath, { recursive: true }); +fs.writeFileSync( + outputPath, + `${HEADERS.join(',')}\n${rows + .map((row) => HEADERS.map((header) => csvEscape(row[header])).join(',')) + .join('\n')}\n` +); + +console.log( + `Exported ${flows.length} Qase cases to ${path.relative(root, outputPath)}` +); diff --git a/qa/scripts/validate-flows.mjs b/qa/scripts/validate-flows.mjs new file mode 100644 index 0000000000..6b9a28ebed --- /dev/null +++ b/qa/scripts/validate-flows.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import YAML from 'yaml'; + +const REQUIRED_QASE_FIELDS = [ + 'suite', + 'priority', + 'severity', + 'status', + 'automation', +]; +const REQUIRED_STEP_COLUMNS = [ + 'Step', + 'Action', + 'Test data', + 'Expected result', + 'Agent action', +]; +const VAGUE_NAVIGATION_PATTERNS = [ + /\bOpen Settings\b/i, + /\bOpen Telephony settings\b/i, + /\bEnable Telephony\b/i, + /\bTurn Telephony\b/i, +]; +const SELF_CONTAINED_NAVIGATION_PATTERNS = [ + /three-dots\/kebab button/i, + /left vertical server list/i, + /Voice & Video/i, + /Telephony/i, + /test-links\.html/i, + /prompt button labeled/i, + /diagnostics row/i, +]; + +const packPath = process.argv[2]; + +if (!packPath) { + console.error('Usage: node qa/scripts/validate-flows.mjs qa/<pack>'); + process.exit(1); +} + +const root = process.cwd(); +const absolutePackPath = path.resolve(root, packPath); +const flowsPath = path.join(absolutePackPath, 'flows'); +const htmlPath = path.join(absolutePackPath, 'test-links.html'); + +const errors = []; + +const parseFlow = (filePath) => { + const content = fs.readFileSync(filePath, 'utf8'); + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (!frontmatterMatch) { + throw new Error('missing YAML frontmatter'); + } + + const frontmatter = YAML.parse(frontmatterMatch[1]); + const lines = content.split('\n'); + + const tableStart = lines.findIndex( + (line) => line.trim() === `| ${REQUIRED_STEP_COLUMNS.join(' | ')} |` + ); + + if (tableStart === -1) { + throw new Error( + `missing steps table header: | ${REQUIRED_STEP_COLUMNS.join(' | ')} |` + ); + } + + const stepRows = []; + + for (const line of lines.slice(tableStart + 2)) { + if (!line.startsWith('|')) { + break; + } + + const cells = line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); + + if (/^\d+$/.test(cells[0])) { + stepRows.push(cells); + } + } + + return { + content, + frontmatter, + stepRows, + }; +}; + +const assertArray = (flow, field, file) => { + if (!Array.isArray(flow.frontmatter[field])) { + errors.push(`${file}: frontmatter.${field} must be an array`); + } +}; + +const ids = new Map(); +const testLinks = new Set(); + +if (!fs.existsSync(flowsPath)) { + errors.push(`${packPath}: missing flows directory`); +} else { + const flowFiles = fs + .readdirSync(flowsPath) + .filter((file) => file.endsWith('.md')) + .sort(); + + for (const file of flowFiles) { + const flowPath = path.join(flowsPath, file); + + try { + const flow = parseFlow(flowPath); + const { content, frontmatter, stepRows } = flow; + + for (const field of ['id', 'title', 'priority', 'expected_result']) { + if (!frontmatter[field]) { + errors.push(`${file}: frontmatter.${field} is required`); + } + } + + assertArray(flow, 'platforms', file); + assertArray(flow, 'requires', file); + + if (/^## How To Find This UI$/m.test(content)) { + errors.push( + `${file}: remove ## How To Find This UI; navigation must live in executable steps` + ); + } + + if (frontmatter.id) { + if (ids.has(frontmatter.id)) { + errors.push( + `${file}: duplicate id ${frontmatter.id} also used by ${ids.get(frontmatter.id)}` + ); + } + + ids.set(frontmatter.id, file); + } + + if (!frontmatter.qase || typeof frontmatter.qase !== 'object') { + errors.push(`${file}: frontmatter.qase block is required`); + } else { + for (const field of REQUIRED_QASE_FIELDS) { + if (!frontmatter.qase[field]) { + errors.push(`${file}: frontmatter.qase.${field} is required`); + } + } + } + + if (stepRows.length === 0) { + errors.push( + `${file}: steps table must contain at least one numbered step` + ); + } + + for (const [index, row] of stepRows.entries()) { + if (row.length !== REQUIRED_STEP_COLUMNS.length) { + errors.push( + `${file}: step ${index + 1} has ${row.length} columns, expected ${REQUIRED_STEP_COLUMNS.length}` + ); + } + + if (!row[1]) { + errors.push(`${file}: step ${index + 1} action is required`); + } + + if (!row[3]) { + errors.push(`${file}: step ${index + 1} expected result is required`); + } + + if ( + /navigation\.md/i.test(row[1]) || + /^Use\b/i.test(row[1]) || + VAGUE_NAVIGATION_PATTERNS.some((pattern) => pattern.test(row[1])) + ) { + const hasConcreteNavigation = SELF_CONTAINED_NAVIGATION_PATTERNS.some( + (pattern) => pattern.test(row[1]) + ); + + if (!hasConcreteNavigation) { + errors.push( + `${file}: step ${index + 1} must include visually findable navigation in the action text` + ); + } + } + + if ( + /Customize and control app/i.test(row[1]) && + !/three-dots\/kebab button|left vertical server list/i.test(row[1]) + ) { + errors.push( + `${file}: step ${index + 1} uses hidden menu title without a visible anchor` + ); + } + } + + for (const link of frontmatter.test_links ?? []) { + testLinks.add(link); + } + } catch (error) { + errors.push(`${file}: ${error.message}`); + } + } +} + +if (testLinks.size > 0) { + if (!fs.existsSync(htmlPath)) { + errors.push( + `${packPath}: test_links are declared but test-links.html is missing` + ); + } else { + const html = fs.readFileSync(htmlPath, 'utf8'); + const hrefs = new Set( + [...html.matchAll(/href="([^"]+)"/g)].map((match) => decodeURI(match[1])) + ); + + for (const link of testLinks) { + if (!hrefs.has(link)) { + errors.push( + `${packPath}: test link ${link} is not present in test-links.html` + ); + } + } + } +} + +if (errors.length > 0) { + console.error(errors.map((error) => `- ${error}`).join('\n')); + process.exit(1); +} + +console.log(`Validated ${ids.size} QA flows in ${packPath}`); diff --git a/qa/supported-versions/README.md b/qa/supported-versions/README.md new file mode 100644 index 0000000000..2ae33f8dbb --- /dev/null +++ b/qa/supported-versions/README.md @@ -0,0 +1,52 @@ +# Supported Versions QA Pack + +This folder contains manual and agent-readable QA flows for Desktop supported +version checks. It covers startup/version-support behavior where the app decides +whether a server should be allowed, warned, or blocked. + +The flows are intentionally written for testers without implementation context. +When a live environment is unavailable, the flow must say which targeted test or +code-path proof was used and mark runtime validation as blocked or not run. + +## Quick Start + +From the repo root: + +```sh +node qa/scripts/validate-flows.mjs qa/supported-versions +node qa/scripts/export-qase-csv.mjs qa/supported-versions +``` + +## Smoke Order + +1. Run `flows/01-sha-prefixed-exception.md`. + +## Flow Result Format + +```text +Flow ID: +Platform: +Build: +Review range: +Coverage: Full requested range | Partial surface review +Result: Pass | Fail | Blocked +Finding status: confirmed | suspected | blocked | none +Evidence: +Notes: +``` + +## Folder Map + +| Path | Purpose | +| --- | --- | +| `flows/` | Structured QA flows | +| `exports/` | Generated Qase CSV exports | +| `results/` | Optional local evidence area; do not commit run-specific evidence | + +## Source Of Truth + +When updating this pack, derive expected behavior from: + +- `src/servers/supportedVersions/main.ts` +- `src/servers/supportedVersions/main.main.spec.ts` +- `docs/supported-versions-flow.md` diff --git a/qa/supported-versions/exports/README.md b/qa/supported-versions/exports/README.md new file mode 100644 index 0000000000..6197cce3d6 --- /dev/null +++ b/qa/supported-versions/exports/README.md @@ -0,0 +1,15 @@ +# Qase Exports + +This directory is for generated Qase import files. + +Run: + +```sh +node qa/scripts/export-qase-csv.mjs qa/supported-versions +``` + +The script writes `qase-import.csv`. Import it in Qase with source type +`Qase.io`. + +Do not hand-edit generated CSV files. Edit the Markdown flows, validate them, +and export again. diff --git a/qa/supported-versions/exports/qase-import.csv b/qa/supported-versions/exports/qase-import.csv new file mode 100644 index 0000000000..820a09795b --- /dev/null +++ b/qa/supported-versions/exports/qase-import.csv @@ -0,0 +1,25 @@ +v2.id,suite_id,suite,suite_without_cases,title,description,preconditions,postconditions,severity,priority,status,automation,steps_type,tags,steps_actions,steps_data,steps_results +"","1","Supported versions","1","","","","","","","","","","","","","" +"","1","Supported versions","","SHA-prefixed supported-version exception allows matching server commit","Source flow: SV-QA-001 + +A server matching a sha-prefixed exception is treated as supported and does not show the unsupported-version block.","repo_checkout +dependencies_installed","- Targeted test output showing the SHA-prefixed exception test passed. +- Optional screenshot showing the app opened the matching server without the + unsupported-version block.","major","high","actual","manual","classic","SV-QA-001,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:repo_checkout,requires:dependencies_installed","1. ""From a terminal at the Rocket.Chat Desktop repo root, confirm the branch under test is checked out with `git branch --show-current`."" +2. ""Run the targeted supported-version test file: `yarn test src/servers/supportedVersions/main.main.spec.ts --runInBand`."" +3. ""In the terminal output, find the test named `should support sha-prefixed exception versions by git commit hash`."" +4. ""If the release owner provides a runtime fixture, launch the Desktop build and add the server whose supported-version data contains the `sha-<commit-prefix>` exception."" +5. ""Record the result using `qa/supported-versions/README.md` result format.""","1. ""Expected branch: `feat/telephony-deeplink` or the release branch containing the supported-version exception change. +Agent: Run `git branch --show-current` and record the output."" +2. ""Test case names include `should support sha-prefixed exception versions by git commit hash` and `should not match malformed exception versions by git commit hash`. +Agent: Execute the command and capture pass/fail output."" +3. ""Exception version: `sha-bb83777`; server git commit hash: `bb83777b51a42d`. +Agent: Inspect the test output or rerun the specific test if the runner supports name filtering."" +4. ""Fixture must include the same server domain/unique ID rules used by supported-version data and a matching server git commit hash. +Agent: If a fixture is unavailable, mark runtime validation as blocked and keep the targeted test as code-path proof."" +5. ""Include branch, platform, test command, and whether runtime fixture validation was available. +Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""Terminal shows the intended branch name."" +2. ""The targeted test command exits successfully."" +3. ""The matching SHA-prefixed exception test passes."" +4. ""Desktop opens the server without showing the unsupported-version block."" +5. ""Result clearly distinguishes confirmed test proof from blocked runtime validation.""" diff --git a/qa/supported-versions/flows/01-sha-prefixed-exception.md b/qa/supported-versions/flows/01-sha-prefixed-exception.md new file mode 100644 index 0000000000..9d3bda59ee --- /dev/null +++ b/qa/supported-versions/flows/01-sha-prefixed-exception.md @@ -0,0 +1,56 @@ +--- +id: SV-QA-001 +title: SHA-prefixed supported-version exception allows matching server commit +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Supported versions + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [repo_checkout, dependencies_installed] +test_links: [] +expected_result: A server matching a sha-prefixed exception is treated as supported and does not show the unsupported-version block. +--- + +# SHA-Prefixed Supported-Version Exception + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Supported-version exception matching in + `src/servers/supportedVersions/main.ts`. +- User-visible risk: A customer can be blocked by the unsupported-version + dialog even though their server commit is explicitly allowed by a + `sha-<commit-prefix>` exception. +- Hypothesis: When supported-version data contains an exception like + `sha-bb83777` and the server reports git commit hash `bb83777b51a42d`, Desktop + treats the server as supported. +- Smallest useful proof: Targeted Jest coverage in + `src/servers/supportedVersions/main.main.spec.ts`; runtime validation requires + a server and supported-version data fixture controlled by the release owner. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | From a terminal at the Rocket.Chat Desktop repo root, confirm the branch under test is checked out with `git branch --show-current`. | Expected branch: `feat/telephony-deeplink` or the release branch containing the supported-version exception change. | Terminal shows the intended branch name. | Run `git branch --show-current` and record the output. | +| 2 | Run the targeted supported-version test file: `yarn test src/servers/supportedVersions/main.main.spec.ts --runInBand`. | Test case names include `should support sha-prefixed exception versions by git commit hash` and `should not match malformed exception versions by git commit hash`. | The targeted test command exits successfully. | Execute the command and capture pass/fail output. | +| 3 | In the terminal output, find the test named `should support sha-prefixed exception versions by git commit hash`. | Exception version: `sha-bb83777`; server git commit hash: `bb83777b51a42d`. | The matching SHA-prefixed exception test passes. | Inspect the test output or rerun the specific test if the runner supports name filtering. | +| 4 | If the release owner provides a runtime fixture, launch the Desktop build and add the server whose supported-version data contains the `sha-<commit-prefix>` exception. | Fixture must include the same server domain/unique ID rules used by supported-version data and a matching server git commit hash. | Desktop opens the server without showing the unsupported-version block. | If a fixture is unavailable, mark runtime validation as blocked and keep the targeted test as code-path proof. | +| 5 | Record the result using `qa/supported-versions/README.md` result format. | Include branch, platform, test command, and whether runtime fixture validation was available. | Result clearly distinguishes confirmed test proof from blocked runtime validation. | Write a concise result note without committing machine-specific logs unless requested. | + +## Evidence + +- Targeted test output showing the SHA-prefixed exception test passed. +- Optional screenshot showing the app opened the matching server without the + unsupported-version block. + +## Failure Signals + +- Targeted supported-version test fails. +- Matching `sha-<commit-prefix>` exception is treated as unsupported. +- Unsupported-version block appears for the matching runtime fixture. +- Runtime fixture is unavailable but the result is reported as fully validated. diff --git a/qa/supported-versions/results/README.md b/qa/supported-versions/results/README.md new file mode 100644 index 0000000000..bc9f7c7cd7 --- /dev/null +++ b/qa/supported-versions/results/README.md @@ -0,0 +1,10 @@ +# QA Results + +Store local run notes, screenshots, logs, or diagnostics here while testing. Do +not commit run-specific evidence unless a release owner asks for it. + +Recommended filename format: + +```text +YYYY-MM-DD-platform-flow-id-result.md +``` diff --git a/qa/telephony-deeplink/README.md b/qa/telephony-deeplink/README.md new file mode 100644 index 0000000000..7405e3b22d --- /dev/null +++ b/qa/telephony-deeplink/README.md @@ -0,0 +1,73 @@ +# Telephony Deeplink QA Pack + +This folder contains manual and agent-readable QA flows for the telephony +deeplink branch. It covers `tel:` and `callto:` protocol handling, telephony +settings, diagnostics, workspace selection, default-app registration, and +installer policy behavior. + +The steps are intentionally visual and self-contained. They describe screen +region, icon shape, visible labels, and confirmation states because these flows +are meant for both QA engineers and future visual agents. Do not replace those +instructions with references to a separate navigation document. + +## Quick Start + +1. Install or run a build from this branch. +2. Add at least one Rocket.Chat workspace and sign in far enough for the main + window to load. +3. Open `qa/telephony-deeplink/test-links.html` in a browser. +4. Follow the smoke order below, then run the platform-specific flows. +5. For Qase import, run + `node qa/scripts/export-qase-csv.mjs qa/telephony-deeplink` and import the + generated CSV with source type `Qase.io`. + +## Smoke Order + +| Order | Flow | Required on | +| --- | --- | --- | +| 1 | `flows/01-settings-discovery.md` | All platforms | +| 2 | `flows/02-enable-disable-gating.md` | All platforms | +| 3 | `flows/05-single-workspace-links.md` | All platforms | +| 4 | `flows/06-multi-workspace-picker.md` | All platforms with 2+ workspaces | +| 5 | `flows/04-diagnostics-panel.md` | All platforms | +| 6 | `flows/10-windows-default-apps.md` | Windows | +| 7 | `flows/09-macos-cold-launch.md` | macOS | +| 8 | `flows/12-linux-protocols.md` | Linux | + +## Flow Result Format + +Use this format in `results/` or in the release issue/PR comment: + +```text +Flow ID: +Platform: +Build: +Result: Pass | Fail | Blocked +Evidence: +Notes: +``` + +Capture screenshots for UI failures, diagnostics JSON for protocol/default-app +failures, and install logs for MSI failures. + +## Folder Map + +| Path | Purpose | +| --- | --- | +| `test-links.html` | Local browser page with clickable `tel:` and `callto:` links | +| `flows/` | Structured QA flows | +| `exports/` | Generated Qase CSV exports | +| `scripts/` | Future helper scripts for OS-specific checks | +| `results/` | Optional local evidence area; do not commit run-specific evidence | + +## Source Of UI Truth + +When updating this pack, derive visible steps from the implementation: + +- Sidebar entry point: `src/ui/components/SideBar/index.tsx`. +- Settings tabs: `src/ui/components/SettingsView/SettingsView.tsx`. +- Telephony settings: `src/ui/components/SettingsView/VoiceVideoTab.tsx`. +- Telephony feature controls: `src/ui/components/SettingsView/features/`. +- User-facing strings: `src/i18n/` and generated `app/*.i18n-*.js` only as a + built artifact reference. +- Protocol/default-app behavior: `src/telephony/` and related tests. diff --git a/qa/telephony-deeplink/exports/README.md b/qa/telephony-deeplink/exports/README.md new file mode 100644 index 0000000000..408adaf3f5 --- /dev/null +++ b/qa/telephony-deeplink/exports/README.md @@ -0,0 +1,13 @@ +# Qase Exports + +This directory is for generated Qase import files. + +Run: + +```sh +node qa/scripts/export-qase-csv.mjs qa/telephony-deeplink +``` + +The script writes `qase-import.csv`. Import it in Qase with source type `Qase.io`. + +Do not hand-edit generated CSV files. Edit the Markdown flows, validate them, and export again. diff --git a/qa/telephony-deeplink/exports/qase-import.csv b/qa/telephony-deeplink/exports/qase-import.csv new file mode 100644 index 0000000000..84cb518217 --- /dev/null +++ b/qa/telephony-deeplink/exports/qase-import.csv @@ -0,0 +1,327 @@ +v2.id,suite_id,suite,suite_without_cases,title,description,preconditions,postconditions,severity,priority,status,automation,steps_type,tags,steps_actions,steps_data,steps_results +"","1","Telephony deeplinks","1","","","","","","","","","","","","","" +"","1","Telephony deeplinks","","Telephony settings discovery","Source flow: TEL-QA-001 + +Telephony settings are visible under Voice & Video and start disabled unless already configured.","installed_or_running_branch_build +at_least_one_workspace","- Screenshot of Voice & Video showing Telephony. +- Note whether this is a fresh or reused profile.","major","high","actual","manual","classic","TEL-QA-001,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:installed_or_running_branch_build,requires:at_least_one_workspace","1. ""Launch Rocket.Chat."" +2. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, then click `Settings` in the menu that opens."" +3. ""Click the `Voice & Video` tab."" +4. ""Find Telephony."" +5. ""Check initial state.""","1. ""Agent: Start app and wait for main window."" +2. ""Alternate path: app menu item `Settings` when the desktop menu bar is visible. +Agent: Navigate to Settings view."" +3. ""Agent: Select Voice & Video settings tab."" +4. ""Agent: Search visible text for Telephony."" +5. ""Agent: Read the telephony toggle state.""","1. ""Main window is visible."" +2. ""Settings screen opens."" +3. ""Voice & Video options are visible."" +4. ""Telephony section is present."" +5. ""Toggle is off unless this profile was previously configured.""" +"","1","Telephony deeplinks","","Enable and disable telephony protocol handling","Source flow: TEL-QA-002 + +Disabled telephony ignores phone links; enabled telephony handles them.","test-links-html +at_least_one_workspace","- Screenshot or screen recording showing disabled vs enabled behavior. +- Note any OS prompt shown by the browser. +- Optional code-path proof: targeted test output for startup registration showing + `rocketchat` registers at startup while `tel` and `callto` do not.","major","high","actual","manual","classic","TEL-QA-002,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:test-links-html,requires:at_least_one_workspace","1. ""Start from a fresh app launch before enabling Telephony. Open `test-links.html` in a browser and click `tel:+15551234567`."" +2. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading."" +3. ""Switch the Telephony toggle off if it is on."" +4. ""Click `callto:+15551234567` from `test-links.html`."" +5. ""Switch the Telephony toggle on."" +6. ""Click `tel:+15551234567` again."" +7. ""Click `callto:+15551234567`.""","1. ""Telephony has not been enabled in this profile. +Agent: Trigger the link before enabling Telephony."" +2. ""Agent: Navigate to Telephony settings."" +3. ""Agent: Set telephony toggle to off."" +4. ""Agent: Trigger the same link."" +5. ""Agent: Set telephony toggle to on."" +6. ""Agent: Trigger the same link."" +7. ""Agent: Trigger the same link.""","1. ""Rocket.Chat does not place a telephony call request or steal the link as a newly registered phone handler."" +2. ""Toggle is visible."" +3. ""Diagnostics section is hidden or inactive."" +4. ""Rocket.Chat does not place a telephony call request."" +5. ""Default-handler prompt or diagnostics can appear."" +6. ""Rocket.Chat opens the telephony dialpad flow."" +7. ""Rocket.Chat opens the telephony dialpad flow.""" +"","1","Telephony deeplinks","","Default handler prompt","Source flow: TEL-QA-003 + +Enabling telephony shows a clear default-handler prompt with working actions where supported.","telephony_toggle","- Screenshot of the prompt. +- On Windows/Linux, screenshot the opened settings page. On macOS, note that no + settings page is expected.","major","high","actual","manual","classic","TEL-QA-003,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_toggle","1. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony off."" +2. ""Switch the Telephony toggle on."" +3. ""Read prompt copy."" +4. ""In the visible default-handler prompt modal, click the prompt button labeled `Open Settings` if present."" +5. ""Close the prompt."" +6. ""Switch Telephony off, then switch it on again.""","1. ""Agent: Ensure toggle is off."" +2. ""Agent: Set toggle to on."" +3. ""Agent: Capture prompt title/body/buttons."" +4. ""Agent: Activate Open Settings action."" +5. ""Agent: Dismiss modal."" +6. ""Agent: Repeat state transition.""","1. ""Prompt is not visible."" +2. ""Prompt opens once for the enable transition."" +3. ""Copy mentions handling phone links/default app behavior."" +4. ""Windows/Linux opens default-app settings; macOS action is absent or no-op by design."" +5. ""Modal closes and app remains usable."" +6. ""Prompt can appear again on a new off-to-on transition.""" +"","1","Telephony deeplinks","","Telephony diagnostics panel","Source flow: TEL-QA-004 + +Diagnostics can be expanded, refreshed, copied, and interpreted.","telephony_enabled","- Paste copied diagnostics JSON into the result note. +- Screenshot any failed row.","major","high","actual","manual","classic","TEL-QA-004,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled","1. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading."" +2. ""Expand Diagnostics."" +3. ""Click Refresh."" +4. ""Click Copy."" +5. ""Review `isDefault.tel` and `isDefault.callto`."" +6. ""If a diagnostics row has a button labeled `Open Settings`, click it.""","1. ""Agent: Navigate to Telephony settings."" +2. ""Agent: Expand diagnostics panel."" +3. ""Agent: Activate Refresh."" +4. ""Agent: Activate Copy."" +5. ""Agent: Parse copied JSON checks."" +6. ""Agent: Activate row action.""","1. ""Diagnostics accordion is visible."" +2. ""Checks list appears."" +3. ""Generated timestamp or statuses update."" +4. ""Clipboard contains diagnostics JSON."" +5. ""Statuses reflect current OS default-handler state."" +6. ""OS settings opens where supported.""" +"","1","Telephony deeplinks","","Single workspace tel and callto links","Source flow: TEL-QA-005 + +Clicking valid phone links opens the dialpad in the only configured workspace.","telephony_enabled +exactly_one_workspace +test-links-html","- Record each link clicked and observed number. +- Screenshot the dialpad state for at least one `tel:` link and one `callto:` + link.","major","high","actual","manual","classic","TEL-QA-005,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled,requires:exactly_one_workspace,requires:test-links-html","1. ""Confirm only one workspace is configured."" +2. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +3. ""Open `test-links.html` in a browser."" +4. ""Click each valid `tel:` link."" +5. ""Click each valid `callto:` link."" +6. ""Return to Rocket.Chat after each click.""","1. ""Agent: Count configured servers."" +2. ""Agent: Set telephony toggle on."" +3. ""Agent: Open local HTML file."" +4. ""Agent: Trigger each valid `tel:` URI."" +5. ""Agent: Trigger each valid `callto:` URI."" +6. ""Agent: Observe app focus and workspace.""","1. ""Exactly one workspace exists."" +2. ""Telephony is enabled."" +3. ""Link page is visible."" +4. ""Dialpad receives the normalized number."" +5. ""Dialpad receives the normalized number."" +6. ""The only workspace is used; no server picker appears.""" +"","1","Telephony deeplinks","","Multi-workspace server picker","Source flow: TEL-QA-006 + +The server picker opens, routes to the chosen workspace, and supports cancel.","telephony_enabled +two_or_more_workspaces +test-links-html","- Screenshot of server picker. +- Note selected workspace and final dialpad workspace.","major","high","actual","manual","classic","TEL-QA-006,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled,requires:two_or_more_workspaces,requires:test-links-html","1. ""Configure at least two workspaces."" +2. ""Click `tel:+15551234567`."" +3. ""Review modal contents."" +4. ""Click a workspace without Remember checked."" +5. ""Click another link."" +6. ""Close/cancel the modal.""","1. ""Agent: Ensure server list length is at least two."" +2. ""Agent: Trigger the link."" +3. ""Agent: Read server rows."" +4. ""Agent: Select a server with remember false."" +5. ""Agent: Trigger another phone link."" +6. ""Agent: Dismiss modal.""","1. ""Multiple workspace choices are available."" +2. ""Server picker modal opens."" +3. ""Each workspace has readable title/host and selectable row."" +4. ""Dialpad opens in selected workspace."" +5. ""Picker opens again."" +6. ""No call request is placed.""" +"","1","Telephony deeplinks","","Preferred server persistence","Source flow: TEL-QA-007 + +Remembering a workspace skips the picker on later calls and survives restart.","telephony_enabled +two_or_more_workspaces +test-links-html","- Note chosen workspace URL/title. +- Record whether restart preserved the choice.","major","high","actual","manual","classic","TEL-QA-007,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled,requires:two_or_more_workspaces,requires:test-links-html","1. ""Start with a fresh profile or clear persisted app settings before launching Rocket.Chat."" +2. ""Click `tel:+15551234567`."" +3. ""Check Remember choice and select workspace A."" +4. ""Click the same link again."" +5. ""Quit and relaunch Rocket.Chat."" +6. ""Click the same link again."" +7. ""Remove or make workspace A unavailable.""","1. ""Agent: Start from no preferred server."" +2. ""Agent: Trigger link."" +3. ""Agent: Select server with remember true."" +4. ""Agent: Trigger link again."" +5. ""Agent: Restart app."" +6. ""Agent: Trigger link again."" +7. ""Agent: Simulate stale preferred server if practical.""","1. ""First link opens picker."" +2. ""Server picker opens."" +3. ""Dialpad opens in workspace A."" +4. ""Picker is skipped; workspace A is used."" +5. ""App starts normally."" +6. ""Picker is still skipped; workspace A is used."" +7. ""Picker opens instead of silently failing.""" +"","1","Telephony deeplinks","","Telephony global shortcut","Source flow: TEL-QA-008 + +Configured shortcut reads clipboard on trigger and opens the telephony flow.","telephony_enabled +clipboard_access +at_least_one_workspace","- Screenshot shortcut configuration and any error state. +- Record accelerator used.","major","high","actual","manual","classic","TEL-QA-008,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled,requires:clipboard_access,requires:at_least_one_workspace","1. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading."" +2. ""Enable shortcut and set a non-conflicting accelerator."" +3. ""Copy `+15551234567` to clipboard."" +4. ""Press the shortcut."" +5. ""Copy `tel:+55 11 99999-1234`."" +6. ""Press the shortcut."" +7. ""Copy invalid text."" +8. ""Press the shortcut."" +9. ""Try a reserved/conflicting shortcut."" +10. ""Switch Telephony off from the same Telephony settings section."" +11. ""Press the previously configured shortcut again.""","1. ""Agent: Navigate to Telephony settings."" +2. ""Agent: Configure shortcut."" +3. ""Agent: Set clipboard text."" +4. ""Agent: Dispatch accelerator."" +5. ""Agent: Set clipboard text."" +6. ""Agent: Dispatch accelerator."" +7. ""Agent: Set clipboard text to `not a phone`."" +8. ""Agent: Dispatch accelerator."" +9. ""Agent: Configure known conflict if safe."" +10. ""Agent: Set telephony toggle to off."" +11. ""Clipboard still contains the last valid phone number. +Agent: Dispatch the same accelerator after disabling Telephony.""","1. ""Global shortcut controls are visible."" +2. ""Registration status shows success or no error."" +3. ""Clipboard contains phone number."" +4. ""Dialpad opens with `+15551234567`."" +5. ""Clipboard contains URI."" +6. ""Dialpad opens with `+5511999991234`."" +7. ""Clipboard contains invalid text."" +8. ""Dialpad opens with empty input; no malformed number is sent."" +9. ""UI reports failure without crashing."" +10. ""Shortcut controls become inactive or the configured shortcut is no longer active."" +11. ""Dialpad does not open and no call request is created while Telephony is disabled.""" +"","1","Telephony deeplinks","","macOS cold launch from tel and callto links","Source flow: TEL-QA-009 + +Clicking a phone link while Rocket.Chat is closed launches the app and routes the link.","telephony_enabled +app_registered_for_protocols +test-links-html","- Screen recording is preferred because this tests app launch timing. +- Note browser used.","critical","high","actual","manual","classic","TEL-QA-009,source:repo-qa,platform:macos,requires:telephony_enabled,requires:app_registered_for_protocols,requires:test-links-html","1. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +2. ""Quit Rocket.Chat completely."" +3. ""Open `test-links.html` in Safari or Chrome."" +4. ""Click `tel:+15551234567`."" +5. ""Quit Rocket.Chat again."" +6. ""Click `callto:+15551234567`."" +7. ""Repeat while Rocket.Chat is already running.""","1. ""Agent: Set telephony toggle on."" +2. ""Agent: Ensure no Rocket.Chat process remains."" +3. ""Agent: Open local HTML in browser."" +4. ""Agent: Trigger link."" +5. ""Agent: Ensure no process remains."" +6. ""Agent: Trigger link."" +7. ""Agent: Trigger link with running app.""","1. ""App is registered for phone protocols."" +2. ""App is closed."" +3. ""Link page is visible."" +4. ""Rocket.Chat launches and opens telephony flow."" +5. ""App is closed."" +6. ""Rocket.Chat launches and opens telephony flow."" +7. ""Existing app window focuses and routes link.""" +"","1","Telephony deeplinks","","Windows Default Apps for tel and callto","Source flow: TEL-QA-010 + +Rocket.Chat appears in Windows Default Apps and can own both tel and callto.","telephony_enabled +installed_windows_build +test-links-html","- Screenshot Default Apps assignment. +- Copied diagnostics JSON.","critical","high","actual","manual","classic","TEL-QA-010,source:repo-qa,platform:windows,requires:telephony_enabled,requires:installed_windows_build,requires:test-links-html","1. ""Install the branch Windows build."" +2. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +3. ""Open Windows Settings -> Apps -> Default apps."" +4. ""Search or open Rocket.Chat."" +5. ""Assign `tel` to Rocket.Chat."" +6. ""Assign `callto` to Rocket.Chat."" +7. ""Open Rocket.Chat diagnostics."" +8. ""Click valid links in `test-links.html`.""","1. ""Agent: Install app."" +2. ""Agent: Set telephony toggle on."" +3. ""Agent: Open Default Apps settings."" +4. ""Agent: Locate registered app entry."" +5. ""Agent: Set `tel` protocol default."" +6. ""Agent: Set `callto` protocol default."" +7. ""Agent: Read checks."" +8. ""Agent: Trigger `tel` and `callto`.""","1. ""Rocket.Chat appears in Start menu/apps."" +2. ""Prompt or diagnostics is available."" +3. ""Default Apps window opens."" +4. ""Rocket.Chat appears as a candidate."" +5. ""Windows accepts Rocket.Chat."" +6. ""Windows accepts Rocket.Chat."" +7. ""`isDefault.tel` and `isDefault.callto` pass."" +8. ""Rocket.Chat opens telephony flow for both.""" +"","1","Telephony deeplinks","","Windows MSI SET_DEFAULT_ASSOCIATIONS policy","Source flow: TEL-QA-011 + +MSI policy points to an existing XML and diagnostics pass after Windows applies defaults.","msi_artifact +administrator_or_system_install_context","- MSI install log. +- `reg query` output. +- Screenshot or command output proving XML exists. +- Diagnostics JSON.","critical","high","actual","manual","classic","TEL-QA-011,source:repo-qa,platform:windows,requires:msi_artifact,requires:administrator_or_system_install_context","1. ""Install MSI with `SET_DEFAULT_ASSOCIATIONS=1`."" +2. ""Query policy registry value."" +3. ""Check the XML path exists."" +4. ""Sign out and sign back in after install."" +5. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +6. ""Open diagnostics."" +7. ""Click valid links."" +8. ""Uninstall.""","1. ""Agent: Run `msiexec /i <msi> SET_DEFAULT_ASSOCIATIONS=1 /qn`."" +2. ""Agent: Run `reg query """"HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\System"""" /v DefaultAssociationsConfiguration`."" +3. ""Agent: Test file existence."" +4. ""Agent: Reapply Windows default associations through a logon cycle."" +5. ""Agent: Set telephony toggle on."" +6. ""Agent: Read checks."" +7. ""Agent: Trigger `tel` and `callto`."" +8. ""Agent: Remove MSI.""","1. ""Install succeeds."" +2. ""Value points to Rocket.Chat XML under install `resources`."" +3. ""`RocketChatDefaultAppAssociations.xml` exists at the registry path."" +4. ""Windows applies defaults."" +5. ""Diagnostics are available."" +6. ""`isDefault.tel` and `isDefault.callto` pass when policy applied."" +7. ""Rocket.Chat handles both protocols."" +8. ""Installer removes policy only if it owns the sentinel.""" +"","1","Telephony deeplinks","","Linux protocol handling","Source flow: TEL-QA-012 + +Linux desktop protocol defaults can route tel and callto links to Rocket.Chat.","telephony_enabled +installed_linux_build +test-links-html","- Diagnostics JSON. +- Desktop environment name. +- Command output from `xdg-mime` if used.","major","high","actual","manual","classic","TEL-QA-012,source:repo-qa,platform:linux,requires:telephony_enabled,requires:installed_linux_build,requires:test-links-html","1. ""Install Linux package or run packaged build."" +2. ""In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +3. ""Open diagnostics."" +4. ""If a visible diagnostics row shows a button labeled `Open Settings`, click it."" +5. ""Set `tel` and `callto` to Rocket.Chat when diagnostics report another handler."" +6. ""Click valid links in `test-links.html`.""","1. ""Agent: Install app package."" +2. ""Agent: Set telephony toggle on."" +3. ""Agent: Read checks."" +4. ""Agent: Activate Open Settings action."" +5. ""Agent: Use desktop settings first; use xdg tools only when desktop settings are unavailable."" +6. ""Agent: Trigger `tel` and `callto`.""","1. ""Desktop file is available."" +2. ""App attempts protocol registration."" +3. ""`linux.xdg.tel` and `linux.xdg.callto` reflect current defaults."" +4. ""GNOME/KDE default-app settings opens when supported."" +5. ""Rocket.Chat owns both handlers."" +6. ""Rocket.Chat opens telephony flow.""" +"","1","Telephony deeplinks","","Localization and layout smoke","Source flow: TEL-QA-013 + +Telephony UI is readable without clipping in key locales.","telephony_enabled","- Screenshots for each locale and UI surface.","minor","medium","actual","manual","classic","TEL-QA-013,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:telephony_enabled","1. ""Set app/system locale to English."" +2. ""Check prompt, diagnostics, server picker, shortcut controls."" +3. ""Repeat in Brazilian Portuguese."" +4. ""Repeat in German."" +5. ""Resize window to a narrow supported width.""","1. ""Agent: Launch with English locale or record that locale switching is unavailable."" +2. ""Agent: Capture each UI surface."" +3. ""Agent: Launch with pt-BR or record that locale switching is unavailable."" +4. ""Agent: Launch with de-DE or record that locale switching is unavailable."" +5. ""Agent: Set smaller viewport/window.""","1. ""Telephony settings copy is readable."" +2. ""No clipping or overlap."" +3. ""Copy remains readable."" +4. ""Long labels remain readable."" +5. ""Text wraps or truncates cleanly.""" +"","1","Telephony deeplinks","","Negative and edge cases","Source flow: TEL-QA-014 + +Invalid or unsupported inputs fail safely without crashes or wrong calls.","test-links-html","- Notes for each edge input and observed result. +- Screenshots for any unexpected modal or error.","major","high","actual","manual","classic","TEL-QA-014,source:repo-qa,platform:windows,platform:macos,platform:linux,requires:test-links-html","1. ""Disable Telephony and click a valid phone link."" +2. ""In a fresh profile with zero workspaces, use the left vertical server list and click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on."" +3. ""Click `tel:` from `test-links.html`."" +4. ""Click `callto:?subject=empty`."" +5. ""With multiple workspaces, open picker and cancel."" +6. ""Trigger two phone links quickly."" +7. ""Create a stale remembered workspace by remembering a server, then removing that server from the profile.""","1. ""Agent: Trigger `tel:+15551234567`."" +2. ""Agent: Use a fresh profile with zero workspaces."" +3. ""Agent: Trigger empty `tel`."" +4. ""Agent: Trigger query-only `callto`."" +5. ""Agent: Trigger link then dismiss modal."" +6. ""Agent: Fire links in quick succession."" +7. ""Agent: Remove remembered server from server list.""","1. ""No call request is placed."" +2. ""Phone link does not crash; no call is placed."" +3. ""No call request is placed."" +4. ""No call request is placed."" +5. ""No call request is placed."" +6. ""App avoids duplicate/concurrent modal failures."" +7. ""Picker opens instead of silently failing.""" diff --git a/qa/telephony-deeplink/flows/01-settings-discovery.md b/qa/telephony-deeplink/flows/01-settings-discovery.md new file mode 100644 index 0000000000..0738740a59 --- /dev/null +++ b/qa/telephony-deeplink/flows/01-settings-discovery.md @@ -0,0 +1,50 @@ +--- +id: TEL-QA-001 +title: Telephony settings discovery +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_or_running_branch_build, at_least_one_workspace] +test_links: [] +expected_result: Telephony settings are visible under Voice & Video and start disabled unless already configured. +--- + +# Telephony Settings Discovery + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Settings UI under Voice & Video. +- User-visible risk: QA cannot find the new Telephony controls, or the controls + appear in the wrong state. +- Hypothesis: A tester with no feature context can visually navigate to Settings, + open Voice & Video, and identify the Telephony section. +- Smallest useful proof: Local UI repro against an installed or running branch + build. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch Rocket.Chat. | | Main window is visible. | Start app and wait for main window. | +| 2 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, then click `Settings` in the menu that opens. | Alternate path: app menu item `Settings` when the desktop menu bar is visible. | Settings screen opens. | Navigate to Settings view. | +| 3 | Click the `Voice & Video` tab. | | Voice & Video options are visible. | Select Voice & Video settings tab. | +| 4 | Find Telephony. | | Telephony section is present. | Search visible text for Telephony. | +| 5 | Check initial state. | | Toggle is off unless this profile was previously configured. | Read the telephony toggle state. | + +## Evidence + +- Screenshot of Voice & Video showing Telephony. +- Note whether this is a fresh or reused profile. + +## Failure Signals + +- No Voice & Video tab. +- No Telephony section. +- Text is clipped or unreadable. diff --git a/qa/telephony-deeplink/flows/02-enable-disable-gating.md b/qa/telephony-deeplink/flows/02-enable-disable-gating.md new file mode 100644 index 0000000000..dca1aac584 --- /dev/null +++ b/qa/telephony-deeplink/flows/02-enable-disable-gating.md @@ -0,0 +1,56 @@ +--- +id: TEL-QA-002 +title: Enable and disable telephony protocol handling +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [test-links-html, at_least_one_workspace] +test_links: ["tel:+15551234567", "callto:+15551234567"] +expected_result: Disabled telephony ignores phone links; enabled telephony handles them. +--- + +# Enable And Disable Gating + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Telephony settings toggle, startup protocol registration, and + `tel:` / `callto:` link gating. +- User-visible risk: Phone links route into the app while Telephony is disabled, + or fail to route after Telephony is enabled. +- Hypothesis: The enabled setting is the user-visible gate for handling phone + links. +- Smallest useful proof: Local UI repro using `test-links.html` clickable + protocol links, plus targeted startup registration coverage in + `src/app/main/app.main.spec.ts`. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Start from a fresh app launch before enabling Telephony. Open `test-links.html` in a browser and click `tel:+15551234567`. | Telephony has not been enabled in this profile. | Rocket.Chat does not place a telephony call request or steal the link as a newly registered phone handler. | Trigger the link before enabling Telephony. | +| 2 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading. | | Toggle is visible. | Navigate to Telephony settings. | +| 3 | Switch the Telephony toggle off if it is on. | | Diagnostics section is hidden or inactive. | Set telephony toggle to off. | +| 4 | Click `callto:+15551234567` from `test-links.html`. | | Rocket.Chat does not place a telephony call request. | Trigger the same link. | +| 5 | Switch the Telephony toggle on. | | Default-handler prompt or diagnostics can appear. | Set telephony toggle to on. | +| 6 | Click `tel:+15551234567` again. | | Rocket.Chat opens the telephony dialpad flow. | Trigger the same link. | +| 7 | Click `callto:+15551234567`. | | Rocket.Chat opens the telephony dialpad flow. | Trigger the same link. | + +## Evidence + +- Screenshot or screen recording showing disabled vs enabled behavior. +- Note any OS prompt shown by the browser. +- Optional code-path proof: targeted test output for startup registration showing + `rocketchat` registers at startup while `tel` and `callto` do not. + +## Failure Signals + +- Disabled mode still opens the dialpad. +- Enabled mode ignores both `tel:` and `callto:`. +- A fresh startup registers `tel` or `callto` before the tester opts in. diff --git a/qa/telephony-deeplink/flows/03-default-handler-prompt.md b/qa/telephony-deeplink/flows/03-default-handler-prompt.md new file mode 100644 index 0000000000..93c3c42dbe --- /dev/null +++ b/qa/telephony-deeplink/flows/03-default-handler-prompt.md @@ -0,0 +1,53 @@ +--- +id: TEL-QA-003 +title: Default handler prompt +platforms: [windows, macos, linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_toggle] +test_links: [] +expected_result: Enabling telephony shows a clear default-handler prompt with working actions where supported. +--- + +# Default Handler Prompt + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Default handler prompt, remember-choice state, and OS handler + detection. +- User-visible risk: The app prompts at the wrong time, repeats dismissed + prompts, or hides the OS settings path when Rocket.Chat is not the handler. +- Hypothesis: Enabling Telephony shows the correct default-handler prompt flow + and respects the tester's prompt decision. +- Smallest useful proof: Local UI repro with the current OS default-handler + state observed before and after the prompt. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony off. | | Prompt is not visible. | Ensure toggle is off. | +| 2 | Switch the Telephony toggle on. | | Prompt opens once for the enable transition. | Set toggle to on. | +| 3 | Read prompt copy. | | Copy mentions handling phone links/default app behavior. | Capture prompt title/body/buttons. | +| 4 | In the visible default-handler prompt modal, click the prompt button labeled `Open Settings` if present. | | Windows/Linux opens default-app settings; macOS action is absent or no-op by design. | Activate Open Settings action. | +| 5 | Close the prompt. | | Modal closes and app remains usable. | Dismiss modal. | +| 6 | Switch Telephony off, then switch it on again. | | Prompt can appear again on a new off-to-on transition. | Repeat state transition. | + +## Evidence + +- Screenshot of the prompt. +- On Windows/Linux, screenshot the opened settings page. On macOS, note that no + settings page is expected. + +## Failure Signals + +- Prompt blocks the app after dismissal. +- Open Settings crashes or opens an unrelated page. +- Prompt appears repeatedly without user action. diff --git a/qa/telephony-deeplink/flows/04-diagnostics-panel.md b/qa/telephony-deeplink/flows/04-diagnostics-panel.md new file mode 100644 index 0000000000..003114a47a --- /dev/null +++ b/qa/telephony-deeplink/flows/04-diagnostics-panel.md @@ -0,0 +1,50 @@ +--- +id: TEL-QA-004 +title: Telephony diagnostics panel +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled] +test_links: [] +expected_result: Diagnostics can be expanded, refreshed, copied, and interpreted. +--- + +# Diagnostics Panel + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Telephony diagnostics UI and copied diagnostics payload. +- User-visible risk: Support cannot diagnose protocol-handler state because the + panel is missing, stale, or omits platform-specific details. +- Hypothesis: Diagnostics expose enabled state, default-handler state, and useful + platform details without requiring code knowledge. +- Smallest useful proof: Local UI repro plus copied diagnostics text or JSON. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading. | | Diagnostics accordion is visible. | Navigate to Telephony settings. | +| 2 | Expand Diagnostics. | | Checks list appears. | Expand diagnostics panel. | +| 3 | Click Refresh. | | Generated timestamp or statuses update. | Activate Refresh. | +| 4 | Click Copy. | | Clipboard contains diagnostics JSON. | Activate Copy. | +| 5 | Review `isDefault.tel` and `isDefault.callto`. | | Statuses reflect current OS default-handler state. | Parse copied JSON checks. | +| 6 | If a diagnostics row has a button labeled `Open Settings`, click it. | | OS settings opens where supported. | Activate row action. | + +## Evidence + +- Paste copied diagnostics JSON into the result note. +- Screenshot any failed row. + +## Failure Signals + +- Diagnostics never load. +- Copy button does not write JSON. +- `tel` and `callto` rows are missing. diff --git a/qa/telephony-deeplink/flows/05-single-workspace-links.md b/qa/telephony-deeplink/flows/05-single-workspace-links.md new file mode 100644 index 0000000000..704537baff --- /dev/null +++ b/qa/telephony-deeplink/flows/05-single-workspace-links.md @@ -0,0 +1,52 @@ +--- +id: TEL-QA-005 +title: Single workspace tel and callto links +platforms: [windows, macos, linux] +priority: smoke +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, exactly_one_workspace, test-links-html] +test_links: ["tel:+15551234567", "tel:+55 11 99999-1234", "callto:+15551234567", "callto://+491234567890"] +expected_result: Clicking valid phone links opens the dialpad in the only configured workspace. +--- + +# Single Workspace Links + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Deep-link routing when exactly one workspace is available. +- User-visible risk: Clicking a phone link opens the wrong destination, does + nothing, or asks for a server when only one valid workspace exists. +- Hypothesis: With one workspace, enabled Telephony routes `tel:` and `callto:` + links directly to that workspace's call handling path. +- Smallest useful proof: Local UI repro using clickable protocol links from + `test-links.html`. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Confirm only one workspace is configured. | | Exactly one workspace exists. | Count configured servers. | +| 2 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | Telephony is enabled. | Set telephony toggle on. | +| 3 | Open `test-links.html` in a browser. | | Link page is visible. | Open local HTML file. | +| 4 | Click each valid `tel:` link. | | Dialpad receives the normalized number. | Trigger each valid `tel:` URI. | +| 5 | Click each valid `callto:` link. | | Dialpad receives the normalized number. | Trigger each valid `callto:` URI. | +| 6 | Return to Rocket.Chat after each click. | | The only workspace is used; no server picker appears. | Observe app focus and workspace. | + +## Evidence + +- Record each link clicked and observed number. +- Screenshot the dialpad state for at least one `tel:` link and one `callto:` + link. + +## Failure Signals + +- Browser opens an unrelated application. +- Server picker appears despite only one workspace. +- Query strings or formatting are included in the dialed number. diff --git a/qa/telephony-deeplink/flows/06-multi-workspace-picker.md b/qa/telephony-deeplink/flows/06-multi-workspace-picker.md new file mode 100644 index 0000000000..8c8ef9f489 --- /dev/null +++ b/qa/telephony-deeplink/flows/06-multi-workspace-picker.md @@ -0,0 +1,50 @@ +--- +id: TEL-QA-006 +title: Multi-workspace server picker +platforms: [windows, macos, linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, two_or_more_workspaces, test-links-html] +test_links: ["tel:+15551234567", "callto://+491234567890"] +expected_result: The server picker opens, routes to the chosen workspace, and supports cancel. +--- + +# Multi-Workspace Server Picker + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Multi-workspace picker shown after phone-link activation. +- User-visible risk: The app selects the wrong workspace, hides available + workspaces, or leaves the tester without a way to choose where the call goes. +- Hypothesis: With multiple workspaces, enabled Telephony presents a visually + findable picker and routes the call to the selected workspace. +- Smallest useful proof: Local UI repro with two or more configured workspaces. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Configure at least two workspaces. | | Multiple workspace choices are available. | Ensure server list length is at least two. | +| 2 | Click `tel:+15551234567`. | | Server picker modal opens. | Trigger the link. | +| 3 | Review modal contents. | | Each workspace has readable title/host and selectable row. | Read server rows. | +| 4 | Click a workspace without Remember checked. | | Dialpad opens in selected workspace. | Select a server with remember false. | +| 5 | Click another link. | | Picker opens again. | Trigger another phone link. | +| 6 | Close/cancel the modal. | | No call request is placed. | Dismiss modal. | + +## Evidence + +- Screenshot of server picker. +- Note selected workspace and final dialpad workspace. + +## Failure Signals + +- Wrong workspace receives the call. +- Modal cannot be dismissed. +- Text is unreadable or clipped. diff --git a/qa/telephony-deeplink/flows/07-preferred-server-persistence.md b/qa/telephony-deeplink/flows/07-preferred-server-persistence.md new file mode 100644 index 0000000000..e6945bcca1 --- /dev/null +++ b/qa/telephony-deeplink/flows/07-preferred-server-persistence.md @@ -0,0 +1,51 @@ +--- +id: TEL-QA-007 +title: Preferred server persistence +platforms: [windows, macos, linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, two_or_more_workspaces, test-links-html] +test_links: ["tel:+15551234567"] +expected_result: Remembering a workspace skips the picker on later calls and survives restart. +--- + +# Preferred Server Persistence + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Preferred workspace persistence for future phone links. +- User-visible risk: The app forgets the tester's preferred workspace or routes + future phone links to an unexpected server. +- Hypothesis: Choosing a preferred server persists across subsequent `tel:` and + `callto:` activations until changed or cleared by the UI. +- Smallest useful proof: Local UI repro with repeated clickable protocol links. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Start with a fresh profile or clear persisted app settings before launching Rocket.Chat. | | First link opens picker. | Start from no preferred server. | +| 2 | Click `tel:+15551234567`. | | Server picker opens. | Trigger link. | +| 3 | Check Remember choice and select workspace A. | | Dialpad opens in workspace A. | Select server with remember true. | +| 4 | Click the same link again. | | Picker is skipped; workspace A is used. | Trigger link again. | +| 5 | Quit and relaunch Rocket.Chat. | | App starts normally. | Restart app. | +| 6 | Click the same link again. | | Picker is still skipped; workspace A is used. | Trigger link again. | +| 7 | Remove or make workspace A unavailable. | | Picker opens instead of silently failing. | Simulate stale preferred server if practical. | + +## Evidence + +- Note chosen workspace URL/title. +- Record whether restart preserved the choice. + +## Failure Signals + +- Preferred server is forgotten after restart. +- Stale preferred server causes no visible behavior. +- Remember checkbox stays checked after modal cancel/reopen unexpectedly. diff --git a/qa/telephony-deeplink/flows/08-global-shortcut.md b/qa/telephony-deeplink/flows/08-global-shortcut.md new file mode 100644 index 0000000000..5cf56478fd --- /dev/null +++ b/qa/telephony-deeplink/flows/08-global-shortcut.md @@ -0,0 +1,56 @@ +--- +id: TEL-QA-008 +title: Telephony global shortcut +platforms: [windows, macos, linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, clipboard_access, at_least_one_workspace] +test_links: [] +expected_result: Configured shortcut reads clipboard on trigger and opens the telephony flow. +--- + +# Global Shortcut + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Global shortcut and dialpad entrypoint. +- User-visible risk: The shortcut fails, opens the wrong UI, or remains active + when Telephony is disabled. +- Hypothesis: The configured shortcut opens the expected Telephony dialpad or + shortcut handling surface only when the feature state allows it. +- Smallest useful proof: Local keyboard/UI repro on a branch build. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, then find the `Telephony` section heading. | | Global shortcut controls are visible. | Navigate to Telephony settings. | +| 2 | Enable shortcut and set a non-conflicting accelerator. | | Registration status shows success or no error. | Configure shortcut. | +| 3 | Copy `+15551234567` to clipboard. | | Clipboard contains phone number. | Set clipboard text. | +| 4 | Press the shortcut. | | Dialpad opens with `+15551234567`. | Dispatch accelerator. | +| 5 | Copy `tel:+55 11 99999-1234`. | | Clipboard contains URI. | Set clipboard text. | +| 6 | Press the shortcut. | | Dialpad opens with `+5511999991234`. | Dispatch accelerator. | +| 7 | Copy invalid text. | | Clipboard contains invalid text. | Set clipboard text to `not a phone`. | +| 8 | Press the shortcut. | | Dialpad opens with empty input; no malformed number is sent. | Dispatch accelerator. | +| 9 | Try a reserved/conflicting shortcut. | | UI reports failure without crashing. | Configure known conflict if safe. | +| 10 | Switch Telephony off from the same Telephony settings section. | | Shortcut controls become inactive or the configured shortcut is no longer active. | Set telephony toggle to off. | +| 11 | Press the previously configured shortcut again. | Clipboard still contains the last valid phone number. | Dialpad does not open and no call request is created while Telephony is disabled. | Dispatch the same accelerator after disabling Telephony. | + +## Evidence + +- Screenshot shortcut configuration and any error state. +- Record accelerator used. + +## Failure Signals + +- Clipboard is read before shortcut is pressed. +- Invalid clipboard crashes or opens a malformed call. +- Conflict state is silent. +- Shortcut still opens the dialpad after Telephony is disabled. diff --git a/qa/telephony-deeplink/flows/09-macos-cold-launch.md b/qa/telephony-deeplink/flows/09-macos-cold-launch.md new file mode 100644 index 0000000000..21fa5e85bd --- /dev/null +++ b/qa/telephony-deeplink/flows/09-macos-cold-launch.md @@ -0,0 +1,51 @@ +--- +id: TEL-QA-009 +title: macOS cold launch from tel and callto links +platforms: [macos] +priority: release +qase: + suite: Telephony deeplinks + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, app_registered_for_protocols, test-links-html] +test_links: ["tel:+15551234567", "callto:+15551234567"] +expected_result: Clicking a phone link while Rocket.Chat is closed launches the app and routes the link. +--- + +# macOS Cold Launch + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: macOS protocol handling during cold launch. +- User-visible risk: A phone link clicked while the app is closed is lost, + ignored, or routed before workspaces are ready. +- Hypothesis: macOS launches Rocket.Chat from a `tel:` or `callto:` link and + preserves the pending call until the app can route it. +- Smallest useful proof: OS-level repro on macOS using clickable protocol links. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | App is registered for phone protocols. | Set telephony toggle on. | +| 2 | Quit Rocket.Chat completely. | | App is closed. | Ensure no Rocket.Chat process remains. | +| 3 | Open `test-links.html` in Safari or Chrome. | | Link page is visible. | Open local HTML in browser. | +| 4 | Click `tel:+15551234567`. | | Rocket.Chat launches and opens telephony flow. | Trigger link. | +| 5 | Quit Rocket.Chat again. | | App is closed. | Ensure no process remains. | +| 6 | Click `callto:+15551234567`. | | Rocket.Chat launches and opens telephony flow. | Trigger link. | +| 7 | Repeat while Rocket.Chat is already running. | | Existing app window focuses and routes link. | Trigger link with running app. | + +## Evidence + +- Screen recording is preferred because this tests app launch timing. +- Note browser used. + +## Failure Signals + +- App launches but no dialpad opens. +- Browser reports no handler. +- Link works only when app is already running. diff --git a/qa/telephony-deeplink/flows/10-windows-default-apps.md b/qa/telephony-deeplink/flows/10-windows-default-apps.md new file mode 100644 index 0000000000..23a0175e15 --- /dev/null +++ b/qa/telephony-deeplink/flows/10-windows-default-apps.md @@ -0,0 +1,53 @@ +--- +id: TEL-QA-010 +title: Windows Default Apps for tel and callto +platforms: [windows] +priority: release +qase: + suite: Telephony deeplinks + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, installed_windows_build, test-links-html] +test_links: ["tel:+15551234567", "callto:+15551234567"] +expected_result: Rocket.Chat appears in Windows Default Apps and can own both tel and callto. +--- + +# Windows Default Apps + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Windows default-app detection and Settings handoff. +- User-visible risk: Windows reports Rocket.Chat as unavailable or the app sends + testers to the wrong system settings surface. +- Hypothesis: Windows users can verify and change `tel:` / `callto:` ownership + through the expected Default Apps UI path. +- Smallest useful proof: OS-level repro on Windows plus observed Default Apps + state. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Install the branch Windows build. | | Rocket.Chat appears in Start menu/apps. | Install app. | +| 2 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | Prompt or diagnostics is available. | Set telephony toggle on. | +| 3 | Open Windows Settings -> Apps -> Default apps. | | Default Apps window opens. | Open Default Apps settings. | +| 4 | Search or open Rocket.Chat. | | Rocket.Chat appears as a candidate. | Locate registered app entry. | +| 5 | Assign `tel` to Rocket.Chat. | | Windows accepts Rocket.Chat. | Set `tel` protocol default. | +| 6 | Assign `callto` to Rocket.Chat. | | Windows accepts Rocket.Chat. | Set `callto` protocol default. | +| 7 | Open Rocket.Chat diagnostics. | | `isDefault.tel` and `isDefault.callto` pass. | Read checks. | +| 8 | Click valid links in `test-links.html`. | | Rocket.Chat opens telephony flow for both. | Trigger `tel` and `callto`. | + +## Evidence + +- Screenshot Default Apps assignment. +- Copied diagnostics JSON. + +## Failure Signals + +- Rocket.Chat is not listed. +- Only one protocol can be assigned. +- Diagnostics fail despite Windows showing Rocket.Chat as owner. diff --git a/qa/telephony-deeplink/flows/11-windows-msi-policy.md b/qa/telephony-deeplink/flows/11-windows-msi-policy.md new file mode 100644 index 0000000000..6339fdd947 --- /dev/null +++ b/qa/telephony-deeplink/flows/11-windows-msi-policy.md @@ -0,0 +1,55 @@ +--- +id: TEL-QA-011 +title: Windows MSI SET_DEFAULT_ASSOCIATIONS policy +platforms: [windows] +priority: release +qase: + suite: Telephony deeplinks + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: [msi_artifact, administrator_or_system_install_context] +test_links: ["tel:+15551234567", "callto:+15551234567"] +expected_result: MSI policy points to an existing XML and diagnostics pass after Windows applies defaults. +--- + +# Windows MSI Policy + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Windows MSI installer protocol association policy. +- User-visible risk: Enterprise installs do not register the phone-link + protocols, or policy blocks expected association behavior. +- Hypothesis: The MSI package contains the expected `tel:` and `callto:` + association data needed for Windows deployment. +- Smallest useful proof: Installer/package inspection or Windows install repro, + depending on available release artifacts. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Install MSI with `SET_DEFAULT_ASSOCIATIONS=1`. | | Install succeeds. | Run `msiexec /i <msi> SET_DEFAULT_ASSOCIATIONS=1 /qn`. | +| 2 | Query policy registry value. | | Value points to Rocket.Chat XML under install `resources`. | Run `reg query "HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\System" /v DefaultAssociationsConfiguration`. | +| 3 | Check the XML path exists. | | `RocketChatDefaultAppAssociations.xml` exists at the registry path. | Test file existence. | +| 4 | Sign out and sign back in after install. | | Windows applies defaults. | Reapply Windows default associations through a logon cycle. | +| 5 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | Diagnostics are available. | Set telephony toggle on. | +| 6 | Open diagnostics. | | `isDefault.tel` and `isDefault.callto` pass when policy applied. | Read checks. | +| 7 | Click valid links. | | Rocket.Chat handles both protocols. | Trigger `tel` and `callto`. | +| 8 | Uninstall. | | Installer removes policy only if it owns the sentinel. | Remove MSI. | + +## Evidence + +- MSI install log. +- `reg query` output. +- Screenshot or command output proving XML exists. +- Diagnostics JSON. + +## Failure Signals + +- Registry path contains `resources\\resources`. +- XML file is missing. +- Policy is removed during major upgrade unexpectedly. diff --git a/qa/telephony-deeplink/flows/12-linux-protocols.md b/qa/telephony-deeplink/flows/12-linux-protocols.md new file mode 100644 index 0000000000..6fdb83bfe7 --- /dev/null +++ b/qa/telephony-deeplink/flows/12-linux-protocols.md @@ -0,0 +1,51 @@ +--- +id: TEL-QA-012 +title: Linux protocol handling +platforms: [linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled, installed_linux_build, test-links-html] +test_links: ["tel:+15551234567", "callto:+15551234567"] +expected_result: Linux desktop protocol defaults can route tel and callto links to Rocket.Chat. +--- + +# Linux Protocol Handling + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Linux desktop protocol registration and runtime handling. +- User-visible risk: Linux desktops do not expose Rocket.Chat as a handler, or + phone links fail after registration. +- Hypothesis: Supported Linux desktop environments can associate and invoke + Rocket.Chat for `tel:` and `callto:` links. +- Smallest useful proof: OS-level repro with desktop/MIME handler inspection. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Install Linux package or run packaged build. | | Desktop file is available. | Install app package. | +| 2 | In the left vertical server list, click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | App attempts protocol registration. | Set telephony toggle on. | +| 3 | Open diagnostics. | | `linux.xdg.tel` and `linux.xdg.callto` reflect current defaults. | Read checks. | +| 4 | If a visible diagnostics row shows a button labeled `Open Settings`, click it. | | GNOME/KDE default-app settings opens when supported. | Activate Open Settings action. | +| 5 | Set `tel` and `callto` to Rocket.Chat when diagnostics report another handler. | | Rocket.Chat owns both handlers. | Use desktop settings first; use xdg tools only when desktop settings are unavailable. | +| 6 | Click valid links in `test-links.html`. | | Rocket.Chat opens telephony flow. | Trigger `tel` and `callto`. | + +## Evidence + +- Diagnostics JSON. +- Desktop environment name. +- Command output from `xdg-mime` if used. + +## Failure Signals + +- Diagnostics cannot determine handler. +- Default-app settings action does nothing on GNOME/KDE. +- Browser opens another handler after defaults are changed. diff --git a/qa/telephony-deeplink/flows/13-localization-layout.md b/qa/telephony-deeplink/flows/13-localization-layout.md new file mode 100644 index 0000000000..21521b5530 --- /dev/null +++ b/qa/telephony-deeplink/flows/13-localization-layout.md @@ -0,0 +1,50 @@ +--- +id: TEL-QA-013 +title: Localization and layout smoke +platforms: [windows, macos, linux] +priority: medium +qase: + suite: Telephony deeplinks + priority: medium + severity: minor + status: actual + automation: manual + qase_id: null +requires: [telephony_enabled] +test_links: [] +expected_result: Telephony UI is readable without clipping in key locales. +--- + +# Localization And Layout Smoke + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Telephony labels, settings layout, modal copy, and diagnostics + layout across locales and viewport sizes. +- User-visible risk: New strings overflow, become untranslated, or make controls + visually hard to find. +- Hypothesis: Telephony UI remains readable and visually findable in supported + layout and localization conditions. +- Smallest useful proof: Local UI smoke across representative locale/layout + states. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Set app/system locale to English. | | Telephony settings copy is readable. | Launch with English locale or record that locale switching is unavailable. | +| 2 | Check prompt, diagnostics, server picker, shortcut controls. | | No clipping or overlap. | Capture each UI surface. | +| 3 | Repeat in Brazilian Portuguese. | | Copy remains readable. | Launch with pt-BR or record that locale switching is unavailable. | +| 4 | Repeat in German. | | Long labels remain readable. | Launch with de-DE or record that locale switching is unavailable. | +| 5 | Resize window to a narrow supported width. | | Text wraps or truncates cleanly. | Set smaller viewport/window. | + +## Evidence + +- Screenshots for each locale and UI surface. + +## Failure Signals + +- Buttons overlap body text. +- Diagnostics details overflow outside panel. +- Server picker titles are unreadable. diff --git a/qa/telephony-deeplink/flows/14-negative-cases.md b/qa/telephony-deeplink/flows/14-negative-cases.md new file mode 100644 index 0000000000..a679ae1349 --- /dev/null +++ b/qa/telephony-deeplink/flows/14-negative-cases.md @@ -0,0 +1,53 @@ +--- +id: TEL-QA-014 +title: Negative and edge cases +platforms: [windows, macos, linux] +priority: high +qase: + suite: Telephony deeplinks + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [test-links-html] +test_links: ["tel:", "callto:?subject=empty"] +expected_result: Invalid or unsupported inputs fail safely without crashes or wrong calls. +--- + +# Negative And Edge Cases + +## Review Basis + +- Comparison range: `master` to `feat/telephony-deeplink`. +- Changed surface: Link parsing, disabled states, malformed inputs, cancellation, + and unavailable workspace handling. +- User-visible risk: Invalid phone links crash the app, leak stale state, or + route calls despite cancellation or disabled Telephony. +- Hypothesis: Negative and edge inputs fail safely without crashes, unintended + routing, or persistent bad state. +- Smallest useful proof: Local UI repro using malformed links, cancellation, and + disabled-feature scenarios. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Disable Telephony and click a valid phone link. | | No call request is placed. | Trigger `tel:+15551234567`. | +| 2 | In a fresh profile with zero workspaces, use the left vertical server list and click the three-dots/kebab button near the bottom edge below the server buttons, click `Settings`, click the `Voice & Video` tab near the top of Settings, find the `Telephony` section heading, then switch Telephony on. | | Phone link does not crash; no call is placed. | Use a fresh profile with zero workspaces. | +| 3 | Click `tel:` from `test-links.html`. | | No call request is placed. | Trigger empty `tel`. | +| 4 | Click `callto:?subject=empty`. | | No call request is placed. | Trigger query-only `callto`. | +| 5 | With multiple workspaces, open picker and cancel. | | No call request is placed. | Trigger link then dismiss modal. | +| 6 | Trigger two phone links quickly. | | App avoids duplicate/concurrent modal failures. | Fire links in quick succession. | +| 7 | Create a stale remembered workspace by remembering a server, then removing that server from the profile. | | Picker opens instead of silently failing. | Remove remembered server from server list. | + +## Evidence + +- Notes for each edge input and observed result. +- Screenshots for any unexpected modal or error. + +## Failure Signals + +- App crashes. +- Invalid input is sent to dialpad. +- Wrong workspace is used without asking. diff --git a/qa/telephony-deeplink/results/README.md b/qa/telephony-deeplink/results/README.md new file mode 100644 index 0000000000..953262859e --- /dev/null +++ b/qa/telephony-deeplink/results/README.md @@ -0,0 +1,29 @@ +# QA Results + +Store local run notes, screenshots, logs, or diagnostics JSON here while +testing. Do not commit run-specific evidence unless a release owner asks for it. + +Recommended filename format: + +```text +YYYY-MM-DD-platform-flow-id-result.md +``` + +Recommended result note format: + +```text +Flow ID: +Platform: +Build: +Review range: +Coverage: Full requested range | Partial surface review +Result: Pass | Fail | Blocked +Finding status: confirmed | suspected | blocked | none +Evidence: +Notes: +``` + +Use `confirmed` only when the issue or pass condition was reproduced with +evidence. Use `suspected` when the code path is credible but not fully +reproduced. Use `blocked` when platform, permissions, environment, or build +access prevents validation. diff --git a/qa/telephony-deeplink/scripts/README.md b/qa/telephony-deeplink/scripts/README.md new file mode 100644 index 0000000000..824a9a0451 --- /dev/null +++ b/qa/telephony-deeplink/scripts/README.md @@ -0,0 +1,15 @@ +# QA Helper Scripts + +This folder is reserved for small helper scripts used by the telephony QA flows. + +Rules for future scripts: + +- Keep scripts platform-specific when they call OS tools. +- Print a short pass/fail summary and the exact commands run. +- Do not change system defaults unless the flow explicitly says to do so. +- Prefer read-only checks for registry, desktop files, and protocol handlers. + +Shared QA scripts live in `qa/scripts/`: + +- `validate-flows.mjs` checks source Markdown structure. +- `export-qase-csv.mjs` generates the Qase import CSV. diff --git a/qa/telephony-deeplink/test-links.html b/qa/telephony-deeplink/test-links.html new file mode 100644 index 0000000000..36309b0809 --- /dev/null +++ b/qa/telephony-deeplink/test-links.html @@ -0,0 +1,162 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Rocket.Chat Telephony QA Links + + + +
+

Rocket.Chat Telephony QA Links

+

+ Open this file in a browser on the test machine, then click the links + while Rocket.Chat is installed or running from this branch. +

+ +
+ Expected result for valid links: Rocket.Chat handles the protocol and + opens the telephony dialpad flow with the normalized number. +
+ +
+

Valid tel links

+ + + + + + + + + + + + + + + + + + + + + + + + + +
LinkPurposeExpected normalized number
tel:+15551234567Basic international `tel:` link.+15551234567
tel:+55 11 99999-1234Spaces and dash should be accepted.+5511999991234
tel:(555) 123-4567Parentheses and formatting should be stripped.5551234567
+
+ +
+

Valid callto links

+ + + + + + + + + + + + + + + + + + + + + + + + + +
LinkPurposeExpected normalized number
callto:+15551234567Basic `callto:` link.+15551234567
callto://+491234567890Authority form with double slash.+491234567890
callto with queryQuery string should not become part of the number.+442071234567
+
+ +
+

Negative examples

+ + + + + + + + + + + + + + + + + + + + +
LinkPurposeExpected result
tel:Empty target.No dialpad call request should be placed.
callto:?subject=emptyOnly query data.No dialpad call request should be placed.
+
+
+ + diff --git a/skills/desktop-qa-flows/SKILL.md b/skills/desktop-qa-flows/SKILL.md new file mode 100644 index 0000000000..7c6649acb9 --- /dev/null +++ b/skills/desktop-qa-flows/SKILL.md @@ -0,0 +1,73 @@ +--- +name: desktop-qa-flows +description: Create or review Qase-ready QA flows for Rocket.Chat Desktop PRs and branches. +--- + +# Desktop QA Flows + +Use this skill when asked to create, review, or improve QA flows for a +Rocket.Chat Desktop PR, branch, release candidate, or changed feature. + +## Canonical References + +Read these before authoring flows: + +- `AGENTS.md` +- `qa/AGENTS.md` +- `qa/README.md` +- `qa/flow-template.md` + +Those files define the schema and validation rules. This skill defines the +repeatable PR workflow. + +## Workflow + +1. Lock the comparison range: base branch, head branch or commit, and whether + the requested range is fully in scope. +2. Inspect the changed implementation before writing steps: changed files, + commits, tests, React components, Fuselage icons, i18n labels, menu + definitions, modal buttons, platform guards, docs, installers, and helper + pages. +3. Map changed Desktop surfaces to user-visible risk: + - Electron main process, preload, IPC, and deep links. + - Settings UI, menus, modals, server list, i18n, and layout. + - OS protocol handlers, default apps, registry, desktop files, and cold + launch behavior. + - Packaging, installers, release policy, startup, persistence, shortcuts, + workspace routing, and diagnostics. +4. Compare those risks with existing `qa/**/flows/*.md`. +5. Update an existing flow when it already covers the same user-visible + hypothesis. +6. Add a new flow when the changed surface creates a new user-visible risk. +7. Create a new `qa//` pack when the risk does not belong in an + existing pack. +8. Write every branch-derived flow with `## Review Basis`: comparison range, + changed surface, user-visible risk, hypothesis, and smallest useful proof. +9. Keep every step visually findable. Put screen region, relative position, icon + shape, nearby UI, visible labels, and confirmation state directly in the + `Action` cell. +10. Add static helper HTML or read-only scripts when they reduce ambiguity for + clickable links, protocol handlers, OS checks, or repeated evidence capture. + +## Coverage Rules + +- Do not claim full QA unless the full requested comparison range was checked. +- Mark unchanged or already-covered surfaces explicitly in the summary. +- Classify result findings as `confirmed`, `suspected`, or `blocked`. +- If runtime validation is not practical, use the smallest useful proof: an + existing test, targeted test, local UI repro, OS-level repro, or code-path + proof. +- Keep Qase source IDs in the repo and leave generated Qase IDs empty until a + case already exists in Qase. + +## Validation + +After changing QA packs, run: + +```sh +node qa/scripts/validate-flows.mjs qa/ +node qa/scripts/export-qase-csv.mjs qa/ +git diff --check +``` + +Report any unvalidated pack or partial surface review clearly. diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index 1fe354ec3b..3e6e5f65f8 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -2,6 +2,7 @@ import type { Certificate } from 'electron'; import type { Download } from '../downloads/common'; import type { Server } from '../servers/common'; +import type { TelephonyGlobalShortcutConfig } from '../telephony/actions'; import type { WindowState } from '../ui/common'; type PersistableValues_0_0_0 = { @@ -106,9 +107,15 @@ type PersistableValues_4_13_0 = PersistableValues_4_11_0 & { isDebugLoggingEnabled: boolean; }; +type PersistableValues_4_14_0 = PersistableValues_4_13_0 & { + isTelephonyEnabled: boolean; + telephonyPreferredServer: string | null; + telephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig; +}; + export type PersistableValues = Pick< - PersistableValues_4_13_0, - keyof PersistableValues_4_13_0 + PersistableValues_4_14_0, + keyof PersistableValues_4_14_0 >; export const migrations = { @@ -201,4 +208,16 @@ export const migrations = { ...before, isDebugLoggingEnabled: false, }), + '>=4.14.0': (before: PersistableValues_4_13_0): PersistableValues_4_14_0 => ({ + ...before, + isTelephonyEnabled: + (before as Partial).isTelephonyEnabled ?? false, + telephonyPreferredServer: + (before as Partial).telephonyPreferredServer ?? + null, + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + }), }; diff --git a/src/app/__tests__/PersistableValues.spec.ts b/src/app/__tests__/PersistableValues.spec.ts new file mode 100644 index 0000000000..5988634746 --- /dev/null +++ b/src/app/__tests__/PersistableValues.spec.ts @@ -0,0 +1,18 @@ +import { migrations } from '../PersistableValues'; + +describe('PersistableValues migrations', () => { + it('adds telephony shortcut config without losing a persisted telephony server', () => { + const before = { + telephonyPreferredServer: 'https://chat.example.com', + } as unknown as Parameters<(typeof migrations)['>=4.14.0']>[0]; + + expect(migrations['>=4.14.0'](before)).toEqual({ + isTelephonyEnabled: false, + telephonyPreferredServer: 'https://chat.example.com', + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + }); + }); +}); diff --git a/src/app/main/app.main.spec.ts b/src/app/main/app.main.spec.ts index 8fffea9a6f..1fb625c221 100644 --- a/src/app/main/app.main.spec.ts +++ b/src/app/main/app.main.spec.ts @@ -470,6 +470,26 @@ describe('performElectronStartup - Platform Detection', () => { }); }); + describe('Telephony scheme gating', () => { + it('registers rocketchat at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith('rocketchat'); + }); + + it('does NOT register tel at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalledWith('tel'); + }); + + it('does NOT register callto at startup', () => { + performElectronStartup(); + + expect(app.setAsDefaultProtocolClient).not.toHaveBeenCalledWith('callto'); + }); + }); + describe('Integration', () => { it('should work correctly with PipeWire feature enabled', () => { process.env.XDG_SESSION_TYPE = 'x11'; diff --git a/src/app/main/app.ts b/src/app/main/app.ts index c1101196dd..a5c2eaeab6 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -40,9 +40,15 @@ export const packageJsonInformation = { export const electronBuilderJsonInformation = { appId: electronBuilderJson.appId, - protocol: electronBuilderJson.protocols.schemes[0], + protocol: (electronBuilderJson.protocols as Array<{ schemes: string[] }>)[0] + .schemes[0], + protocols: ( + electronBuilderJson.protocols as Array<{ schemes: string[] }> + ).flatMap((p) => p.schemes), }; +export const TELEPHONY_SCHEMES = ['tel', 'callto'] as const; + let isScreenCaptureFallbackForced = false; export const getPlatformName = (): string => { @@ -83,7 +89,12 @@ export const relaunchApp = (...args: string[]): void => { }; export const performElectronStartup = (): void => { - app.setAsDefaultProtocolClient(electronBuilderJsonInformation.protocol); + for (const scheme of electronBuilderJsonInformation.protocols) { + if ((TELEPHONY_SCHEMES as readonly string[]).includes(scheme)) { + continue; + } + app.setAsDefaultProtocolClient(scheme); + } app.setAppUserModelId(electronBuilderJsonInformation.appId); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); diff --git a/src/app/selectors.ts b/src/app/selectors.ts index cc10bfd3a3..6b73be7d6c 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -83,4 +83,10 @@ export const selectPersistableValues = createStructuredSelector({ }: RootState) => isDetailedEventsLoggingEnabled, isDebugLoggingEnabled: ({ isDebugLoggingEnabled }: RootState) => isDebugLoggingEnabled, + telephonyPreferredServer: ({ telephonyPreferredServer }: RootState) => + telephonyPreferredServer, + telephonyGlobalShortcutConfig: ({ + telephonyGlobalShortcutConfig, + }: RootState) => telephonyGlobalShortcutConfig, + isTelephonyEnabled: ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled, }); diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts new file mode 100644 index 0000000000..0cbbc4afc8 --- /dev/null +++ b/src/deepLinks/main.spec.ts @@ -0,0 +1,794 @@ +import { app } from 'electron'; + +import { ServerUrlResolutionStatus } from '../servers/common'; +import { resolveServerUrl } from '../servers/main'; +import { select, dispatch, listen } from '../store'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../telephony/actions'; +import { telephonyPreferredServer } from '../telephony/reducers'; +import { + TELEPHONY_SERVER_SELECT_OPEN, + TELEPHONY_SERVER_SELECT_CLOSE, +} from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { getWebContentsByServerUrl } from '../ui/main/serverView'; +import { + parseTelephonyLink, + getDeepLinkArgs, + performTelephonyCall, + setupDeepLinks, + processDeepLinksInArgs, +} from './main'; +import type { TelephonyLink } from './main'; + +jest.mock('electron', () => ({ + app: { + addListener: jest.fn(), + isPackaged: false, + getPath: jest.fn(), + getName: jest.fn(() => 'Rocket.Chat'), + }, +})); +jest.mock('../store'); +jest.mock('../ui/main/serverView'); +jest.mock('../servers/main'); +jest.mock('../ui/main/dialogs'); +jest.mock('../ui/main/rootWindow'); +jest.mock('../app/main/app', () => ({ + electronBuilderJsonInformation: { protocol: 'rocketchat' }, + packageJsonInformation: { goUrlShortener: 'go.rocket.chat' }, +})); + +const selectMock = select as jest.MockedFunction; +const dispatchMock = dispatch as jest.MockedFunction; +const listenMock = listen as jest.MockedFunction; +const getWebContentsByServerUrlMock = + getWebContentsByServerUrl as jest.MockedFunction< + typeof getWebContentsByServerUrl + >; +const resolveServerUrlMock = resolveServerUrl as jest.MockedFunction< + typeof resolveServerUrl +>; +const getRootWindowMock = getRootWindow as jest.MockedFunction< + typeof getRootWindow +>; +const appMock = app as jest.Mocked; + +describe('deepLinks/main.ts', () => { + const mockRootWindow = {} as any; + + beforeEach(() => { + jest.clearAllMocks(); + getRootWindowMock.mockResolvedValue(mockRootWindow); + }); + + const simulateModalResponse = ( + payload: { serverUrl: string; rememberChoice: boolean } | null + ) => { + listenMock.mockImplementation((_type: any, callback: any) => { + setTimeout( + () => callback({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload }), + 0 + ); + return jest.fn(); + }); + }; + + describe('getDeepLinkArgs', () => { + it('keeps only supported deep link arguments', () => { + expect( + getDeepLinkArgs([ + 'electron', + '.', + '--force-renderer-accessibility', + 'tel:+491234567890', + '--source-app-id', + 'callto:+15551234567', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + 'https://go.rocket.chat/invite?host=https://chat.example.com', + 'https://example.com/not-a-deep-link', + ]) + ).toEqual([ + 'tel:+491234567890', + 'callto:+15551234567', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + 'https://go.rocket.chat/invite?host=https://chat.example.com', + ]); + }); + }); + + describe('parseTelephonyLink', () => { + it('should parse valid tel: with international format', () => { + const result = parseTelephonyLink('tel:+491234567890'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }); + }); + + it('should parse valid callto: protocol', () => { + const result = parseTelephonyLink('callto:+1-800-555-0199'); + expect(result).toEqual({ + phoneNumber: '+18005550199', + rawUri: 'callto:+1-800-555-0199', + }); + }); + + it('should strip spaces, dashes, parens, and dots', () => { + const result = parseTelephonyLink('tel:(049) 123-456.78'); + expect(result).toEqual({ + phoneNumber: '04912345678', + rawUri: 'tel:(049) 123-456.78', + }); + }); + + it('should return null when empty number after stripping', () => { + const result = parseTelephonyLink('tel:---'); + expect(result).toBeNull(); + }); + + it('should return null for CLI flags starting with --', () => { + const result = parseTelephonyLink('--tel:+49123'); + expect(result).toBeNull(); + }); + + it('should return null for non-telephony protocols', () => { + const result = parseTelephonyLink('rocketchat://auth?host=x'); + expect(result).toBeNull(); + }); + + it('should return null for invalid URL', () => { + const result = parseTelephonyLink('not a url at all'); + expect(result).toBeNull(); + }); + + it('should return null for tel: with no number', () => { + const result = parseTelephonyLink('tel:'); + expect(result).toBeNull(); + }); + + it('should preserve + prefix', () => { + const result = parseTelephonyLink('tel:+44207123456'); + expect(result).toEqual({ + phoneNumber: '+44207123456', + rawUri: 'tel:+44207123456', + }); + }); + + it('should handle callto:// with double-slash (authority) format', () => { + const result = parseTelephonyLink('callto://+491234567890'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890', + }); + }); + + it('should ignore query strings in callto:// authority format', () => { + const result = parseTelephonyLink('callto://+491234567890?source=crm'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890?source=crm', + }); + }); + + it('should ignore fragments in callto:// authority format', () => { + const result = parseTelephonyLink('callto://+491234567890#details'); + expect(result).toEqual({ + phoneNumber: '+491234567890', + rawUri: 'callto://+491234567890#details', + }); + }); + + it('should preserve callto: with extension syntax', () => { + const result = parseTelephonyLink('callto:+1234;ext=5678'); + expect(result).toEqual({ + phoneNumber: '+1234;ext=5678', + rawUri: 'callto:+1234;ext=5678', + }); + }); + }); + + describe('performTelephonyCall', () => { + const mockWebContents = { + send: jest.fn(), + }; + + const mockLink: TelephonyLink = { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }; + + beforeEach(() => { + // Mock getWebContentsByServerUrl to return immediately (no polling needed) + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('should no-op when there are 0 servers', async () => { + selectMock.mockReturnValue([]); + + await performTelephonyCall(mockLink); + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it('should auto-select when there is 1 server', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await performTelephonyCall(mockLink); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + expect(listenMock).not.toHaveBeenCalled(); + }); + + it('should open dialpad path with empty input when requested', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await performTelephonyCall({ phoneNumber: '', rawUri: '' }); + + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '', + rawUri: '', + } + ); + }); + + it('should show dialog when there are 2+ servers and no preference', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: false, + }); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: '+491234567890', rawUri: 'tel:+491234567890' }, + }); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server1.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + + it('should skip dialog when preferred server exists in server list', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce('https://server2.com'); + + await performTelephonyCall(mockLink); + + expect(listenMock).not.toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server2.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + + it('should show dialog when preferred server is stale (not in server list)', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce('https://stale-server.com'); + + simulateModalResponse({ + serverUrl: 'https://server2.com', + rememberChoice: false, + }); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ type: TELEPHONY_SERVER_SELECT_OPEN }) + ); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://server2.com' + ); + }); + + it('should dispatch TELEPHONY_PREFERRED_SERVER_SET when Remember is checked', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: true, + }); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://server1.com', + }); + }); + + it('should not dispatch when Remember is unchecked', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + simulateModalResponse({ + serverUrl: 'https://server2.com', + rememberChoice: false, + }); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ type: TELEPHONY_PREFERRED_SERVER_SET }) + ); + }); + + it('should dispatch open action even when server titles are missing', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com' }, + { url: 'https://server2.com' }, + ]) + .mockReturnValueOnce(null); + + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: false, + }); + + await performTelephonyCall(mockLink); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: '+491234567890', rawUri: 'tel:+491234567890' }, + }); + }); + + it('should poll for webContents when not immediately available', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + let callCount = 0; + getWebContentsByServerUrlMock.mockImplementation(() => { + callCount++; + if (callCount < 3) { + return null as any; + } + return mockWebContents as any; + }); + + jest.useFakeTimers(); + const promise = performTelephonyCall(mockLink); + await jest.advanceTimersByTimeAsync(250); + await promise; + jest.useRealTimers(); + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledTimes(3); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + mockLink + ); + }); + + it('should not proceed when modal is cancelled (null response)', async () => { + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + simulateModalResponse(null); + + await performTelephonyCall(mockLink); + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.send).not.toHaveBeenCalled(); + }); + + it('should reject concurrent calls while modal is open', async () => { + selectMock.mockReturnValue([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]); + + // First call: modal stays open (listen never fires) + listenMock.mockImplementation(() => { + // Never call the callback — modal stays open + return jest.fn(); + }); + + selectMock + .mockReturnValueOnce([ + { url: 'https://server1.com', title: 'Server 1' }, + { url: 'https://server2.com', title: 'Server 2' }, + ]) + .mockReturnValueOnce(null); + + const firstCall = performTelephonyCall(mockLink); + + // Yield so first call reaches the listen/promise + await new Promise((r) => { + setTimeout(r, 0); + }); + + // Second call should be rejected + const secondLink: TelephonyLink = { + phoneNumber: '+1999', + rawUri: 'tel:+1999', + }; + await performTelephonyCall(secondLink); + + // Only one OPEN dispatch (from first call) + expect(dispatchMock).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ type: TELEPHONY_SERVER_SELECT_OPEN }) + ); + + // Clean up: force-close the modal so firstCall resolves + const listenCallback = listenMock.mock.calls[0][1]; + listenCallback({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: null, + }); + await firstCall; + }); + + it('should not send when webContents times out', async () => { + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + // webContents never becomes available + getWebContentsByServerUrlMock.mockReturnValue(null as any); + + jest.useFakeTimers(); + const promise = performTelephonyCall(mockLink); + // Advance past the 10s webContents timeout + await jest.advanceTimersByTimeAsync(11_000); + await promise; + jest.useRealTimers(); + + expect(mockWebContents.send).not.toHaveBeenCalled(); + }); + }); + + describe('processDeepLink telephony routing', () => { + const mockBrowserWindow = { + isVisible: jest.fn(() => true), + focus: jest.fn(), + showInactive: jest.fn(), + }; + + const mockWebContents = { + send: jest.fn(), + loadURL: jest.fn(), + }; + + beforeEach(() => { + getRootWindowMock.mockResolvedValue(mockBrowserWindow as any); + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('should route tel: URL to telephony path', async () => { + setupDeepLinks(); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + // Telephony path: getWebContentsByServerUrl called for the server + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + // Normal deep link path NOT taken + expect(resolveServerUrlMock).not.toHaveBeenCalled(); + }); + + it('queues macOS open-url events until startup processing is ready', async () => { + setupDeepLinks(); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const listenerCalls = appMock.addListener.mock.calls as Array< + [string, (...args: any[]) => Promise | void] + >; + const openUrlHandler = listenerCalls.find( + ([eventName]) => eventName === 'open-url' + )?.[1]; + const event = { preventDefault: jest.fn() }; + + if (!openUrlHandler) { + throw new Error('open-url listener was not registered'); + } + + openUrlHandler(event, 'tel:+491234567890'); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + + await processDeepLinksInArgs(); + + expect(mockBrowserWindow.focus).toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('processes second-instance argv immediately', async () => { + setupDeepLinks(); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const listenerCalls = appMock.addListener.mock.calls as Array< + [string, (...args: any[]) => Promise | void] + >; + const secondInstanceHandler = listenerCalls.find( + ([eventName]) => eventName === 'second-instance' + )?.[1]; + const event = { preventDefault: jest.fn() }; + + if (!secondInstanceHandler) { + throw new Error('second-instance listener was not registered'); + } + + await secondInstanceHandler(event, [ + 'electron', + '.', + 'tel:+491234567890', + ]); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockBrowserWindow.focus).toHaveBeenCalled(); + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('should route rocketchat:// URL to normal deep link path, not telephony', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + const savedArgv = process.argv; + process.argv = [ + 'electron', + '.', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + ]; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + // Normal deep link path taken + expect(resolveServerUrlMock).toHaveBeenCalled(); + // Telephony modal NOT opened (telephony branch skipped) + expect(listenMock).not.toHaveBeenCalled(); + }); + }); + + describe('isTelephonyEnabled gate for tel: deep links', () => { + const mockBrowserWindow = { + isVisible: jest.fn(() => true), + focus: jest.fn(), + showInactive: jest.fn(), + }; + + const mockWebContents = { + send: jest.fn(), + loadURL: jest.fn(), + }; + + beforeEach(() => { + getRootWindowMock.mockResolvedValue(mockBrowserWindow as any); + getWebContentsByServerUrlMock.mockReturnValue(mockWebContents as any); + }); + + it('does NOT open dialpad for tel: link when isTelephonyEnabled=false', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.send).not.toHaveBeenCalled(); + expect(resolveServerUrlMock).not.toHaveBeenCalled(); + }); + + it('does NOT open dialpad for callto: link when isTelephonyEnabled=false', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'callto:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.send).not.toHaveBeenCalled(); + }); + + it('opens dialpad for tel: link when isTelephonyEnabled=true', async () => { + setupDeepLinks(); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: true, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = ['electron', '.', 'tel:+491234567890']; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(mockWebContents.send).toHaveBeenCalledWith( + 'telephony/call-requested', + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('does not gate non-telephony deep links on isTelephonyEnabled', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + + selectMock.mockImplementation((selector: any) => + selector({ + isTelephonyEnabled: false, + servers: [{ url: 'https://chat.example.com', title: 'Chat' }], + }) + ); + + const savedArgv = process.argv; + process.argv = [ + 'electron', + '.', + 'rocketchat://auth?host=https://chat.example.com&token=abc&userId=123', + ]; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + + expect(resolveServerUrlMock).toHaveBeenCalled(); + }); + }); +}); + +describe('telephonyPreferredServer reducer', () => { + it('should return initial state as null', () => { + expect( + telephonyPreferredServer(undefined, { type: 'UNKNOWN_ACTION' } as any) + ).toBe(null); + }); + + it('should set preferred server URL', () => { + expect( + telephonyPreferredServer(null, { + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://chat.example.com', + }) + ).toBe('https://chat.example.com'); + }); + + it('should clear preferred server when payload is null', () => { + expect( + telephonyPreferredServer('https://chat.example.com', { + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: null, + }) + ).toBe(null); + }); +}); diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index d8e298bc35..e082f52201 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -8,6 +8,8 @@ import { import { ServerUrlResolutionStatus } from '../servers/common'; import { resolveServerUrl } from '../servers/main'; import { select, dispatch } from '../store'; +import { openTelephonyDialpad } from '../telephony/dialpad'; +import { parseTelephonyLink } from '../telephony/links'; import { askForServerAddition, warnAboutInvalidServerUrl, @@ -16,6 +18,13 @@ import { getRootWindow } from '../ui/main/rootWindow'; import { getWebContentsByServerUrl } from '../ui/main/serverView'; import { DEEP_LINKS_SERVER_FOCUSED, DEEP_LINKS_SERVER_ADDED } from './actions'; +export type { TelephonyLink } from '../telephony/common'; +export { openTelephonyDialpad as performTelephonyCall } from '../telephony/dialpad'; +export { parseTelephonyLink } from '../telephony/links'; + +const pendingOpenUrls: string[] = []; +let isOpenUrlProcessingReady = false; + const isDefinedProtocol = (parsedUrl: URL): boolean => parsedUrl.protocol === `${electronBuilderJsonInformation.protocol}:`; @@ -54,8 +63,22 @@ const parseDeepLink = ( return null; }; +export const getDeepLinkArgs = (argv: string[]): string[] => + argv + .slice(app.isPackaged ? 1 : 2) + .filter((arg) => parseTelephonyLink(arg) || parseDeepLink(arg)); + export let processDeepLinksInArgs = async (): Promise => undefined; +const focusRootWindow = async (): Promise => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); +}; + type AuthenticationParams = { host: string; token: string; @@ -108,8 +131,19 @@ const performOnServer = async ( await action(serverUrl); }; -const getWebContents = (serverUrl: string): Promise => - new Promise((resolve) => { +function getWebContents(serverUrl: string): Promise; +function getWebContents( + serverUrl: string, + timeoutMs: number +): Promise; +function getWebContents( + serverUrl: string, + timeoutMs?: number +): Promise { + return new Promise((resolve) => { + const deadline = + timeoutMs !== undefined ? Date.now() + timeoutMs : undefined; + const poll = (): void => { const webContents = getWebContentsByServerUrl(serverUrl); if (webContents) { @@ -117,11 +151,17 @@ const getWebContents = (serverUrl: string): Promise => return; } + if (deadline !== undefined && Date.now() >= deadline) { + resolve(null); + return; + } + setTimeout(poll, 100); }; poll(); }); +} const performAuthentication = async ({ host, @@ -180,6 +220,18 @@ const performConference = async ({ host, path }: InviteParams): Promise => }); const processDeepLink = async (deepLink: string): Promise => { + const telephonyLink = parseTelephonyLink(deepLink); + if (telephonyLink) { + const isTelephonyEnabled = select( + ({ isTelephonyEnabled }) => isTelephonyEnabled + ); + if (!isTelephonyEnabled) { + return; + } + await openTelephonyDialpad(telephonyLink); + return; + } + const parsedDeepLink = parseDeepLink(deepLink); if (!parsedDeepLink) { @@ -231,19 +283,32 @@ const processDeepLink = async (deepLink: string): Promise => { }; export const setupDeepLinks = (): void => { + pendingOpenUrls.length = 0; + isOpenUrlProcessingReady = false; + app.addListener('open-url', async (event, url): Promise => { event.preventDefault(); - const browserWindow = await getRootWindow(); - - if (!browserWindow.isVisible()) { - browserWindow.showInactive(); + if (!isOpenUrlProcessingReady) { + pendingOpenUrls.push(url); + return; } - browserWindow.focus(); + await focusRootWindow(); await processDeepLink(url); }); + const processQueuedOpenUrls = async (): Promise => { + const urls = pendingOpenUrls.splice(0); + + for (const url of urls) { + // eslint-disable-next-line no-await-in-loop + await focusRootWindow(); + // eslint-disable-next-line no-await-in-loop + await processDeepLink(url); + } + }; + app.addListener('second-instance', async (event, argv): Promise => { event.preventDefault(); @@ -254,7 +319,7 @@ export const setupDeepLinks = (): void => { } if (browserWindow) browserWindow.focus(); - const args = argv.slice(app.isPackaged ? 1 : 2); + const args = getDeepLinkArgs(argv); for (const arg of args) { // eslint-disable-next-line no-await-in-loop @@ -263,7 +328,11 @@ export const setupDeepLinks = (): void => { }); processDeepLinksInArgs = async (): Promise => { - const args = process.argv.slice(app.isPackaged ? 1 : 2); + isOpenUrlProcessingReady = true; + + await processQueuedOpenUrls(); + + const args = getDeepLinkArgs(process.argv); for (const arg of args) { // eslint-disable-next-line no-await-in-loop diff --git a/src/i18n/ar.i18n.json b/src/i18n/ar.i18n.json index a603446f03..08fe599119 100644 --- a/src/i18n/ar.i18n.json +++ b/src/i18n/ar.i18n.json @@ -18,9 +18,31 @@ "auto": "اتباع النظام", "light": "فاتح", "dark": "داكن" + }, + "telephonyServer": { + "title": "خادم الاتصالات الهاتفية", + "description": "اختر مساحة العمل التي تفتح عند استخدام اختصار الاتصالات الهاتفية أو رابط tel: أو callto:.", + "auto": "تلقائي (السؤال في كل مرة)" + }, + "telephonyShortcut": { + "title": "اختصار الاتصالات الهاتفية العام", + "description": "استخدم هذا الاختصار من أي مكان لإحضار Rocket.Chat إلى المقدمة وفتح لوحة الاتصال الهاتفية. إذا كانت الحافظة تحتوي على نص يبدو كرقم هاتف، فسيقوم Rocket.Chat بتعبئته مسبقًا.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "اضغط على المفاتيح...", + "save": "حفظ", + "clear": "مسح", + "registered": "تم تسجيل الاختصار", + "reservedAccelerator": "{{accelerator}} محجوز بواسطة Rocket.Chat أو نظام التشغيل لديك." } } }, + "dialog": { + "telephonySelectServer": { + "title": "اختيار الخادم", + "message": "أي خادم يجب أن يتعامل مع هذه المكالمة؟", + "rememberChoice": "تذكر هذا الاختيار" + } + }, "serverInfo": { "title": "معلومات الخادم", "urlLabel": "الرابط:", diff --git a/src/i18n/de-DE.i18n.json b/src/i18n/de-DE.i18n.json index 8a49b7a3c1..7e2f0b11b5 100644 --- a/src/i18n/de-DE.i18n.json +++ b/src/i18n/de-DE.i18n.json @@ -48,6 +48,15 @@ "yes": "Ja", "no": "Nein" }, + "clearCache": { + "announcement": "Neu laden erzwingen", + "title": "Anmeldesitzung behalten?", + "message": "Wenn Sie Ihre Anmeldesitzung löschen, werden Sie abgemeldet und müssen Ihre Anmeldedaten erneut eingeben.", + "keepLoginData": "Anmeldesitzung behalten", + "deleteLoginData": "Anmeldesitzung löschen", + "clearingWait": "Bitte warten", + "cancel": "Abbrechen" + }, "downloadRemoval": { "title": "Bist du dir sicher?", "message": "Diesen Download entfernen?", @@ -56,6 +65,13 @@ }, "resetAppData": { "title": "Bist du dir sicher?", + "message": "Dadurch werden Sie von allen Teams abgemeldet und die App wird auf ihre ursprünglichen Einstellungen zurückgesetzt. Dies kann nicht rückgängig gemacht werden.", + "yes": "Ja", + "cancel": "Abbrechen" + }, + "clearPermittedScreenCaptureServers": { + "title": "Erlaubte Bildschirmaufnahmeserver löschen", + "message": "Dadurch werden alle Berechtigungen für Bildschirmaufnahmeserver gelöscht, sodass sie erneut um Erlaubnis fragen müssen. Dies kann nicht rückgängig gemacht werden.", "yes": "Ja", "cancel": "Abbrechen" }, @@ -136,6 +152,30 @@ "answerCall": "Anrufe entgegennehmen", "recordMessage": "Nachrichten aufnehmen" } + }, + "outlookCalendar": { + "title": "Outlook-Kalender", + "encryptionUnavailableTitle": "Verschlüsselung nicht verfügbar", + "encryptionUnavailable": "Ihr Betriebssystem unterstützt keine Verschlüsselung.\nIhre Anmeldedaten werden im Klartext gespeichert.", + "field_required": "Dieses Feld ist erforderlich", + "remember_credentials": "Meine Anmeldedaten merken", + "cancel": " Abbrechen", + "submit": "Anmelden" + }, + "supportedVersion": { + "title": "Workspace-Version wird nicht unterstützt" + }, + "telephonySelectServer": { + "title": "Workspace für diesen Anruf auswählen", + "message": "Welcher Workspace soll diesen Anruf übernehmen?", + "rememberChoice": "Für zukünftige Anrufe merken" + }, + "clearLogs": { + "title": "Logs löschen", + "message": "Möchten Sie die Logdatei wirklich löschen?", + "detail": "Diese Aktion kann nicht rückgängig gemacht werden. Alle aktuellen Logeinträge werden dauerhaft gelöscht.", + "yes": "Löschen", + "cancel": "Abbrechen" } }, "documentViewer": { @@ -201,6 +241,13 @@ "title": "Einstellungen", "general": "Allgemein", "certificates": "Zertifikate", + "developer": "Entwickler", + "voiceVideo": "Sprache & Video", + "sections": { + "logging": "Logging", + "telephony": "Telefonie", + "videoCalls": "Videoanrufe" + }, "options": { "report": { "title": "Fehler an Entwickler melden", @@ -253,10 +300,34 @@ "loading": "Browser werden geladen...", "current": "Aktuell verwendet:" }, + "telephony": { + "title": "Telefonie", + "description": "Rocket.Chat kann Tastenkombinationen für Telefonanrufe sowie tel:- oder callto:-Links verarbeiten. Deaktivieren Sie diese Option, um die Tastenkombination zu deaktivieren, Klicks auf Telefonlinks zu ignorieren und die folgenden Einstellungen auszublenden." + }, + "telephonyServer": { + "title": "Telefonie-Server", + "description": "Wählen Sie aus, welcher Workspace geöffnet wird, wenn Sie die Telefonie-Tastenkombination oder einen tel:- bzw. callto:-Link verwenden.", + "auto": "Jedes Mal fragen" + }, + "telephonyShortcut": { + "title": "Globale Telefonie-Tastenkombination", + "description": "Verwenden Sie diese Tastenkombination von überall aus, um Rocket.Chat in den Vordergrund zu bringen und das Telefonie-Wählfeld zu öffnen. Wenn Ihre Zwischenablage Text enthält, der wie eine Telefonnummer aussieht, füllt Rocket.Chat ihn vorab aus.", + "placeholder": "Nicht festgelegt - zum Aufzeichnen auf Speichern klicken", + "capturePlaceholder": "Tasten drücken...", + "save": "Speichern", + "clear": "Löschen", + "registered": "Tastenkombination registriert", + "reservedByApp": "{{accelerator}} wird bereits von Rocket.Chat verwendet. Wählen Sie eine andere Kombination.", + "reservedByOS": "{{accelerator}} ist von Ihrem Betriebssystem reserviert. Wählen Sie eine andere Kombination." + }, "clearPermittedScreenCaptureServers": { "title": "Erlaubte Bildschirmaufnahmeserver löschen", "description": "Löschen Sie Server, die Bildschirme von dieser App erfassen dürfen" }, + "allowScreenCaptureOnVideoCalls": { + "title": "Bildschirmaufnahme bei Videoanrufen erlauben", + "description": "Bildschirmaufnahme bei Videoanrufen erlauben. Bei jedem Videoanruf wird um Erlaubnis gefragt." + }, "ntlmCredentials": { "title": "NTLM-Anmeldeinformationen", "description": "Erlauben Sie die Verwendung von NTLM-Anmeldeinformationen bei der Verbindung mit einem Server.", @@ -276,6 +347,22 @@ "auto": "System folgen", "light": "Hell", "dark": "Dunkel" + }, + "outlookCalendarSyncInterval": { + "title": "Synchronisierungsintervall für den Outlook-Kalender", + "description": "Wie oft Outlook-Kalenderereignisse synchronisiert werden, in Minuten (1-60)." + }, + "verboseOutlookLogging": { + "title": "Ausführliches Logging für den Outlook-Kalender", + "description": "Aktiviert detaillierte Exchange-/NTLM-Debug-Logs zur Fehlerbehebung bei Problemen mit der Outlook-Kalenderintegration." + }, + "detailedEventsLogging": { + "title": "Detailliertes Ereignis-Logging", + "description": "Protokolliert die vollständigen Ereignisdaten, die während der Kalendersynchronisierung zwischen Outlook und Rocket.Chat ausgetauscht werden. Nützlich zur Diagnose von Synchronisierungsproblemen." + }, + "debugLogging": { + "title": "Ausführliches Logging", + "description": "Schreibt die gesamte Konsolenausgabe in die Logdatei. Wenn deaktiviert, werden nur Fehler und wichtige Meldungen gespeichert, damit die Logs kleiner und fokussiert bleiben." } } }, @@ -310,6 +397,7 @@ "forward": "&Nach vorne", "helpMenu": "&Hilfe", "hide": "Ausblenden {{- appName}}", + "hideOthers": "Andere ausblenden", "learnMore": "Mehr erfahren", "minimize": "Minimieren", "openDevTools": "&DevTools öffnen", @@ -318,6 +406,7 @@ "quit": "{{- appName}} &beenden", "redo": "Wieder&holen", "reload": "&Neu laden", + "reloadClearingCache": "Neu laden erzwingen", "reportIssue": "Problem melden", "resetAppData": "Appdaten löschen", "resetZoom": "Originalgröße", @@ -329,6 +418,11 @@ "showServerList": "Server liste", "showTrayIcon": "Symbole in menüleiste", "toggleDevTools": "&DevTools ein-/ausblenden", + "openConfigFolder": "&Konfigurationsordner öffnen", + "openLogViewer": "&Loganzeige öffnen", + "videoCallDevTools": "Videoanruf-&DevTools öffnen", + "videoCallTools": "Videoanruf-Tools", + "videoCallDevToolsAutoOpen": "DevTools automatisch öffnen", "undo": "Wider&rufen", "unhide": "Zeige alles", "viewMenu": "Darstellung", @@ -356,6 +450,11 @@ "reload": "Videoanruf neu laden" } }, + "unsupportedServer": { + "title": "{{instanceDomain}} verwendet eine nicht unterstützte Version von Rocket.Chat", + "announcement": "Ein Administrator muss den Workspace auf eine unterstützte Version aktualisieren, damit der Zugriff über mobile und Desktop-Apps wieder aktiviert wird.", + "moreInformation": "Mehr erfahren" + }, "selfxss": { "title": "Halt!", "description": "Dies ist eine Browserfunktion, die für Entwickler gedacht ist. Wenn Ihnen jemand gesagt hat, dass Sie hier etwas kopieren und einfügen sollen, um eine Rocket.Chat-Funktion zu aktivieren oder um das Konto einer anderen Person zu \"hacken\", ist dies ein Betrug und verschafft dieser Person Zugriff auf Ihr Rocket.Chat-Konto.", @@ -365,12 +464,24 @@ "addNewServer": "Neuen Server hinzufügen", "downloads": "Downloads", "settings": "Einstellungen", + "menuTitle": "App anpassen und steuern", "item": { "reload": "Server neu laden", "remove": "Server entfernen", "openDevTools": "DevTools öffnen", "clearCache": "Cache leeren", - "clearStorageData": "Speicherdaten löschen" + "clearStorageData": "Speicherdaten löschen", + "copyCurrentUrl": "Aktuelle URL kopieren", + "reloadClearingCache": "Neu laden erzwingen", + "serverInfo": "Serverinformationen", + "supportedVersionsInfo": "Informationen zu unterstützten Versionen" + }, + "tooltips": { + "unreadMessage": "{{- count}} ungelesene Nachricht", + "unreadMessages": "{{- count}} ungelesene Nachrichten", + "userNotLoggedIn": "Nicht angemeldet", + "addWorkspace": "Workspace hinzufügen ({{shortcut}}+N)", + "settingsMenu": "App anpassen und steuern" } }, "touchBar": { @@ -405,6 +516,7 @@ "permissionDenied": "Bildschirmaufnahme-Berechtigung verweigert", "permissionRequired": "Die Bildschirmaufnahme-Berechtigung ist erforderlich, um Ihren Bildschirm zu teilen.", "permissionInstructions": "Bitte aktivieren Sie diese in den Systemeinstellungen und versuchen Sie es erneut.", + "openSystemPreferences": "Systemeinstellungen öffnen", "title": "Bildschirm teilen", "entireScreen": "Ihr gesamter Bildschirm", "applicationWindow": "Anwendungsfenster", @@ -413,6 +525,134 @@ "cancel": "Abbrechen", "share": "Teilen" }, + "logging": { + "context": { + "processTypes": { + "main": "Hauptprozess", + "renderer": "Renderer-Prozess", + "preload": "Preload-Prozess", + "rendererRoot": "Hauptfenster", + "webview": "Server-Webview", + "videoCall": "Videoanruf-Fenster" + }, + "components": { + "auth": "Authentifizierung", + "connection": "Verbindung", + "notification": "Benachrichtigung", + "outlook": "Outlook-Kalender", + "videoCall": "Videoanruf", + "download": "Download", + "spellCheck": "Rechtschreibprüfung", + "general": "Allgemein" + }, + "serverInfo": { + "anonymous": "Anonymer Server", + "local": "Lokaler Prozess", + "unknown": "Unbekannter Server" + } + }, + "status": { + "moduleNotAvailable": "Speichermodul für Serverkontext-Zuordnung nicht verfügbar", + "importFailed": "Import des Speichers fehlgeschlagen" + }, + "errors": { + "configurationFailed": "electron-log konnte nicht konfiguriert werden", + "storeUnavailable": "Speichermodul für Serverkontext-Zuordnung nicht verfügbar", + "webContentsLoggingFailed": "Logging für webContents konnte nicht eingerichtet werden", + "rendererLogFailed": "Logging aus dem Renderer fehlgeschlagen", + "consoleOverrideFailed": "Konsolenmethoden konnten nicht überschrieben werden" + }, + "messages": { + "serverContext": "Server {{- serverNumber}}", + "logContext": "Logkontext: {{- context}}" + } + }, + "logViewer": { + "title": "Loganzeige", + "aria": { + "logIcon": "Symbol der Loganzeige", + "entriesCount": "{{count}} Logeinträge angezeigt" + }, + "fileInfo": { + "custom": "Benutzerdefiniert", + "entries": "{{count}} Einträge", + "entriesOfTotal": "{{count}} von {{total}} Einträgen", + "noEntries": "Keine Einträge" + }, + "buttons": { + "openLogFile": "Logdatei öffnen", + "defaultLog": "Standard-Log", + "refresh": "Aktualisieren", + "autoRefresh": "Automatisch aktualisieren", + "stopAutoRefresh": "Automatische Aktualisierung stoppen", + "copy": "Kopieren", + "save": "Speichern", + "clear": "Löschen", + "close": "Schließen", + "clearFilters": "Filter löschen" + }, + "controls": { + "showContext": "Kontext anzeigen", + "showServer": "Server anzeigen", + "autoScrollToTop": "Automatisch nach oben scrollen" + }, + "placeholders": { + "loadAmount": "Zu ladende Anzahl", + "searchLogs": "Logs suchen...", + "level": "Stufe", + "context": "Kontext", + "exchangeDebug": "Exchange-Debug-Filter" + }, + "filters": { + "entryLimit": { + "last100": "Letzte 100 Einträge", + "last500": "Letzte 500 Einträge", + "last1000": "Letzte 1000 Einträge", + "last5000": "Letzte 5000 Einträge", + "all": "Alle Einträge" + }, + "level": { + "all": "Alle Stufen", + "debug": "Debug", + "info": "Info", + "warn": "Warnung", + "error": "Fehler", + "verbose": "Ausführlich" + }, + "context": { + "all": "Alle Kontexte", + "main": "Hauptprozess", + "renderer": "Renderer", + "webview": "Webview", + "videocall": "Videoanruf", + "outlook": "Outlook-Kalender", + "auth": "Authentifizierung", + "updates": "Updates", + "notifications": "Benachrichtigungen", + "servers": "Server", + "ipc": "IPC-Kommunikation" + }, + "server": { + "all": "Alle Server", + "label": "Server" + }, + "exchangeDebug": { + "all": "Alle Logs", + "success": "Nur Erfolge", + "failure": "Nur Fehler", + "ntlmFlow": "NTLM-Authentifizierung", + "exchangeComm": "Exchange-Kommunikation", + "sslCerts": "SSL-/Zertifikatsprobleme", + "networkErrors": "Netzwerkprobleme", + "successFactors": "Erfolgsfaktoren", + "outlookCalendar": "Outlook-Kalender" + } + }, + "messages": { + "noLogsFound": "Keine Logs gefunden", + "adjustFilters": "Passen Sie die Filter an oder aktualisieren Sie die Logs" + } + }, "serverInfo": { "title": "Serverinformationen", "urlLabel": "URL:", @@ -438,5 +678,52 @@ "label": "Ablauf:", "expiresOn": "Läuft ab am {{date}}" } + }, + "telephony": { + "defaultHandlerPrompt": { + "title": "Rocket.Chat öffnet jetzt Telefonlinks", + "body": "tel:- und callto:-Links aus Ihrem Browser oder anderen Apps können jetzt Rocket.Chat öffnen.", + "bodyWindows": "Windows benötigt noch Ihre Bestätigung. Wählen Sie auf der Seite Standard-Apps jeden Linktyp (tel und callto) aus und wählen Sie Rocket.Chat. Dies ist einmal pro Linktyp erforderlich, und Windows verhindert, dass Apps dies automatisch für Sie festlegen.", + "bodyLinux": "Andere Apps können Telefonlinks auf Ihrem System weiterhin für sich beanspruchen. Öffnen Sie die Standardanwendungen und legen Sie Rocket.Chat für tel: und callto: fest, um die Einrichtung abzuschließen.", + "openSettingsWindows": "Rocket.Chat-Standard-Apps öffnen", + "openSettingsLinux": "Standardanwendungen öffnen", + "dismiss": "Verstanden" + }, + "diagnostics": { + "title": "Diagnose für Telefonlink-Handler", + "subtitle": "Prüfen Sie, ob Ihr System tel:- und callto:-Links an Rocket.Chat weiterleitet.", + "refresh": "Aktualisieren", + "copy": "Diagnose kopieren", + "copied": "Kopiert", + "openSettingsAction": "Einstellungen öffnen", + "platform": "Plattform", + "lastChecked": "Zuletzt geprüft", + "summary": { + "checking": "Wird geprüft...", + "issues_one": "{{count}} Problem", + "issues_other": "{{count}} Probleme", + "warnings_one": "{{count}} Warnung", + "warnings_other": "{{count}} Warnungen", + "healthy": "Alle Prüfungen bestanden" + }, + "status": { + "pass": "Bestanden", + "fail": "Fehlgeschlagen", + "unknown": "Unbekannt" + }, + "checks": { + "isDefault.tel": "Systemstandard für Click-to-Call (tel:): Rocket.Chat", + "isDefault.callto": "Systemstandard für Click-to-Conference (callto:): Rocket.Chat", + "windows.registeredApp": "Rocket.Chat ist in den registrierten Windows-Anwendungen aufgeführt", + "windows.capabilities.tel": "Windows Capabilities ordnen Click-to-Call (tel:) Rocket.Chat zu", + "windows.capabilities.callto": "Windows Capabilities ordnen Click-to-Conference (callto:) Rocket.Chat zu", + "windows.progid.tel": "Windows-ProgID für Click-to-Call (tel:) startet Rocket.Chat", + "windows.progid.callto": "Windows-ProgID für Click-to-Conference (callto:) startet Rocket.Chat", + "darwin.handler.tel": "macOS bestätigt, dass Rocket.Chat Click-to-Call (tel:) verarbeitet", + "darwin.handler.callto": "macOS bestätigt, dass Rocket.Chat Click-to-Conference (callto:) verarbeitet", + "linux.xdg.tel": "Linux xdg-mime zeigt für Click-to-Call (tel:) auf Rocket.Chat", + "linux.xdg.callto": "Linux xdg-mime zeigt für Click-to-Conference (callto:) auf Rocket.Chat" + } + } } } diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index d4d3632a3f..f8be5deb84 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -165,6 +165,11 @@ "supportedVersion": { "title": "Workspace version unsupported" }, + "telephonySelectServer": { + "title": "Choose a workspace for this call", + "message": "Which workspace should take this call?", + "rememberChoice": "Remember for future calls" + }, "clearLogs": { "title": "Clear Logs", "message": "Are you sure you want to clear the log file?", @@ -237,8 +242,11 @@ "general": "General", "certificates": "Certificates", "developer": "Developer", + "voiceVideo": "Voice & Video", "sections": { - "logging": "Logging" + "logging": "Logging", + "telephony": "Telephony", + "videoCalls": "Video calls" }, "options": { "report": { @@ -292,6 +300,26 @@ "loading": "Loading browsers...", "current": "Currently using:" }, + "telephony": { + "title": "Telephony", + "description": "Let Rocket.Chat handle phone-call shortcuts and tel: or callto: links. Turn off to disable the shortcut, ignore phone-link clicks, and hide the settings below." + }, + "telephonyServer": { + "title": "Telephony Server", + "description": "Choose which workspace opens when you use the telephony shortcut or a tel: or callto: link.", + "auto": "Ask each time" + }, + "telephonyShortcut": { + "title": "Global call shortcut", + "description": "Press this shortcut from any app to bring Rocket.Chat to the front and open the dial pad. If a phone number is on your clipboard, it gets pre-filled.", + "placeholder": "Not set — click Save to record", + "capturePlaceholder": "Press shortcut keys…", + "save": "Save", + "clear": "Clear", + "registered": "Shortcut registered", + "reservedByApp": "{{accelerator}} is already used by Rocket.Chat. Pick a different combination.", + "reservedByOS": "{{accelerator}} is reserved by your operating system. Pick a different combination." + }, "clearPermittedScreenCaptureServers": { "title": "Clear Screen Capture Permissions", "description": "Clear the screen capture permissions that was selected to not ask again on video calls." @@ -650,5 +678,52 @@ "label": "Expiration:", "expiresOn": "Expires on {{date}}" } + }, + "telephony": { + "defaultHandlerPrompt": { + "title": "Rocket.Chat now opens phone links", + "body": "tel: and callto: links from your browser or other apps can now open Rocket.Chat.", + "bodyWindows": "Windows still needs your confirmation. On the Default Apps page, tap each link type (tel and callto) and pick Rocket.Chat. You do this once per link type, and Windows prevents apps from setting it for you.", + "bodyLinux": "Other apps may still claim phone links on your system. Open default applications and set Rocket.Chat for tel: and callto: to finish.", + "openSettingsWindows": "Open Rocket.Chat default apps", + "openSettingsLinux": "Open default applications", + "dismiss": "Got it" + }, + "diagnostics": { + "title": "Phone-link handler diagnostics", + "subtitle": "Check whether your system routes tel: and callto: links to Rocket.Chat.", + "refresh": "Refresh", + "copy": "Copy diagnostics", + "copied": "Copied", + "openSettingsAction": "Open settings", + "platform": "Platform", + "lastChecked": "Last checked", + "summary": { + "checking": "Checking…", + "issues_one": "{{count}} issue", + "issues_other": "{{count}} issues", + "warnings_one": "{{count}} warning", + "warnings_other": "{{count}} warnings", + "healthy": "All checks pass" + }, + "status": { + "pass": "Pass", + "fail": "Fail", + "unknown": "Unknown" + }, + "checks": { + "isDefault.tel": "System default for click-to-call (tel:): Rocket.Chat", + "isDefault.callto": "System default for click-to-conference (callto:): Rocket.Chat", + "windows.registeredApp": "Rocket.Chat listed in Windows Registered Applications", + "windows.capabilities.tel": "Windows Capabilities map click-to-call (tel:) to Rocket.Chat", + "windows.capabilities.callto": "Windows Capabilities map click-to-conference (callto:) to Rocket.Chat", + "windows.progid.tel": "Windows ProgID for click-to-call (tel:) launches Rocket.Chat", + "windows.progid.callto": "Windows ProgID for click-to-conference (callto:) launches Rocket.Chat", + "darwin.handler.tel": "macOS confirms Rocket.Chat handles click-to-call (tel:)", + "darwin.handler.callto": "macOS confirms Rocket.Chat handles click-to-conference (callto:)", + "linux.xdg.tel": "Linux xdg-mime points click-to-call (tel:) at Rocket.Chat", + "linux.xdg.callto": "Linux xdg-mime points click-to-conference (callto:) at Rocket.Chat" + } + } } } diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json index ab1b314f61..57d1f140a8 100644 --- a/src/i18n/es.i18n.json +++ b/src/i18n/es.i18n.json @@ -155,6 +155,11 @@ }, "supportedVersion": { "title": "Versión de espacio de trabajo no admitida" + }, + "telephonySelectServer": { + "title": "Seleccionar servidor", + "message": "¿Qué servidor debe gestionar esta llamada?", + "rememberChoice": "Recordar esta elección" } }, "documentViewer": { @@ -272,6 +277,21 @@ "loading": "Cargando navegadores...", "current": "Usando actualmente:" }, + "telephonyServer": { + "title": "Servidor de telefonía", + "description": "Elige qué espacio de trabajo se abre cuando usas el atajo de telefonía o un enlace tel: o callto:.", + "auto": "Automático (preguntar siempre)" + }, + "telephonyShortcut": { + "title": "Atajo global de telefonía", + "description": "Usa este atajo desde cualquier lugar para traer Rocket.Chat al frente y abrir el teclado de marcado de telefonía. Si tu portapapeles contiene texto que parece un número de teléfono, Rocket.Chat lo completará automáticamente.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Presiona las teclas...", + "save": "Guardar", + "clear": "Borrar", + "registered": "Atajo registrado", + "reservedAccelerator": "{{accelerator}} está reservado por Rocket.Chat o por tu sistema operativo." + }, "clearPermittedScreenCaptureServers": { "title": "Borrar permisos de captura de pantalla", "description": "Borrar los permisos de captura de pantalla que se seleccionaron para no volver a preguntar en las videollamadas." diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json index 01c04c1f11..de987209e0 100644 --- a/src/i18n/fi.i18n.json +++ b/src/i18n/fi.i18n.json @@ -143,6 +143,11 @@ "answerCall": "vastata puheluun", "recordMessage": "tallentaa viestin" } + }, + "telephonySelectServer": { + "title": "Valitse palvelin", + "message": "Mikä palvelin käsittelee tämän puhelun?", + "rememberChoice": "Muista tämä valinta" } }, "documentViewer": { @@ -260,6 +265,21 @@ "loading": "Ladataan selaimia...", "current": "Käytössä nyt:" }, + "telephonyServer": { + "title": "Puhelinpalvelin", + "description": "Valitse, mikä työtila avataan, kun käytät puhelutoiminnon pikanäppäintä tai tel:- tai callto:-linkkiä.", + "auto": "Automaattinen (kysy joka kerta)" + }, + "telephonyShortcut": { + "title": "Puhelutoiminnon yleinen pikanäppäin", + "description": "Käytä tätä pikanäppäintä mistä tahansa tuodaksesi Rocket.Chatin etualalle ja avataksesi puhelutoiminnon numeronäppäimistön. Jos leikepöydälläsi on tekstiä, joka näyttää puhelinnumerolta, Rocket.Chat täyttää sen valmiiksi.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Paina näppäimiä...", + "save": "Tallenna", + "clear": "Tyhjennä", + "registered": "Pikanäppäin rekisteröity", + "reservedAccelerator": "{{accelerator}} on Rocket.Chatin tai käyttöjärjestelmäsi varaama." + }, "clearPermittedScreenCaptureServers": { "title": "Tyhjennä näyttökuvaoikeudet", "description": "Tyhjennä valitut näyttökuvaoikeudet, joilla estettiin kysyminen uudelleen videopuheluissa." diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json index 24acd0e94a..01b8dbcc08 100644 --- a/src/i18n/fr.i18n.json +++ b/src/i18n/fr.i18n.json @@ -143,6 +143,11 @@ "answerCall": "répondre aux appels", "recordMessage": "enregistrer des messages" } + }, + "telephonySelectServer": { + "title": "Sélectionner le serveur", + "message": "Quel serveur doit gérer cet appel ?", + "rememberChoice": "Se souvenir de ce choix" } }, "documentViewer": { @@ -260,6 +265,21 @@ "loading": "Chargement des navigateurs...", "current": "Actuellement utilisé:" }, + "telephonyServer": { + "title": "Serveur de téléphonie", + "description": "Choisissez l'espace de travail qui s'ouvre lorsque vous utilisez le raccourci de téléphonie ou un lien tel: ou callto:.", + "auto": "Auto (demander à chaque fois)" + }, + "telephonyShortcut": { + "title": "Raccourci global de téléphonie", + "description": "Utilisez ce raccourci depuis n'importe où pour ramener Rocket.Chat au premier plan et ouvrir le clavier de numérotation. Si votre presse-papiers contient du texte qui ressemble à un numéro de téléphone, Rocket.Chat le préremplit.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Appuyez sur des touches...", + "save": "Enregistrer", + "clear": "Effacer", + "registered": "Raccourci enregistré", + "reservedAccelerator": "{{accelerator}} est réservé par Rocket.Chat ou votre système d'exploitation." + }, "clearPermittedScreenCaptureServers": { "title": "Effacer les autorisations de capture d'écran", "description": "Effacez les autorisations de capture d'écran qui ont été sélectionnées pour ne plus demander lors des appels vidéo." diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json index 7fa9242b98..b897129d14 100644 --- a/src/i18n/hu.i18n.json +++ b/src/i18n/hu.i18n.json @@ -164,6 +164,11 @@ }, "supportedVersion": { "title": "A munkaterület verziója nem támogatott" + }, + "telephonySelectServer": { + "title": "Szerver kiválasztása", + "message": "Melyik szerver kezelje ezt a hívást?", + "rememberChoice": "Választás megjegyzése" } }, "documentViewer": { @@ -285,6 +290,21 @@ "loading": "Böngészők betöltése…", "current": "Jelenleg használt:" }, + "telephonyServer": { + "title": "Telefonos kiszolgáló", + "description": "Válaszd ki, melyik munkaterület nyíljon meg, amikor a telefonos gyorsbillentyűt vagy egy tel: vagy callto: hivatkozást használsz.", + "auto": "Automatikus (mindig kérdezzen)" + }, + "telephonyShortcut": { + "title": "Globális telefonos gyorsbillentyű", + "description": "Ezzel a gyorsbillentyűvel bárhonnan előtérbe hozhatod a Rocket.Chatet, és megnyithatod a telefonos tárcsázót. Ha a vágólapodon telefonszámnak tűnő szöveg van, a Rocket.Chat előre kitölti.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Nyomd meg a billentyűket...", + "save": "Mentés", + "clear": "Törlés", + "registered": "Gyorsbillentyű regisztrálva", + "reservedAccelerator": "{{accelerator}} a Rocket.Chat vagy az operációs rendszer által fenntartott." + }, "clearPermittedScreenCaptureServers": { "title": "Képernyőfelvételi engedélyek törlése", "description": "Azon képernyőfelvételi engedélyek törlése, amelyek úgy lettek kiválasztva, hogy ne kérdezzenek újra a videohívásoknál." @@ -626,4 +646,4 @@ "expiresOn": "Lejár ekkor: {{date}}" } } -} \ No newline at end of file +} diff --git a/src/i18n/it-IT.i18n.json b/src/i18n/it-IT.i18n.json index e9ba349df6..b119df54d3 100644 --- a/src/i18n/it-IT.i18n.json +++ b/src/i18n/it-IT.i18n.json @@ -18,9 +18,31 @@ "auto": "Segui sistema", "light": "Chiaro", "dark": "Scuro" + }, + "telephonyServer": { + "title": "Server telefonia", + "description": "Scegli quale spazio di lavoro si apre quando usi la scorciatoia di telefonia o un link tel: o callto:.", + "auto": "Automatico (chiedi ogni volta)" + }, + "telephonyShortcut": { + "title": "Scorciatoia globale di telefonia", + "description": "Usa questa scorciatoia da qualsiasi punto per portare Rocket.Chat in primo piano e aprire il tastierino di telefonia. Se gli appunti contengono testo che sembra un numero di telefono, Rocket.Chat lo precompila.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Premi i tasti...", + "save": "Salva", + "clear": "Cancella", + "registered": "Scorciatoia registrata", + "reservedAccelerator": "{{accelerator}} è riservata da Rocket.Chat o dal tuo sistema operativo." } } }, + "dialog": { + "telephonySelectServer": { + "title": "Seleziona server", + "message": "Quale server deve gestire questa chiamata?", + "rememberChoice": "Ricorda questa scelta" + } + }, "serverInfo": { "title": "Informazioni sul Server", "urlLabel": "URL:", diff --git a/src/i18n/ja.i18n.json b/src/i18n/ja.i18n.json index 7ab559f747..613b81aa60 100644 --- a/src/i18n/ja.i18n.json +++ b/src/i18n/ja.i18n.json @@ -101,6 +101,11 @@ "answerCall": "通話に応答する", "recordMessage": "メッセージを録音する" } + }, + "telephonySelectServer": { + "title": "サーバーを選択", + "message": "この通話をどのサーバーで処理しますか?", + "rememberChoice": "この選択を記憶する" } }, "documentViewer": { @@ -122,6 +127,21 @@ "auto": "システムに従う", "light": "ライト", "dark": "ダーク" + }, + "telephonyServer": { + "title": "テレフォニーサーバー", + "description": "テレフォニーショートカットまたは tel: / callto: リンクを使用したときに開くワークスペースを選択します。", + "auto": "自動(毎回確認)" + }, + "telephonyShortcut": { + "title": "テレフォニーのグローバルショートカット", + "description": "このショートカットをどこからでも使用して Rocket.Chat を前面に表示し、テレフォニーのダイヤルパッドを開きます。クリップボードに電話番号のようなテキストがある場合、Rocket.Chat が自動的に入力します。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "キーを押してください...", + "save": "保存", + "clear": "クリア", + "registered": "ショートカットを登録しました", + "reservedAccelerator": "{{accelerator}} は Rocket.Chat またはオペレーティングシステムによって予約されています。" } } }, diff --git a/src/i18n/nb-NO.i18n.json b/src/i18n/nb-NO.i18n.json index d87c4803fc..99f7b3d759 100644 --- a/src/i18n/nb-NO.i18n.json +++ b/src/i18n/nb-NO.i18n.json @@ -18,9 +18,31 @@ "auto": "Følg system", "light": "Lys", "dark": "Mørk" + }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Velg hvilket arbeidsområde som åpnes når du bruker telefonisnarveien eller en tel:- eller callto:-lenke.", + "auto": "Automatisk (spør hver gang)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarvei", + "description": "Bruk denne snarveien hvor som helst for å hente Rocket.Chat frem og åpne telefonitastaturet. Hvis utklippstavlen inneholder tekst som ser ut som et telefonnummer, fyller Rocket.Chat det ut på forhånd.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på taster...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarvei registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." } } }, + "dialog": { + "telephonySelectServer": { + "title": "Velg server", + "message": "Hvilken server skal håndtere denne samtalen?", + "rememberChoice": "Husk dette valget" + } + }, "serverInfo": { "title": "Serverinformasjon", "urlLabel": "URL:", diff --git a/src/i18n/nn.i18n.json b/src/i18n/nn.i18n.json index 795604d72f..f8b3a27d70 100644 --- a/src/i18n/nn.i18n.json +++ b/src/i18n/nn.i18n.json @@ -18,9 +18,31 @@ "auto": "Følg system", "light": "Lys", "dark": "Mørk" + }, + "telephonyServer": { + "title": "Telefonitenar", + "description": "Vel kva arbeidsområde som opnast når du brukar telefonisnarvegen eller ei tel:- eller callto:-lenkje.", + "auto": "Automatisk (spør kvar gong)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarveg", + "description": "Bruk denne snarvegen kvar som helst for å hente Rocket.Chat fram og opne telefonitastaturet. Dersom utklippstavla inneheld tekst som ser ut som eit telefonnummer, fyller Rocket.Chat det ut på førehand.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på tastar...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarveg registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." } } }, + "dialog": { + "telephonySelectServer": { + "title": "Vel tenar", + "message": "Kva tenar skal handtere denne samtalen?", + "rememberChoice": "Hugs dette valet" + } + }, "serverInfo": { "title": "Serverinformasjon", "urlLabel": "URL:", diff --git a/src/i18n/no.i18n.json b/src/i18n/no.i18n.json index b1e1827e0f..7757157638 100644 --- a/src/i18n/no.i18n.json +++ b/src/i18n/no.i18n.json @@ -165,6 +165,11 @@ "supportedVersion": { "title": "Workspace-versjonen støttes ikke" }, + "telephonySelectServer": { + "title": "Velg server", + "message": "Hvilken server skal håndtere denne samtalen?", + "rememberChoice": "Husk dette valget" + }, "clearLogs": { "title": "Tøm logger", "message": "Er du sikker på at du vil slette loggfilen?", @@ -292,6 +297,21 @@ "loading": "Laster inn nettlesere ...", "current": "Bruker for øyeblikket:" }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Velg hvilket arbeidsområde som åpnes når du bruker telefonisnarveien eller en tel:- eller callto:-lenke.", + "auto": "Automatisk (spør hver gang)" + }, + "telephonyShortcut": { + "title": "Global telefonisnarvei", + "description": "Bruk denne snarveien hvor som helst for å hente Rocket.Chat frem og åpne telefonitastaturet. Hvis utklippstavlen inneholder tekst som ser ut som et telefonnummer, fyller Rocket.Chat det ut på forhånd.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Trykk på taster...", + "save": "Lagre", + "clear": "Tøm", + "registered": "Snarvei registrert", + "reservedAccelerator": "{{accelerator}} er reservert av Rocket.Chat eller operativsystemet ditt." + }, "clearPermittedScreenCaptureServers": { "title": "Fjern skjermopptakstillatelser", "description": "Fjern skjermopptakstillatelsene på videosamtaler som ble valgt for ikke å spørre igjen." diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json index 49e2cbd3a2..a85eaea0d7 100644 --- a/src/i18n/pl.i18n.json +++ b/src/i18n/pl.i18n.json @@ -113,6 +113,11 @@ "answerCall": "odbierania połączeń", "recordMessage": "nagrywania wiadomości" } + }, + "telephonySelectServer": { + "title": "Wybierz serwer", + "message": "Który serwer ma obsłużyć to połączenie?", + "rememberChoice": "Zapamiętaj ten wybór" } }, "documentViewer": { @@ -137,6 +142,21 @@ "loading": "Ładowanie przeglądarek...", "current": "Aktualnie używana:" }, + "telephonyServer": { + "title": "Serwer telefonii", + "description": "Wybierz, który obszar roboczy otwiera się po użyciu skrótu telefonii albo linku tel: lub callto:.", + "auto": "Automatycznie (pytaj za każdym razem)" + }, + "telephonyShortcut": { + "title": "Globalny skrót telefonii", + "description": "Użyj tego skrótu z dowolnego miejsca, aby przenieść Rocket.Chat na pierwszy plan i otworzyć klawiaturę wybierania. Jeśli schowek zawiera tekst wyglądający jak numer telefonu, Rocket.Chat wypełni go automatycznie.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Naciśnij klawisze...", + "save": "Zapisz", + "clear": "Wyczyść", + "registered": "Skrót zarejestrowany", + "reservedAccelerator": "{{accelerator}} jest zarezerwowany przez Rocket.Chat lub system operacyjny." + }, "transparentWindow": { "title": "Efekt przezroczystego okna", "description": "Włącz natywny efekt wibracji/przezroczystości dla okna. Wymaga ponownego uruchomienia, aby zastosować." diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index 0d22169fc8..16e8a3a7cb 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -153,8 +153,29 @@ "recordMessage": "gravar mensagens" } }, + "outlookCalendar": { + "title": "Calendário do Outlook", + "encryptionUnavailableTitle": "Criptografia indisponível", + "encryptionUnavailable": "Seu sistema operacional não oferece suporte à criptografia.\nSuas credenciais serão armazenadas em texto simples.", + "field_required": "Este campo é obrigatório", + "remember_credentials": "Lembrar minhas credenciais", + "cancel": " Cancelar", + "submit": "Entrar" + }, "supportedVersion": { "title": "Versão de workspace não suportada" + }, + "telephonySelectServer": { + "title": "Selecionar servidor", + "message": "Qual servidor deve atender esta chamada?", + "rememberChoice": "Lembrar desta escolha" + }, + "clearLogs": { + "title": "Limpar logs", + "message": "Tem certeza de que deseja limpar o arquivo de log?", + "detail": "Esta ação não pode ser desfeita. Todas as entradas de log atuais serão excluídas permanentemente.", + "yes": "Limpar", + "cancel": "Cancelar" } }, "documentViewer": { @@ -167,7 +188,12 @@ "downloads": { "title": "Downloads", "notifications": { - "downloadFinished": "Download finalizado" + "downloadFinished": "Download finalizado", + "downloadInterrupted": "Download interrompido", + "downloadCancelled": "Download cancelado", + "downloadFailed": "Falha no download", + "downloadExpired": "Download expirado", + "downloadExpiredMessage": "Tente baixar novamente pela origem." }, "filters": { "search": "Buscar", @@ -215,6 +241,13 @@ "title": "Configurações", "general": "Geral", "certificates": "Certificados", + "developer": "Desenvolvedor", + "voiceVideo": "Voz e vídeo", + "sections": { + "logging": "Logs", + "telephony": "Telefonia", + "videoCalls": "Chamadas de vídeo" + }, "options": { "report": { "title": "Relatar erros aos desenvolvedores", @@ -267,6 +300,26 @@ "loading": "Carregando navegadores...", "current": "Usando atualmente:" }, + "telephony": { + "title": "Telefonia", + "description": "Permitir que o Rocket.Chat lide com atalhos de chamada e links tel: ou callto:. Desative para desabilitar o atalho, ignorar cliques em links telefônicos e ocultar as configurações abaixo." + }, + "telephonyServer": { + "title": "Servidor de Telefonia", + "description": "Escolha qual espaço de trabalho será aberto quando você usar o atalho de telefonia ou um link tel: ou callto:.", + "auto": "Perguntar sempre" + }, + "telephonyShortcut": { + "title": "Atalho Global de Telefonia", + "description": "Use este atalho de qualquer lugar para trazer o Rocket.Chat para frente e abrir o teclado de telefonia. Se a área de transferência contiver um texto que pareça um número de telefone, o Rocket.Chat o preencherá automaticamente.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Pressione as teclas...", + "save": "Salvar", + "clear": "Limpar", + "registered": "Atalho registrado", + "reservedByApp": "{{accelerator}} já está em uso pelo Rocket.Chat. Escolha outra combinação.", + "reservedByOS": "{{accelerator}} está reservado pelo seu sistema operacional. Escolha outra combinação." + }, "clearPermittedScreenCaptureServers": { "title": "Limpar Permissões de Captura de Tela", "description": "Limpar as permissões de captura de tela que foram selecionadas para não perguntar novamente em chamadas de vídeo." @@ -294,6 +347,22 @@ "auto": "Seguir sistema", "light": "Claro", "dark": "Escuro" + }, + "outlookCalendarSyncInterval": { + "title": "Intervalo de sincronização do Calendário do Outlook", + "description": "Com que frequência sincronizar eventos do calendário do Outlook, em minutos (1-60)." + }, + "verboseOutlookLogging": { + "title": "Logs detalhados do Calendário do Outlook", + "description": "Ativar logs detalhados de depuração de Exchange/NTLM para solucionar problemas de integração com o Calendário do Outlook." + }, + "detailedEventsLogging": { + "title": "Logs detalhados de eventos", + "description": "Registrar todos os dados de eventos trocados entre o Outlook e o Rocket.Chat durante a sincronização do calendário. Útil para diagnosticar problemas de sincronização." + }, + "debugLogging": { + "title": "Logs detalhados", + "description": "Grava toda a saída do console no arquivo de log. Quando desativado, apenas erros e mensagens importantes são salvos, mantendo os logs menores e focados." } } }, @@ -349,6 +418,11 @@ "showServerList": "Lista de servidores", "showTrayIcon": "Ícone da bandeja", "toggleDevTools": "Alternar &DevTools", + "openConfigFolder": "Abrir pasta de &configuração", + "openLogViewer": "Abrir visualizador de &logs", + "videoCallDevTools": "Abrir &DevTools da chamada de vídeo", + "videoCallTools": "Ferramentas de chamada de vídeo", + "videoCallDevToolsAutoOpen": "Abrir DevTools automaticamente", "undo": "&Desfazer", "unhide": "Mostrar todos", "viewMenu": "&Exibir", @@ -398,7 +472,9 @@ "clearCache": "Limpar cache", "clearStorageData": "Limpar dados de armazenamento", "copyCurrentUrl": "Copiar URL", - "reloadClearingCache": "Forçar recarregamento" + "reloadClearingCache": "Forçar recarregamento", + "serverInfo": "Informações do servidor", + "supportedVersionsInfo": "Informações de versões suportadas" }, "tooltips": { "unreadMessage": "{{- count}} mensagem não lida", @@ -440,6 +516,7 @@ "permissionDenied": "Permissão de Gravação de Tela Negada", "permissionRequired": "A permissão de gravação de tela é necessária para compartilhar sua tela.", "permissionInstructions": "Por favor, ative-a nas preferências do sistema e tente novamente.", + "openSystemPreferences": "Abrir preferências do sistema", "title": "Compartilhar sua tela", "entireScreen": "Toda sua tela", "applicationWindow": "Janela de aplicativo", @@ -448,6 +525,134 @@ "cancel": "Cancelar", "share": "Compartilhar" }, + "logging": { + "context": { + "processTypes": { + "main": "Processo principal", + "renderer": "Processo renderer", + "preload": "Processo preload", + "rendererRoot": "Janela principal", + "webview": "Webview do servidor", + "videoCall": "Janela de chamada de vídeo" + }, + "components": { + "auth": "Autenticação", + "connection": "Conexão", + "notification": "Notificação", + "outlook": "Calendário do Outlook", + "videoCall": "Chamada de vídeo", + "download": "Download", + "spellCheck": "Verificação ortográfica", + "general": "Geral" + }, + "serverInfo": { + "anonymous": "Servidor anônimo", + "local": "Processo local", + "unknown": "Servidor desconhecido" + } + }, + "status": { + "moduleNotAvailable": "Módulo de armazenamento indisponível para mapeamento de contexto do servidor", + "importFailed": "Falha ao importar o armazenamento" + }, + "errors": { + "configurationFailed": "Falha ao configurar electron-log", + "storeUnavailable": "Módulo de armazenamento indisponível para mapeamento de contexto do servidor", + "webContentsLoggingFailed": "Falha ao configurar logs de webContents", + "rendererLogFailed": "Falha ao registrar log do renderer", + "consoleOverrideFailed": "Falha ao substituir métodos do console" + }, + "messages": { + "serverContext": "Servidor {{- serverNumber}}", + "logContext": "Contexto do log: {{- context}}" + } + }, + "logViewer": { + "title": "Visualizador de logs", + "aria": { + "logIcon": "Ícone do visualizador de logs", + "entriesCount": "{{count}} entradas de log exibidas" + }, + "fileInfo": { + "custom": "Personalizado", + "entries": "{{count}} entradas", + "entriesOfTotal": "{{count}} de {{total}} entradas", + "noEntries": "Nenhuma entrada" + }, + "buttons": { + "openLogFile": "Abrir arquivo de log", + "defaultLog": "Log padrão", + "refresh": "Atualizar", + "autoRefresh": "Atualização automática", + "stopAutoRefresh": "Parar atualização automática", + "copy": "Copiar", + "save": "Salvar", + "clear": "Limpar", + "close": "Fechar", + "clearFilters": "Limpar filtros" + }, + "controls": { + "showContext": "Mostrar contexto", + "showServer": "Mostrar servidor", + "autoScrollToTop": "Rolar automaticamente para o topo" + }, + "placeholders": { + "loadAmount": "Quantidade a carregar", + "searchLogs": "Buscar logs...", + "level": "Nível", + "context": "Contexto", + "exchangeDebug": "Filtro de depuração do Exchange" + }, + "filters": { + "entryLimit": { + "last100": "Últimas 100 entradas", + "last500": "Últimas 500 entradas", + "last1000": "Últimas 1000 entradas", + "last5000": "Últimas 5000 entradas", + "all": "Todas as entradas" + }, + "level": { + "all": "Todos os níveis", + "debug": "Depuração", + "info": "Informação", + "warn": "Aviso", + "error": "Erro", + "verbose": "Detalhado" + }, + "context": { + "all": "Todos os contextos", + "main": "Processo principal", + "renderer": "Renderer", + "webview": "Webview", + "videocall": "Chamada de vídeo", + "outlook": "Calendário do Outlook", + "auth": "Autenticação", + "updates": "Atualizações", + "notifications": "Notificações", + "servers": "Servidores", + "ipc": "Comunicação IPC" + }, + "server": { + "all": "Todos os servidores", + "label": "Servidor" + }, + "exchangeDebug": { + "all": "Todos os logs", + "success": "Apenas sucessos", + "failure": "Apenas falhas", + "ntlmFlow": "Autenticação NTLM", + "exchangeComm": "Comunicação com Exchange", + "sslCerts": "Problemas de SSL/certificado", + "networkErrors": "Problemas de rede", + "successFactors": "Fatores de sucesso", + "outlookCalendar": "Calendário do Outlook" + } + }, + "messages": { + "noLogsFound": "Nenhum log encontrado", + "adjustFilters": "Tente ajustar os filtros ou atualizar os logs" + } + }, "serverInfo": { "title": "Informações do Servidor", "urlLabel": "URL:", @@ -473,5 +678,52 @@ "label": "Expiração:", "expiresOn": "Expira em {{date}}" } + }, + "telephony": { + "defaultHandlerPrompt": { + "title": "O Rocket.Chat agora abre links telefônicos", + "body": "Links tel: e callto: do seu navegador ou de outros aplicativos agora podem abrir o Rocket.Chat.", + "bodyWindows": "O Windows ainda precisa da sua confirmação. Na página Aplicativos padrão, selecione cada tipo de link (tel e callto) e escolha Rocket.Chat. Você faz isso uma vez por tipo de link, e o Windows impede que aplicativos façam isso por você.", + "bodyLinux": "Outros aplicativos ainda podem reivindicar links telefônicos no seu sistema. Abra os aplicativos padrão e defina o Rocket.Chat para tel: e callto: para concluir.", + "openSettingsWindows": "Abrir aplicativos padrão do Rocket.Chat", + "openSettingsLinux": "Abrir aplicativos padrão", + "dismiss": "Entendi" + }, + "diagnostics": { + "title": "Diagnóstico do manipulador de links telefônicos", + "subtitle": "Verifique se o sistema direciona links tel: e callto: para o Rocket.Chat.", + "refresh": "Atualizar", + "copy": "Copiar diagnóstico", + "copied": "Copiado", + "openSettingsAction": "Abrir configurações", + "platform": "Plataforma", + "lastChecked": "Última verificação", + "summary": { + "checking": "Verificando...", + "issues_one": "{{count}} problema", + "issues_other": "{{count}} problemas", + "warnings_one": "{{count}} aviso", + "warnings_other": "{{count}} avisos", + "healthy": "Todas as verificações passaram" + }, + "status": { + "pass": "Passou", + "fail": "Falhou", + "unknown": "Desconhecido" + }, + "checks": { + "isDefault.tel": "Padrão do sistema para click-to-call (tel:): Rocket.Chat", + "isDefault.callto": "Padrão do sistema para click-to-conference (callto:): Rocket.Chat", + "windows.registeredApp": "Rocket.Chat listado em Aplicativos Registrados do Windows", + "windows.capabilities.tel": "Capabilities do Windows mapeiam click-to-call (tel:) para Rocket.Chat", + "windows.capabilities.callto": "Capabilities do Windows mapeiam click-to-conference (callto:) para Rocket.Chat", + "windows.progid.tel": "ProgID do Windows para click-to-call (tel:) inicia o Rocket.Chat", + "windows.progid.callto": "ProgID do Windows para click-to-conference (callto:) inicia o Rocket.Chat", + "darwin.handler.tel": "macOS confirma que o Rocket.Chat lida com click-to-call (tel:)", + "darwin.handler.callto": "macOS confirma que o Rocket.Chat lida com click-to-conference (callto:)", + "linux.xdg.tel": "xdg-mime do Linux aponta click-to-call (tel:) para Rocket.Chat", + "linux.xdg.callto": "xdg-mime do Linux aponta click-to-conference (callto:) para Rocket.Chat" + } + } } } diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json index 170fca6dc5..e88b86cd4d 100644 --- a/src/i18n/ru.i18n.json +++ b/src/i18n/ru.i18n.json @@ -137,6 +137,11 @@ "answerCall": "отвечать на звонки", "recordMessage": "записывать сообщения" } + }, + "telephonySelectServer": { + "title": "Выбор сервера", + "message": "Какой сервер должен обработать этот звонок?", + "rememberChoice": "Запомнить этот выбор" } }, "documentViewer": { @@ -254,6 +259,21 @@ "loading": "Загрузка браузеров...", "current": "Сейчас используется:" }, + "telephonyServer": { + "title": "Сервер телефонии", + "description": "Выберите рабочее пространство, которое будет открываться при использовании сочетания клавиш телефонии или ссылки tel: либо callto:.", + "auto": "Авто (спрашивать каждый раз)" + }, + "telephonyShortcut": { + "title": "Глобальное сочетание клавиш телефонии", + "description": "Используйте это сочетание клавиш из любого места, чтобы вывести Rocket.Chat на передний план и открыть панель набора номера. Если в буфере обмена есть текст, похожий на номер телефона, Rocket.Chat подставит его автоматически.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Нажмите клавиши...", + "save": "Сохранить", + "clear": "Очистить", + "registered": "Сочетание клавиш зарегистрировано", + "reservedAccelerator": "{{accelerator}} зарезервировано Rocket.Chat или вашей операционной системой." + }, "clearPermittedScreenCaptureServers": { "title": "Очистить разрешенные серверы захвата экрана", "description": "Выберите серверы, которые могут захватывать экраны приложений из этого приложения." diff --git a/src/i18n/se.i18n.json b/src/i18n/se.i18n.json index 1044539ad2..27cb1485c5 100644 --- a/src/i18n/se.i18n.json +++ b/src/i18n/se.i18n.json @@ -18,9 +18,31 @@ "auto": "Följ system", "light": "Ljus", "dark": "Mörk" + }, + "telephonyServer": { + "title": "Telefoniabálvá", + "description": "Vállje guđe bargosadji rahpasa go geavahat telefoniija oanehisboalu dahje tel: dahje callto: liŋkka.", + "auto": "Automáhtalaš (jeara juohke háve)" + }, + "telephonyShortcut": { + "title": "Telefoniija globála oanehisboallu", + "description": "Geavat dán oanehisboalu gos fal vai Rocket.Chat boahtá ovdii ja telefoniija numerboallu rahpasa. Jus čuohpusis lea teaksta mii orru telefonnummirin, de Rocket.Chat deavdá dan ovdagihtii.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Deaddil boaluid...", + "save": "Vurke", + "clear": "Sálke", + "registered": "Oanehisboallu lea registrerejuvvon", + "reservedAccelerator": "{{accelerator}} lea Rocket.Chat dahje du operatiivavuogádaga várrejuvvon." } } }, + "dialog": { + "telephonySelectServer": { + "title": "Vállje bálvá", + "message": "Guđe bálvá galgá meannudit dán riŋgema?", + "rememberChoice": "Muitte dán válljema" + } + }, "serverInfo": { "title": "Serverinformation", "urlLabel": "URL:", diff --git a/src/i18n/sv.i18n.json b/src/i18n/sv.i18n.json index ddb98f49ab..91c65c3fc9 100644 --- a/src/i18n/sv.i18n.json +++ b/src/i18n/sv.i18n.json @@ -164,6 +164,11 @@ }, "supportedVersion": { "title": "Versionen av arbetsytan stöds inte" + }, + "telephonySelectServer": { + "title": "Välj server", + "message": "Vilken server ska hantera detta samtal?", + "rememberChoice": "Kom ihåg detta val" } }, "documentViewer": { @@ -281,6 +286,21 @@ "loading": "Laddar webbläsare...", "current": "Använder för närvarande:" }, + "telephonyServer": { + "title": "Telefoniserver", + "description": "Välj vilken arbetsyta som öppnas när du använder telefonigenvägen eller en tel:- eller callto:-länk.", + "auto": "Automatiskt (fråga varje gång)" + }, + "telephonyShortcut": { + "title": "Global telefonigenväg", + "description": "Använd den här genvägen var som helst för att ta Rocket.Chat till förgrunden och öppna telefonins knappsats. Om urklippet innehåller text som ser ut som ett telefonnummer fyller Rocket.Chat i den i förväg.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Tryck på tangenter...", + "save": "Spara", + "clear": "Rensa", + "registered": "Genväg registrerad", + "reservedAccelerator": "{{accelerator}} är reserverad av Rocket.Chat eller ditt operativsystem." + }, "clearPermittedScreenCaptureServers": { "title": "Rensa behörigheter för skärmdumpning", "description": "Ta bort skärmdumpstillstånden som valts för att inte fråga igen vid videosamtal." diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json index 9d6383c893..766042f212 100644 --- a/src/i18n/tr-TR.i18n.json +++ b/src/i18n/tr-TR.i18n.json @@ -101,6 +101,11 @@ "answerCall": "aramaları yanıtlamak", "recordMessage": "mesaj kaydetmek" } + }, + "telephonySelectServer": { + "title": "Sunucu Seç", + "message": "Bu aramayı hangi sunucu yönetmeli?", + "rememberChoice": "Bu seçimi hatırla" } }, "documentViewer": { @@ -125,6 +130,21 @@ "loading": "Tarayıcılar yükleniyor...", "current": "Şu anda kullanılan:" }, + "telephonyServer": { + "title": "Telefon sunucusu", + "description": "Telefon kısayolunu veya tel: ya da callto: bağlantısını kullandığınızda hangi çalışma alanının açılacağını seçin.", + "auto": "Otomatik (her seferinde sor)" + }, + "telephonyShortcut": { + "title": "Genel telefon kısayolu", + "description": "Rocket.Chat'i öne getirmek ve telefon arama tuş takımını açmak için bu kısayolu her yerden kullanın. Panonuzda telefon numarasına benzeyen bir metin varsa Rocket.Chat bunu otomatik olarak doldurur.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Tuşlara basın...", + "save": "Kaydet", + "clear": "Temizle", + "registered": "Kısayol kaydedildi", + "reservedAccelerator": "{{accelerator}}, Rocket.Chat veya işletim sisteminiz tarafından ayrılmıştır." + }, "transparentWindow": { "title": "Şeffaf pencere efekti", "description": "Pencere için yerel titreşim/şeffaflık efektini etkinleştir. Uygulamak için yeniden başlatma gerektirir." diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json index 24f998a4d1..9d1b63da77 100644 --- a/src/i18n/uk-UA.i18n.json +++ b/src/i18n/uk-UA.i18n.json @@ -107,6 +107,21 @@ "loading": "Завантаження браузерів...", "current": "Зараз використовується:" }, + "telephonyServer": { + "title": "Сервер телефонії", + "description": "Виберіть робочий простір, який відкриватиметься під час використання ярлика телефонії або посилання tel: чи callto:.", + "auto": "Автоматично (запитувати щоразу)" + }, + "telephonyShortcut": { + "title": "Глобальний ярлик телефонії", + "description": "Використовуйте цей ярлик звідусіль, щоб вивести Rocket.Chat на передній план і відкрити панель набору номера. Якщо буфер обміну містить текст, схожий на номер телефону, Rocket.Chat підставить його автоматично.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "Натисніть клавіші...", + "save": "Зберегти", + "clear": "Очистити", + "registered": "Ярлик зареєстровано", + "reservedAccelerator": "{{accelerator}} зарезервовано Rocket.Chat або вашою операційною системою." + }, "transparentWindow": { "title": "Ефект прозорого вікна", "description": "Увімкнути нативний ефект вібрації/прозорості для вікна. Потрібен перезапуск для застосування." diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json index 35468f9bc7..53cce81773 100644 --- a/src/i18n/zh-CN.i18n.json +++ b/src/i18n/zh-CN.i18n.json @@ -102,6 +102,11 @@ "answerCall": "接听电话", "recordMessage": "录制消息" } + }, + "telephonySelectServer": { + "title": "选择服务器", + "message": "哪个服务器应该处理此通话?", + "rememberChoice": "记住此选择" } }, "documentViewer": { @@ -172,6 +177,21 @@ "auto": "跟随系统", "light": "浅色", "dark": "深色" + }, + "telephonyServer": { + "title": "语音通话服务器", + "description": "选择使用语音通话快捷键或 tel:、callto: 链接时要打开的工作区。", + "auto": "自动(每次询问)" + }, + "telephonyShortcut": { + "title": "语音通话全局快捷键", + "description": "在任何地方使用此快捷键,将 Rocket.Chat 置于前台并打开语音通话拨号盘。如果剪贴板中包含看起来像电话号码的文本,Rocket.Chat 会自动预填。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按键...", + "save": "保存", + "clear": "清除", + "registered": "快捷键已注册", + "reservedAccelerator": "{{accelerator}} 已被 Rocket.Chat 或您的操作系统保留。" } } }, diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json index 4d5f606f7f..79962665f1 100644 --- a/src/i18n/zh-TW.i18n.json +++ b/src/i18n/zh-TW.i18n.json @@ -80,6 +80,11 @@ "title": "忽略更新", "message": "我們將會在下次有新的更新版本的時候通知您\n如果您改變主意想安裝此次更新,您可以從「關於」的選單中檢視更新", "ok": "好" + }, + "telephonySelectServer": { + "title": "選擇伺服器", + "message": "哪個伺服器應該處理此通話?", + "rememberChoice": "記住此選擇" } }, "documentViewer": { @@ -104,6 +109,21 @@ "loading": "正在載入瀏覽器...", "current": "目前使用:" }, + "telephonyServer": { + "title": "語音通話伺服器", + "description": "選擇使用語音通話快捷鍵或 tel:、callto: 連結時要開啟的工作區。", + "auto": "自動(每次詢問)" + }, + "telephonyShortcut": { + "title": "語音通話全域快捷鍵", + "description": "在任何地方使用此快捷鍵,將 Rocket.Chat 帶到最前方並開啟語音通話撥號盤。如果剪貼簿包含看起來像電話號碼的文字,Rocket.Chat 會自動預先填入。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按鍵...", + "save": "儲存", + "clear": "清除", + "registered": "快捷鍵已註冊", + "reservedAccelerator": "{{accelerator}} 已由 Rocket.Chat 或您的作業系統保留。" + }, "transparentWindow": { "title": "透明視窗效果", "description": "啟用視窗的原生模糊/透明效果。需要重新啟動才能套用。" diff --git a/src/i18n/zh.i18n.json b/src/i18n/zh.i18n.json index 0696c657f1..2b030d31de 100644 --- a/src/i18n/zh.i18n.json +++ b/src/i18n/zh.i18n.json @@ -18,9 +18,31 @@ "auto": "跟随系统", "light": "浅色", "dark": "深色" + }, + "telephonyServer": { + "title": "语音通话服务器", + "description": "选择使用语音通话快捷键或 tel:、callto: 链接时要打开的工作区。", + "auto": "自动(每次询问)" + }, + "telephonyShortcut": { + "title": "语音通话全局快捷键", + "description": "在任何地方使用此快捷键,将 Rocket.Chat 置于前台并打开语音通话拨号盘。如果剪贴板中包含看起来像电话号码的文本,Rocket.Chat 会自动预填。", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "按下按键...", + "save": "保存", + "clear": "清除", + "registered": "快捷键已注册", + "reservedAccelerator": "{{accelerator}} 已被 Rocket.Chat 或您的操作系统保留。" } } }, + "dialog": { + "telephonySelectServer": { + "title": "选择服务器", + "message": "哪个服务器应该处理此通话?", + "rememberChoice": "记住此选择" + } + }, "serverInfo": { "title": "服务器信息", "urlLabel": "URL:", diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index ab46a3371a..0311f81c26 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -3,6 +3,7 @@ import type { AnyAction } from 'redux'; import type { Download } from '../downloads/common'; import type { OutlookEventsResponse } from '../outlookCalendar/type'; import type { Server } from '../servers/common'; +import type { TelephonyDiagnostics } from '../telephony/diagnostics'; import type { SystemIdleState } from '../userPresence/common'; type ChannelToArgsMap = { @@ -137,6 +138,7 @@ type ChannelToArgsMap = { 'screen-picker/source-responded': (sourceId: string | null) => void; 'screen-picker/screen-recording-is-permission-granted': () => boolean; 'screen-picker/open-url': (url: string) => void; + 'telephony/get-diagnostics': () => TelephonyDiagnostics; }; export type Channel = keyof ChannelToArgsMap; diff --git a/src/main.spec.ts b/src/main.spec.ts index c1d7941789..42fc02b1d4 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -115,6 +115,10 @@ jest.mock('./spellChecking/main', () => ({ setupSpellChecking: jest.fn(() => Promise.resolve()), })); +jest.mock('./telephony/main', () => ({ + setupTelephonyGlobalShortcut: jest.fn(), +})); + jest.mock('./ui/components/CertificatesManager/main', () => ({ handleCertificatesManager: jest.fn(), })); diff --git a/src/main.ts b/src/main.ts index 3b222c55e6..f62058104e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,6 +44,12 @@ import { checkSupportedVersionServers } from './servers/supportedVersions/main'; import { setupSpellChecking } from './spellChecking/main'; import { createMainReduxStore } from './store'; import { applySystemCertificates } from './systemCertificates'; +import { setupTelephonyIpc } from './telephony/ipc'; +import { + setupTelephonyDefaultHandlerPrompt, + setupTelephonyGlobalShortcut, + setupTelephonyProtocolHandlers, +} from './telephony/main'; import { handleCertificatesManager } from './ui/components/CertificatesManager/main'; import dock from './ui/main/dock'; import menuBar from './ui/main/menuBar'; @@ -72,6 +78,7 @@ const start = async (): Promise => { setupWebContentsLogging(); performElectronStartup(); + setupDeepLinks(); // Set up GPU crash handler BEFORE whenReady to catch early GPU failures setupGpuCrashHandler(); @@ -119,7 +126,10 @@ const start = async (): Promise => { await setupSpellChecking(); - setupDeepLinks(); + setupTelephonyGlobalShortcut(); + setupTelephonyProtocolHandlers(); + setupTelephonyDefaultHandlerPrompt(); + setupTelephonyIpc(); await setupNavigation(); setupPowerMonitor(); await setupUpdates(); diff --git a/src/preload.ts b/src/preload.ts index f61560ad4e..f5c6e32b90 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -8,6 +8,7 @@ import { listenToScreenSharingRequests } from './screenSharing/preload'; import { RocketChatDesktop } from './servers/preload/api'; import { setServerUrl } from './servers/preload/urls'; import { createRendererReduxStore, listen } from './store'; +import { listenToTelephonyRequests } from './telephony/preload'; import { WEBVIEW_DID_NAVIGATE } from './ui/actions'; import { debounce } from './ui/main/debounce'; import { listenToMessageBoxEvents } from './ui/preload/messageBox'; @@ -64,6 +65,8 @@ const start = async (): Promise => { await invoke('server-view/ready'); + listenToTelephonyRequests(); + console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady'); RocketChatDesktop.onReady(() => { console.log('[Rocket.Chat Desktop] RocketChatDesktop.onReady fired'); diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index 9ebc67567d..b41b8d1a7e 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -14,6 +14,7 @@ import { clearOutlookCredentials, setUserToken, } from '../../outlookCalendar/preload'; +import { onTelephonyCallRequested } from '../../telephony/preload'; import { setUserPresenceDetection } from '../../userPresence/preload'; import { setBadge } from './badge'; import { writeTextToClipboard } from './clipboard'; @@ -49,6 +50,9 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { ) => Promise; closeCustomNotification: (id: unknown) => void; openInBrowser: (url: string) => void; + onTelephonyCallRequested: ( + callback: (payload: { phoneNumber: string; rawUri: string }) => void + ) => void; }; declare global { @@ -95,4 +99,5 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { openDocumentViewer, openInBrowser, reloadServer, + onTelephonyCallRequested, }; diff --git a/src/servers/supportedVersions/main.main.spec.ts b/src/servers/supportedVersions/main.main.spec.ts index 8e74b3eae6..f7475b833e 100644 --- a/src/servers/supportedVersions/main.main.spec.ts +++ b/src/servers/supportedVersions/main.main.spec.ts @@ -11,6 +11,7 @@ import { WEBVIEW_READY, WEBVIEW_SERVER_RELOADED, SUPPORTED_VERSION_DIALOG_DISMISS, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, } from '../../ui/actions'; import { checkSupportedVersionServers, @@ -175,6 +176,32 @@ describe('supportedVersions/main.ts', () => { }); }); + it('should dispatch git commit hash from server info', async () => { + const mockServer = createMockServer(); + const mockServerInfo = createMockServerInfo({ + commit: { + ...createMockServerInfo().commit, + hash: 'bb83777b51a42d', + }, + }); + selectMock.mockReturnValue(mockServer); + axiosMock.get = jest.fn().mockResolvedValue({ data: mockServerInfo }); + + (jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue( + createMockSupportedVersions() + ); + + await updateSupportedVersionsData(mockServer.url); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { + url: mockServer.url, + gitCommitHash: 'bb83777b51a42d', + }, + }); + }); + it('should retry server fetch on failure and succeed on retry', async () => { const mockServer = createMockServer(); const mockServerInfo = createMockServerInfo(); @@ -766,7 +793,7 @@ describe('supportedVersions/main.ts', () => { }); it('should optionally include message property', async () => { - const futureDate = new Date(Date.now() + 86400000).toISOString(); + const futureDate = new Date(Date.now() + 86400000); const supportedVersions = { versions: [ { @@ -807,6 +834,78 @@ describe('supportedVersions/main.ts', () => { expect(result.supported).toBe(true); }); + + it('should support sha-prefixed exception versions by git commit hash', async () => { + const futureDate = new Date(Date.now() + 86400000); + const supportedVersions: SupportedVersions = { + enforcementStartDate: new Date(Date.now() - 86400000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '8.4.0', + expiration: futureDate, + }, + ], + exceptions: { + domain: 'open.rocket.chat', + uniqueId: 'test-unique-id', + versions: [ + { + version: 'sha-bb83777', + expiration: futureDate, + }, + ], + }, + }; + + const result = await isServerVersionSupported( + { + url: 'https://open.rocket.chat/', + version: '8.5', + title: 'Rocket.Chat Open', + gitCommitHash: 'bb83777b51a42d', + } as any, + supportedVersions + ); + + expect(result.supported).toBe(true); + }); + + it('should not match malformed exception versions by git commit hash', async () => { + const futureDate = new Date(Date.now() + 86400000); + const supportedVersions: SupportedVersions = { + enforcementStartDate: new Date(Date.now() - 86400000).toISOString(), + timestamp: new Date().toISOString(), + versions: [ + { + version: '8.4.0', + expiration: futureDate, + }, + ], + exceptions: { + domain: 'open.rocket.chat', + uniqueId: 'test-unique-id', + versions: [ + { + version: '', + expiration: futureDate, + }, + ], + }, + }; + + const result = await isServerVersionSupported( + { + url: 'https://open.rocket.chat/', + version: '8.5', + title: 'Rocket.Chat Open', + gitCommitHash: 'bb83777b51a42d', + } as any, + supportedVersions + ); + + expect(result.supported).toBe(false); + }); }); describe('Cache and Retry Integration', () => { diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 9fdae6c7b8..6ce59564be 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -19,6 +19,7 @@ import { WEBVIEW_SERVER_UNIQUE_ID_UPDATED, WEBVIEW_SERVER_RELOADED, SUPPORTED_VERSION_DIALOG_DISMISS, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, } from '../../ui/actions'; import * as urls from '../../urls'; import type { Server } from '../common'; @@ -207,6 +208,40 @@ const getExpirationMessage = ({ return message; }; +const isVersionExceptionForServer = ( + exceptionVersion: string, + server: Server, + serverVersionTilde: string +): boolean => { + if (satisfies(coerce(exceptionVersion)?.version ?? '', serverVersionTilde)) { + return true; + } + + const trimmedExceptionVersion = exceptionVersion.trim(); + if (!trimmedExceptionVersion.toLowerCase().startsWith('sha-')) { + return false; + } + + const normalizedExceptionVersion = trimmedExceptionVersion + .replace(/^sha-/i, '') + .toLowerCase(); + if (!normalizedExceptionVersion) { + return false; + } + + const gitCommitHash = server.gitCommitHash?.trim(); + if (!gitCommitHash) { + return false; + } + + const normalizedGitCommitHash = gitCommitHash + .trim() + .replace(/^sha-/i, '') + .toLowerCase(); + + return normalizedGitCommitHash.startsWith(normalizedExceptionVersion); +}; + export const getExpirationMessageTranslated = ( i18n: Dictionary | undefined, message: Message, @@ -274,7 +309,7 @@ export const isServerVersionSupported = async ( if (!supportedVersionsData) return { supported: true }; const exception = exceptions?.versions?.find(({ version }) => - satisfies(coerce(version)?.version ?? '', serverVersionTilde) + isVersionExceptionForServer(version, server, serverVersionTilde) ); if (exception) { @@ -352,6 +387,16 @@ const dispatchVersionUpdated = (url: string) => (info: ServerInfo) => { }, }); + if (info.commit?.hash) { + dispatch({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { + url, + gitCommitHash: info.commit.hash, + }, + }); + } + return info; }; diff --git a/src/store/actions.ts b/src/store/actions.ts index d305999258..bb409e50fa 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -9,6 +9,7 @@ import type { OutlookCalendarActionTypeToPayloadMap } from '../outlookCalendar/a import type { ScreenSharingActionTypeToPayloadMap } from '../screenSharing/actions'; import type { ServersActionTypeToPayloadMap } from '../servers/actions'; import type { SpellCheckingActionTypeToPayloadMap } from '../spellChecking/actions'; +import type { TelephonyActionTypeToPayloadMap } from '../telephony/actions'; import type { UiActionTypeToPayloadMap } from '../ui/actions'; import type { UpdatesActionTypeToPayloadMap } from '../updates/actions'; import type { UserPresenceActionTypeToPayloadMap } from '../userPresence/actions'; @@ -26,7 +27,8 @@ type ActionTypeToPayloadMap = AppActionTypeToPayloadMap & UiActionTypeToPayloadMap & UpdatesActionTypeToPayloadMap & UserPresenceActionTypeToPayloadMap & - OutlookCalendarActionTypeToPayloadMap; + OutlookCalendarActionTypeToPayloadMap & + TelephonyActionTypeToPayloadMap; type RootActions = { [Type in keyof ActionTypeToPayloadMap]: void extends ActionTypeToPayloadMap[Type] diff --git a/src/store/index.ts b/src/store/index.ts index 29b74e15ed..ffa5d60968 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -91,9 +91,10 @@ export const watch = ( return; } - watcher(curr, prev); - + const previous = prev; prev = curr; + + watcher(curr, previous); }); }; diff --git a/src/store/ipc.ts b/src/store/ipc.ts index 9593e39dc8..6fe438ae11 100644 --- a/src/store/ipc.ts +++ b/src/store/ipc.ts @@ -39,7 +39,12 @@ export const forwardToRenderers: Middleware = (api: MiddlewareAPI) => { }); return (next) => (action) => { - if (!isFSA(action) || isLocallyScoped(action)) { + if (!isFSA(action)) { + return next(action); + } + + const locallyScoped = isLocallyScoped(action); + if (locallyScoped) { return next(action); } const rendererAction = { @@ -51,15 +56,13 @@ export const forwardToRenderers: Middleware = (api: MiddlewareAPI) => { }; if (isSingleScoped(action)) { const { webContentsId, viewInstanceId } = action.ipcMeta; - [...renderers] - .filter( - (w) => - w.id === webContentsId || - (viewInstanceId && w.id === viewInstanceId) - ) - .forEach((w) => - invokeFromMain(w, 'redux/action-dispatched', rendererAction) - ); + const targets = [...renderers].filter( + (w) => + w.id === webContentsId || (viewInstanceId && w.id === viewInstanceId) + ); + targets.forEach((w) => + invokeFromMain(w, 'redux/action-dispatched', rendererAction) + ); return next(action); } renderers.forEach((webContents) => { diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index a47f109ee6..323010043e 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -18,6 +18,11 @@ import { allowInsecureOutlookConnections } from '../outlookCalendar/reducers/all import { outlookCalendarSyncInterval } from '../outlookCalendar/reducers/outlookCalendarSyncInterval'; import { outlookCalendarSyncIntervalOverride } from '../outlookCalendar/reducers/outlookCalendarSyncIntervalOverride'; import { servers } from '../servers/reducers'; +import { + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, + telephonyPreferredServer, +} from '../telephony/reducers'; import { availableBrowsers } from '../ui/reducers/availableBrowsers'; import { currentView } from '../ui/reducers/currentView'; import { dialogs } from '../ui/reducers/dialogs'; @@ -36,6 +41,7 @@ import { isNTLMCredentialsEnabled } from '../ui/reducers/isNTLMCredentialsEnable import { isReportEnabled } from '../ui/reducers/isReportEnabled'; import { isShowWindowOnUnreadChangedEnabled } from '../ui/reducers/isShowWindowOnUnreadChangedEnabled'; import { isSideBarEnabled } from '../ui/reducers/isSideBarEnabled'; +import { isTelephonyEnabled } from '../ui/reducers/isTelephonyEnabled'; import { isTransparentWindowEnabled } from '../ui/reducers/isTransparentWindowEnabled'; import { isTrayIconEnabled } from '../ui/reducers/isTrayIconEnabled'; import { isVerboseOutlookLoggingEnabled } from '../ui/reducers/isVerboseOutlookLoggingEnabled'; @@ -118,6 +124,10 @@ export const rootReducer = combineReducers({ isVideoCallDevtoolsAutoOpenEnabled, isTransparentWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, + telephonyPreferredServer, + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, + isTelephonyEnabled, }); export type RootState = ReturnType; diff --git a/src/telephony/__tests__/defaultAssociationsXml.spec.ts b/src/telephony/__tests__/defaultAssociationsXml.spec.ts new file mode 100644 index 0000000000..dc96575ebf --- /dev/null +++ b/src/telephony/__tests__/defaultAssociationsXml.spec.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = join(__dirname, '..', '..', '..'); +const xmlPath = join(repoRoot, 'build', 'RocketChatDefaultAppAssociations.xml'); +const nshPath = join(repoRoot, 'build', 'installer.nsh'); +const diagnosticsPath = join(repoRoot, 'src', 'telephony', 'diagnostics.ts'); +const electronBuilderPath = join(repoRoot, 'electron-builder.json'); + +describe('RocketChatDefaultAppAssociations.xml', () => { + const xml = readFileSync(xmlPath, 'utf8'); + const nsh = readFileSync(nshPath, 'utf8'); + const diagnostics = readFileSync(diagnosticsPath, 'utf8'); + const electronBuilder = readFileSync(electronBuilderPath, 'utf8'); + + it('declares the tel association with the RocketChat.tel ProgId', () => { + expect(xml).toMatch( + /Identifier="tel"[^/>]*ProgId="RocketChat\.tel"|ProgId="RocketChat\.tel"[^/>]*Identifier="tel"/ + ); + }); + + it('declares the callto association with the RocketChat.callto ProgId', () => { + expect(xml).toMatch( + /Identifier="callto"[^/>]*ProgId="RocketChat\.callto"|ProgId="RocketChat\.callto"[^/>]*Identifier="callto"/ + ); + }); + + it('uses the same ProgIds the NSIS installer registers', () => { + expect(nsh).toContain('RocketChat.tel'); + expect(nsh).toContain('RocketChat.callto'); + }); + + it('stays aligned with diagnostics checks for registration and per-scheme verification', () => { + expect(diagnostics).toContain('windows.registeredApp'); + expect(diagnostics).toContain('windows.capabilities.'); + expect(diagnostics).toContain('windows.progid.'); + }); + + it('is included in packaged extraResources so MSI policy path resolves under resources\\', () => { + const config = JSON.parse(electronBuilder) as { + extraResources?: Array; + }; + expect(config.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: 'build/RocketChatDefaultAppAssociations.xml', + to: 'RocketChatDefaultAppAssociations.xml', + }), + ]) + ); + }); +}); diff --git a/src/telephony/__tests__/diagnostics.spec.ts b/src/telephony/__tests__/diagnostics.spec.ts new file mode 100644 index 0000000000..1e2ea8d57c --- /dev/null +++ b/src/telephony/__tests__/diagnostics.spec.ts @@ -0,0 +1,613 @@ +import { app } from 'electron'; + +import { getTelephonyDiagnostics } from '../diagnostics'; + +// child_process.execFile is mocked as a jest.fn() that returns a Promise +// (util.promisify is mocked to return the function unchanged, so the module +// under test calls it directly as a promise-returning function). +jest.mock('child_process', () => ({ + execFile: jest.fn(), +})); + +jest.mock('fs/promises', () => ({ + readFile: jest.fn(), +})); + +jest.mock('util', () => ({ + promisify: (fn: unknown) => fn, +})); + +jest.mock('electron', () => ({ + app: { + isDefaultProtocolClient: jest.fn(), + getApplicationInfoForProtocol: jest.fn< + Promise<{ name: string; path: string; icon: null }>, + [string] + >(), + getName: jest.fn(() => 'Rocket.Chat'), + }, +})); + +const appMock = app as jest.Mocked; + +// Retrieve the mock after jest.mock() factories have run +const getExecFileMock = () => + (jest.requireMock('child_process') as { execFile: jest.Mock }).execFile; + +const getReadFileMock = () => + (jest.requireMock('fs/promises') as { readFile: jest.Mock }).readFile; + +const REG_OUTPUT_REGISTERED_APP = + '\n Rocket.Chat REG_SZ Software\\Rocket.Chat\\Capabilities\n'; +const REG_OUTPUT_CAPABILITY_TEL = '\n tel REG_SZ RocketChat.tel\n'; +const REG_OUTPUT_PROGID_TEL = + '\n (Default) REG_SZ "C:\\Program Files\\Rocket.Chat\\Rocket.Chat.exe" -- "%1"\n'; + +const setPlatform = (platform: NodeJS.Platform): (() => void) => { + const original = process.platform; + Object.defineProperty(process, 'platform', { + value: platform, + writable: true, + configurable: true, + }); + return () => + Object.defineProperty(process, 'platform', { + value: original, + writable: true, + configurable: true, + }); +}; + +// --------------------------------------------------------------------------- +// All-platforms: isDefault checks +// --------------------------------------------------------------------------- + +describe('getTelephonyDiagnostics — isDefault checks (all platforms)', () => { + let restorePlatform: () => void; + + afterEach(() => { + restorePlatform?.(); + jest.clearAllMocks(); + }); + + it('returns pass for tel and callto when isDefaultProtocolClient returns true', async () => { + restorePlatform = setPlatform('linux'); + appMock.isDefaultProtocolClient.mockReturnValue(true); + getExecFileMock().mockRejectedValue(new Error('no xdg-mime')); + + const result = await getTelephonyDiagnostics(); + + expect(result.checks.find((c) => c.id === 'isDefault.tel')?.status).toBe( + 'pass' + ); + expect(result.checks.find((c) => c.id === 'isDefault.callto')?.status).toBe( + 'pass' + ); + expect(result.checks.find((c) => c.id === 'isDefault.sip')).toBeUndefined(); + }); + + it('returns fail when isDefaultProtocolClient returns false', async () => { + restorePlatform = setPlatform('darwin'); + appMock.isDefaultProtocolClient.mockReturnValue(false); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockRejectedValue( + new Error('no handler') + ); + + const result = await getTelephonyDiagnostics(); + + expect(result.checks.find((c) => c.id === 'isDefault.tel')?.status).toBe( + 'fail' + ); + }); + + it('returns unknown with details when isDefaultProtocolClient throws', async () => { + restorePlatform = setPlatform('darwin'); + appMock.isDefaultProtocolClient.mockImplementation(() => { + throw new Error('registry locked'); + }); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockRejectedValue( + new Error('no handler') + ); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('unknown'); + expect(check?.details).toContain('registry locked'); + }); +}); + +// --------------------------------------------------------------------------- +// Windows checks +// --------------------------------------------------------------------------- + +describe('getTelephonyDiagnostics — Windows platform checks', () => { + let restorePlatform: () => void; + + beforeEach(() => { + restorePlatform = setPlatform('win32'); + appMock.isDefaultProtocolClient.mockReturnValue(false); + }); + + afterEach(() => { + restorePlatform(); + jest.clearAllMocks(); + }); + + it('windows.registeredApp passes when HKCU has the expected value', async () => { + getExecFileMock().mockResolvedValue({ + stdout: REG_OUTPUT_REGISTERED_APP, + stderr: '', + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.registeredApp'); + expect(check?.status).toBe('pass'); + }); + + it('windows.registeredApp fails when both HKCU and HKLM are missing', async () => { + getExecFileMock().mockRejectedValue(new Error('registry key not found')); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.registeredApp'); + expect(check?.status).toBe('fail'); + }); + + it('windows.registeredApp passes when HKCU misses but HKLM hits', async () => { + getExecFileMock() + .mockRejectedValueOnce(new Error('not found')) // HKCU miss + .mockResolvedValue({ stdout: REG_OUTPUT_REGISTERED_APP, stderr: '' }); // HKLM hit + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.registeredApp'); + expect(check?.status).toBe('pass'); + }); + + it('windows.capabilities.tel passes with correct ProgID value', async () => { + getExecFileMock().mockResolvedValue({ + stdout: REG_OUTPUT_CAPABILITY_TEL, + stderr: '', + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find( + (c) => c.id === 'windows.capabilities.tel' + ); + expect(check?.status).toBe('pass'); + }); + + it('windows.progid.tel passes when default value contains Rocket.Chat.exe', async () => { + getExecFileMock().mockResolvedValue({ + stdout: REG_OUTPUT_PROGID_TEL, + stderr: '', + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.progid.tel'); + expect(check?.status).toBe('pass'); + }); + + it('windows.progid.tel fails when command does not contain Rocket.Chat.exe', async () => { + getExecFileMock().mockResolvedValue({ + stdout: + '\n (Default) REG_SZ "C:\\Program Files\\Teams\\Teams.exe" -- "%1"\n', + stderr: '', + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.progid.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toBeDefined(); + }); + + it('returns fail when both hives are missing for a windows check', async () => { + getExecFileMock().mockRejectedValue(new Error('access denied')); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'windows.registeredApp'); + expect(check?.status).toBe('fail'); + expect(check?.details).toBeDefined(); + }); + + it('isDefault.tel passes when UserChoice ProgId equals RocketChat.tel', async () => { + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\tel\\UserChoice')) { + return Promise.resolve({ + stdout: '\n ProgId REG_SZ RocketChat.tel\n', + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('pass'); + }); + + it('isDefault.tel fails when UserChoice ProgId points to another handler', async () => { + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\tel\\UserChoice')) { + return Promise.resolve({ + stdout: '\n ProgId REG_SZ MSTeams.Url.tel\n', + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toContain('MSTeams.Url.tel'); + expect(check?.action).toBe('openDefaultAppsSettings'); + }); + + it('isDefault.tel passes when UserChoice is missing but UserChoiceLatest equals RocketChat.tel', async () => { + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\tel\\UserChoice')) { + return Promise.reject(new Error('not found')); + } + if (keyPath.endsWith('URLAssociations\\tel\\UserChoiceLatest\\ProgId')) { + return Promise.resolve({ + stdout: '\n ProgId REG_SZ RocketChat.tel\n', + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('pass'); + }); + + it('isDefault.tel fails when UserChoiceLatest points to another handler even if the protocol class launches Rocket.Chat', async () => { + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\tel\\UserChoice')) { + return Promise.reject(new Error('not found')); + } + if (keyPath.endsWith('URLAssociations\\tel\\UserChoiceLatest\\ProgId')) { + return Promise.resolve({ + stdout: '\n ProgId REG_SZ ChromeHTML\n', + stderr: '', + }); + } + if (keyPath.includes('Software\\Classes\\tel\\shell\\open\\command')) { + return Promise.resolve({ + stdout: REG_OUTPUT_PROGID_TEL, + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toContain('ChromeHTML'); + expect(check?.action).toBe('openDefaultAppsSettings'); + }); + + it('isDefault.tel passes when no user choice is set but the protocol class launches the current Electron executable', async () => { + const originalExecPath = process.execPath; + Object.defineProperty(process, 'execPath', { + value: + 'C:\\Users\\jean\\repo\\node_modules\\electron\\dist\\electron.exe', + writable: true, + configurable: true, + }); + + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\tel\\UserChoice')) { + return Promise.reject(new Error('not found')); + } + if (keyPath.endsWith('URLAssociations\\tel\\UserChoiceLatest\\ProgId')) { + return Promise.reject(new Error('not found')); + } + if (keyPath.includes('Software\\Classes\\tel\\shell\\open\\command')) { + return Promise.resolve({ + stdout: + '\n (Default) REG_SZ "C:\\Users\\jean\\repo\\node_modules\\electron\\dist\\electron.exe" "%1"\n', + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + let status; + try { + const result = await getTelephonyDiagnostics(); + status = result.checks.find((c) => c.id === 'isDefault.tel')?.status; + } finally { + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + writable: true, + configurable: true, + }); + } + + expect(status).toBe('pass'); + }); + + it('isDefault.callto passes when no user choice is set but the protocol class launches Rocket.Chat.exe', async () => { + getExecFileMock().mockImplementation((_cmd: string, args: string[]) => { + const keyPath: string = args[1] ?? ''; + if (keyPath.endsWith('URLAssociations\\callto\\UserChoice')) { + return Promise.reject(new Error('not found')); + } + if ( + keyPath.endsWith('URLAssociations\\callto\\UserChoiceLatest\\ProgId') + ) { + return Promise.reject(new Error('not found')); + } + if (keyPath.includes('Software\\Classes\\callto\\shell\\open\\command')) { + return Promise.resolve({ + stdout: + '\n (Default) REG_SZ "C:\\Program Files\\Rocket.Chat\\Rocket.Chat.exe" "%1"\n', + stderr: '', + }); + } + return Promise.reject(new Error('not found')); + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.callto'); + expect(check?.status).toBe('pass'); + }); + + it('isDefault.tel fails when no UserChoice ProgId is set', async () => { + getExecFileMock().mockRejectedValue(new Error('not found')); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'isDefault.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toContain('default apps'); + expect(check?.action).toBe('openDefaultAppsSettings'); + }); +}); + +// --------------------------------------------------------------------------- +// macOS checks +// --------------------------------------------------------------------------- + +describe('getTelephonyDiagnostics — macOS platform checks', () => { + let restorePlatform: () => void; + const originalExecPath = process.execPath; + + const setExecPath = (value: string): void => { + Object.defineProperty(process, 'execPath', { + value, + writable: true, + configurable: true, + }); + }; + + beforeEach(() => { + restorePlatform = setPlatform('darwin'); + appMock.isDefaultProtocolClient.mockReturnValue(false); + }); + + afterEach(() => { + restorePlatform(); + setExecPath(originalExecPath); + jest.clearAllMocks(); + }); + + it('darwin.handler.tel passes when process.execPath lives inside the handler bundle (packaged)', async () => { + setExecPath('/Applications/Rocket.Chat.app/Contents/MacOS/Rocket.Chat'); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockResolvedValue({ + name: 'Rocket.Chat', + path: '/Applications/Rocket.Chat.app', + icon: null, + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'darwin.handler.tel'); + expect(check?.status).toBe('pass'); + expect(check?.details).toContain('Rocket.Chat'); + }); + + it('darwin.handler.tel passes in dev across sibling worktrees (basename match)', async () => { + setExecPath( + '/Users/jean/repo-a/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron' + ); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockResolvedValue({ + name: 'Electron.app', + path: '/Users/jean/repo-b/node_modules/electron/dist/Electron.app', + icon: null, + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'darwin.handler.tel'); + expect(check?.status).toBe('pass'); + }); + + it('darwin.handler.tel fails when handler bundle does not contain process.execPath', async () => { + setExecPath('/Applications/Rocket.Chat.app/Contents/MacOS/Rocket.Chat'); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockResolvedValue({ + name: 'FaceTime', + path: '/System/Applications/FaceTime.app', + icon: null, + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'darwin.handler.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toContain('FaceTime'); + }); + + it('darwin.handler.* returns unknown when getApplicationInfoForProtocol throws', async () => { + (appMock.getApplicationInfoForProtocol as jest.Mock).mockRejectedValue( + new Error('no handler registered') + ); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'darwin.handler.tel'); + expect(check?.status).toBe('unknown'); + expect(check?.details).toContain('no handler registered'); + }); +}); + +// --------------------------------------------------------------------------- +// Linux checks +// --------------------------------------------------------------------------- + +describe('getTelephonyDiagnostics — Linux platform checks', () => { + let restorePlatform: () => void; + + beforeEach(() => { + restorePlatform = setPlatform('linux'); + appMock.isDefaultProtocolClient.mockReturnValue(false); + }); + + afterEach(() => { + restorePlatform(); + jest.clearAllMocks(); + }); + + it('linux.xdg.tel passes when stdout contains "rocketchat"', async () => { + getExecFileMock().mockResolvedValue({ + stdout: 'rocketchat.desktop\n', + stderr: '', + }); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'linux.xdg.tel'); + expect(check?.status).toBe('pass'); + expect(check?.details).toBe('rocketchat.desktop'); + }); + + it('linux.xdg.tel passes when the default desktop file launches Rocket.Chat', async () => { + getExecFileMock().mockResolvedValue({ + stdout: 'chat.desktop\n', + stderr: '', + }); + getReadFileMock().mockResolvedValue( + '[Desktop Entry]\nExec=/opt/Rocket.Chat/rocket.chat %u\n' + ); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'linux.xdg.tel'); + expect(check?.status).toBe('pass'); + expect(check?.details).toContain('Exec=/opt/Rocket.Chat/rocket.chat %u'); + }); + + it('linux.xdg.tel passes in dev when the default desktop file launches the current executable', async () => { + const originalExecPath = process.execPath; + Object.defineProperty(process, 'execPath', { + value: '/home/jean/repo/node_modules/electron/dist/electron', + writable: true, + configurable: true, + }); + + getExecFileMock().mockResolvedValue({ + stdout: 'electron.desktop\n', + stderr: '', + }); + getReadFileMock().mockResolvedValue( + '[Desktop Entry]\nExec=/home/jean/repo/node_modules/electron/dist/electron %u\n' + ); + + let status; + try { + const result = await getTelephonyDiagnostics(); + status = result.checks.find((c) => c.id === 'linux.xdg.tel')?.status; + } finally { + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + writable: true, + configurable: true, + }); + } + + expect(status).toBe('pass'); + }); + + it('linux.xdg.tel fails when stdout is for a different app', async () => { + getExecFileMock().mockResolvedValue({ + stdout: 'facetime.desktop\n', + stderr: '', + }); + getReadFileMock().mockRejectedValue(new Error('not found')); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'linux.xdg.tel'); + expect(check?.status).toBe('fail'); + expect(check?.details).toBe('facetime.desktop'); + expect(check?.action).toBe('openDefaultAppsSettings'); + }); + + it('linux.xdg.* returns unknown when execFile rejects', async () => { + getExecFileMock().mockRejectedValue(new Error('xdg-mime not found')); + + const result = await getTelephonyDiagnostics(); + + const check = result.checks.find((c) => c.id === 'linux.xdg.tel'); + expect(check?.status).toBe('unknown'); + expect(check?.details).toContain('xdg-mime not found'); + }); +}); + +// --------------------------------------------------------------------------- +// Resilience +// --------------------------------------------------------------------------- + +describe('getTelephonyDiagnostics — resilience', () => { + let restorePlatform: () => void; + + afterEach(() => { + restorePlatform?.(); + jest.clearAllMocks(); + }); + + it('resolves and never throws even when every subcall errors', async () => { + restorePlatform = setPlatform('linux'); + appMock.isDefaultProtocolClient.mockImplementation(() => { + throw new Error('electron exploded'); + }); + getExecFileMock().mockRejectedValue(new Error('execFile exploded')); + + await expect(getTelephonyDiagnostics()).resolves.toBeDefined(); + }); + + it('returns an object with platform and generatedAt fields', async () => { + restorePlatform = setPlatform('darwin'); + appMock.isDefaultProtocolClient.mockReturnValue(false); + (appMock.getApplicationInfoForProtocol as jest.Mock).mockRejectedValue( + new Error('no handler') + ); + + const result = await getTelephonyDiagnostics(); + + expect(result.platform).toBe('darwin'); + expect(typeof result.generatedAt).toBe('string'); + expect(() => new Date(result.generatedAt)).not.toThrow(); + expect(Array.isArray(result.checks)).toBe(true); + }); +}); diff --git a/src/telephony/__tests__/dialpad.spec.ts b/src/telephony/__tests__/dialpad.spec.ts new file mode 100644 index 0000000000..df26fbf120 --- /dev/null +++ b/src/telephony/__tests__/dialpad.spec.ts @@ -0,0 +1,204 @@ +import { DEEP_LINKS_SERVER_FOCUSED } from '../../deepLinks/actions'; +import { dispatch, listen, select } from '../../store'; +import { + TELEPHONY_SERVER_SELECT_CLOSE, + TELEPHONY_SERVER_SELECT_OPEN, +} from '../../ui/actions'; +import { getWebContentsByServerUrl } from '../../ui/main/serverView'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../actions'; +import type { TelephonyLink } from '../common'; +import { openTelephonyDialpad } from '../dialpad'; + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), + listen: jest.fn(), + select: jest.fn(), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(), +})); + +const dispatchMock = dispatch as jest.MockedFunction; +const listenMock = listen as jest.MockedFunction; +const selectMock = select as jest.MockedFunction; +const getWebContentsByServerUrlMock = + getWebContentsByServerUrl as jest.MockedFunction< + typeof getWebContentsByServerUrl + >; + +const link: TelephonyLink = { + phoneNumber: '+15551234567', + rawUri: 'tel:+15551234567', +}; + +const mockState = (state: { + servers: { url: string }[]; + telephonyPreferredServer?: string | null; +}) => { + selectMock.mockImplementation((selector: any) => + selector({ + servers: state.servers, + telephonyPreferredServer: state.telephonyPreferredServer ?? null, + }) + ); +}; + +const stubWebContents = () => { + const send = jest.fn(); + getWebContentsByServerUrlMock.mockReturnValue({ send } as any); + return { send }; +}; + +const findDispatchCall = (type: string) => + dispatchMock.mock.calls.find( + ([action]) => (action as any).type === type + )?.[0]; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('openTelephonyDialpad', () => { + it('does nothing and does not focus a view when there are no servers', async () => { + mockState({ servers: [] }); + + await openTelephonyDialpad(link); + + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toBeUndefined(); + expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); + }); + + it('focuses the single server and forwards the call when only one exists', async () => { + const url = 'https://only.example.com'; + mockState({ servers: [{ url }] }); + const { send } = stubWebContents(); + + await openTelephonyDialpad(link); + + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toEqual({ + type: DEEP_LINKS_SERVER_FOCUSED, + payload: url, + }); + expect(send).toHaveBeenCalledWith('telephony/call-requested', { + phoneNumber: link.phoneNumber, + rawUri: link.rawUri, + }); + }); + + it('focuses the preferred server without opening the modal', async () => { + const preferred = 'https://b.example.com'; + mockState({ + servers: [{ url: 'https://a.example.com' }, { url: preferred }], + telephonyPreferredServer: preferred, + }); + const { send } = stubWebContents(); + + await openTelephonyDialpad(link); + + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toEqual({ + type: DEEP_LINKS_SERVER_FOCUSED, + payload: preferred, + }); + expect( + dispatchMock.mock.calls.some( + ([action]) => (action as any).type === TELEPHONY_SERVER_SELECT_OPEN + ) + ).toBe(false); + expect(send).toHaveBeenCalled(); + }); + + it('opens the modal and focuses the picked server after selection', async () => { + const picked = 'https://picked.example.com'; + mockState({ + servers: [{ url: 'https://a.example.com' }, { url: picked }], + telephonyPreferredServer: null, + }); + const { send } = stubWebContents(); + + let resolveSelection: ((payload: any) => void) | undefined; + listenMock.mockImplementation((type: any, cb: any) => { + if (type === TELEPHONY_SERVER_SELECT_CLOSE) { + resolveSelection = (payload: any) => + cb({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload }); + } + return jest.fn(); + }); + + const pending = openTelephonyDialpad(link); + + expect(resolveSelection).toBeDefined(); + resolveSelection!({ serverUrl: picked, rememberChoice: false }); + + await pending; + + expect(findDispatchCall(TELEPHONY_SERVER_SELECT_OPEN)).toBeDefined(); + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toEqual({ + type: DEEP_LINKS_SERVER_FOCUSED, + payload: picked, + }); + expect( + dispatchMock.mock.calls.some( + ([action]) => (action as any).type === TELEPHONY_PREFERRED_SERVER_SET + ) + ).toBe(false); + expect(send).toHaveBeenCalled(); + }); + + it('persists the preferred server when the user opted to remember', async () => { + const picked = 'https://remember.example.com'; + mockState({ + servers: [{ url: 'https://a.example.com' }, { url: picked }], + telephonyPreferredServer: null, + }); + stubWebContents(); + + let resolveSelection: ((payload: any) => void) | undefined; + listenMock.mockImplementation((type: any, cb: any) => { + if (type === TELEPHONY_SERVER_SELECT_CLOSE) { + resolveSelection = (payload: any) => + cb({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload }); + } + return jest.fn(); + }); + + const pending = openTelephonyDialpad(link); + resolveSelection!({ serverUrl: picked, rememberChoice: true }); + await pending; + + expect(findDispatchCall(TELEPHONY_PREFERRED_SERVER_SET)).toEqual({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: picked, + }); + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toEqual({ + type: DEEP_LINKS_SERVER_FOCUSED, + payload: picked, + }); + }); + + it('does not focus a view when the modal is cancelled', async () => { + mockState({ + servers: [ + { url: 'https://a.example.com' }, + { url: 'https://b.example.com' }, + ], + telephonyPreferredServer: null, + }); + stubWebContents(); + + let resolveSelection: ((payload: any) => void) | undefined; + listenMock.mockImplementation((type: any, cb: any) => { + if (type === TELEPHONY_SERVER_SELECT_CLOSE) { + resolveSelection = (payload: any) => + cb({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload }); + } + return jest.fn(); + }); + + const pending = openTelephonyDialpad(link); + resolveSelection!(null); + await pending; + + expect(findDispatchCall(DEEP_LINKS_SERVER_FOCUSED)).toBeUndefined(); + }); +}); diff --git a/src/telephony/__tests__/msiInjection.spec.ts b/src/telephony/__tests__/msiInjection.spec.ts new file mode 100644 index 0000000000..0bae79f60f --- /dev/null +++ b/src/telephony/__tests__/msiInjection.spec.ts @@ -0,0 +1,161 @@ +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Bridge the build hook (CommonJS) into Jest's TS context. +const buildHook = jest.requireActual<{ + default: (wxsPath: string) => Promise; +}>('../../../build/msiProjectCreated.js'); +const hook = buildHook.default; + +const SAMPLE_WXS = ` + + + + + + +`; + +describe('msiProjectCreated default-associations injection', () => { + let workDir: string; + let wxsPath: string; + let injected: string; + + beforeAll(async () => { + workDir = mkdtempSync(join(tmpdir(), 'msi-inject-')); + wxsPath = join(workDir, 'test.wxs'); + writeFileSync(wxsPath, SAMPLE_WXS, 'utf8'); + await hook(wxsPath); + injected = readFileSync(wxsPath, 'utf8'); + }); + + afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + it('declares SET_DEFAULT_ASSOCIATIONS as a secure public property', () => { + expect(injected).toContain( + '' + ); + }); + + it('points the install CA at the GPO-equivalent policy key', () => { + expect(injected).toContain( + 'HKLM\\SOFTWARE\\Policies\\Microsoft\\Windows\\System\\DefaultAssociationsConfiguration' + ); + }); + + it('writes a sentinel under Rocket.Chat\\InstallState so uninstall knows what it owns', () => { + expect(injected).toContain( + 'HKLM\\SOFTWARE\\Rocket.Chat\\InstallState\\WroteDefaultAssociationsPolicy' + ); + }); + + it('points the install CA at the bundled XML under resources\\', () => { + expect(injected).toContain( + 'resources\\RocketChatDefaultAppAssociations.xml' + ); + }); + + it('renders single backslashes in VBScript registry paths (no double-escape regression)', () => { + // After JS template literal expansion the .wxs MUST contain single + // backslashes that VBScript will parse as literal path separators. + // A double-backslash would mean we mistakenly escaped twice. + expect(injected).not.toContain('HKLM\\\\SOFTWARE'); + expect(injected).not.toContain('resources\\\\RocketChat'); + }); + + it('schedules the install pair conditioned on the property + clean install', () => { + expect(injected).toMatch( + /]*>SET_DEFAULT_ASSOCIATIONS = "1" AND NOT Installed AND NOT REMOVE~="ALL"<\/Custom>/ + ); + expect(injected).toMatch( + /]*>SET_DEFAULT_ASSOCIATIONS = "1" AND NOT Installed AND NOT REMOVE~="ALL"<\/Custom>/ + ); + }); + + it('schedules the uninstall pair to skip major-upgrade RemoveExistingProducts', () => { + expect(injected).toMatch( + /]*>REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""<\/Custom>/ + ); + expect(injected).toMatch( + /]*>REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""<\/Custom>/ + ); + }); + + it('puts CustomAction + Property definitions as direct children of , not inside InstallExecuteSequence', () => { + const productInner = injected.match(/]*>([\s\S]*)<\/Product>/); + expect(productInner).not.toBeNull(); + const productBody = productInner![1]; + + const seqMatch = productBody.match( + /([\s\S]*?)<\/InstallExecuteSequence>/ + ); + expect(seqMatch).not.toBeNull(); + const seqBody = seqMatch![1]; + + expect(seqBody).not.toContain(' { + expect(injected).toMatch( + /Id="WriteDefaultAssociationsPolicy"[\s\S]{0,500}Execute="deferred"[\s\S]{0,500}Impersonate="no"/ + ); + expect(injected).toMatch( + /Id="CleanupDefaultAssociationsPolicy"[\s\S]{0,500}Execute="deferred"[\s\S]{0,500}Impersonate="no"/ + ); + }); + + it('uses Property="" type-51 setter to populate CustomActionData', () => { + expect(injected).toMatch( + /]*Property="WriteDefaultAssociationsPolicy"/ + ); + expect(injected).toMatch( + /]*Property="CleanupDefaultAssociationsPolicy"/ + ); + }); + + it('preserves the pre-existing DISABLE_AUTO_UPDATES injection', () => { + expect(injected).toContain('DISABLE_AUTO_UPDATES'); + expect(injected).toContain('WriteUpdateJson'); + }); + + it('registers telephony capabilities/ProgIds and RegisteredApplications for MSI installs', () => { + expect(injected).toContain('WriteTelephonyCapabilities'); + expect(injected).toContain( + 'HKLM\\SOFTWARE\\RegisteredApplications\\Rocket.Chat' + ); + expect(injected).toContain( + 'HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\URLAssociations\\tel' + ); + expect(injected).toContain( + 'HKLM\\SOFTWARE\\Rocket.Chat\\Capabilities\\URLAssociations\\callto' + ); + expect(injected).toContain('HKLM\\SOFTWARE\\Classes\\RocketChat.tel'); + expect(injected).toContain('HKLM\\SOFTWARE\\Classes\\RocketChat.callto'); + }); + + it('schedules telephony registration independent of SET_DEFAULT_ASSOCIATIONS', () => { + expect(injected).toMatch( + /]*>NOT REMOVE~="ALL"<\/Custom>/ + ); + expect(injected).toMatch( + /]*>NOT REMOVE~="ALL"<\/Custom>/ + ); + }); + + it('schedules telephony cleanup to skip major-upgrade RemoveExistingProducts', () => { + expect(injected).toMatch( + /]*>REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""<\/Custom>/ + ); + expect(injected).toMatch( + /]*>REMOVE~="ALL" AND UPGRADINGPRODUCTCODE=""<\/Custom>/ + ); + }); +}); diff --git a/src/telephony/acceleratorDisplay.ts b/src/telephony/acceleratorDisplay.ts new file mode 100644 index 0000000000..2e01f4fd47 --- /dev/null +++ b/src/telephony/acceleratorDisplay.ts @@ -0,0 +1,50 @@ +const MODIFIER_LABELS: Record = { + command: 'Cmd', + cmd: 'Cmd', + control: 'Ctrl', + ctrl: 'Ctrl', + shift: 'Shift', + alt: 'Alt', + option: 'Option', + meta: 'Meta', + super: 'Super', + altgr: 'AltGr', +}; + +const MAC_LABEL_OVERRIDES: Record = { + commandorcontrol: 'Cmd', + meta: 'Cmd', + super: 'Cmd', + alt: 'Option', +}; + +const NON_MAC_LABEL_OVERRIDES: Record = { + commandorcontrol: 'Ctrl', +}; + +type FormatOptions = { + platform?: NodeJS.Platform; +}; + +export const formatAcceleratorForDisplay = ( + accelerator: string | null | undefined, + { platform = process.platform }: FormatOptions = {} +): string => { + if (!accelerator) return ''; + + const isMac = platform === 'darwin'; + const overrides = isMac ? MAC_LABEL_OVERRIDES : NON_MAC_LABEL_OVERRIDES; + + const parts = accelerator + .split('+') + .map((raw) => raw.trim()) + .filter(Boolean) + .map((part) => { + const lower = part.toLowerCase(); + if (overrides[lower]) return overrides[lower]; + if (MODIFIER_LABELS[lower]) return MODIFIER_LABELS[lower]; + return part.length === 1 ? part.toUpperCase() : part; + }); + + return parts.join('+'); +}; diff --git a/src/telephony/actions.ts b/src/telephony/actions.ts new file mode 100644 index 0000000000..5e0d5677b2 --- /dev/null +++ b/src/telephony/actions.ts @@ -0,0 +1,22 @@ +export const TELEPHONY_PREFERRED_SERVER_SET = 'telephony/preferred-server-set'; +export const TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET = + 'telephony/global-shortcut-config-set'; +export const TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED = + 'telephony/global-shortcut-registration-changed'; + +export type TelephonyGlobalShortcutConfig = { + enabled: boolean; + accelerator: string | null; +}; + +export type TelephonyGlobalShortcutRegistrationStatus = { + registered: boolean; + accelerator: string | null; + error: string | null; +}; + +export type TelephonyActionTypeToPayloadMap = { + [TELEPHONY_PREFERRED_SERVER_SET]: string | null; + [TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET]: TelephonyGlobalShortcutConfig; + [TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED]: TelephonyGlobalShortcutRegistrationStatus; +}; diff --git a/src/telephony/common.ts b/src/telephony/common.ts new file mode 100644 index 0000000000..c2b13586e0 --- /dev/null +++ b/src/telephony/common.ts @@ -0,0 +1,4 @@ +export type TelephonyLink = { + phoneNumber: string; + rawUri: string; +}; diff --git a/src/telephony/diagnostics.ts b/src/telephony/diagnostics.ts new file mode 100644 index 0000000000..75cf64b56b --- /dev/null +++ b/src/telephony/diagnostics.ts @@ -0,0 +1,458 @@ +import { execFile as execFileCb } from 'child_process'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import path from 'path'; +import { promisify } from 'util'; + +import { app } from 'electron'; + +const execFile = promisify(execFileCb); + +export type TelephonyDiagnosticStatus = 'pass' | 'fail' | 'unknown'; +export type TelephonyDiagnosticAction = 'openDefaultAppsSettings'; + +export type TelephonyDiagnosticCheck = { + id: string; + label: string; + status: TelephonyDiagnosticStatus; + details?: string; + action?: TelephonyDiagnosticAction; +}; + +export type TelephonyDiagnostics = { + platform: NodeJS.Platform; + generatedAt: string; + checks: TelephonyDiagnosticCheck[]; +}; + +const SCHEMES = ['tel', 'callto'] as const; +const OPEN_DEFAULT_APPS_SETTINGS_ACTION: TelephonyDiagnosticAction = + 'openDefaultAppsSettings'; + +const commandLaunchesRocketChat = (command: string): boolean => { + const normalizedCommand = command.toLowerCase(); + return ( + normalizedCommand.includes(process.execPath.toLowerCase()) || + /(?:^|[\s"'])rocket\.chat(?:\.exe)?(?:$|[\s"'])/i.test( + command.replace(/\\/g, '/').split('/').pop() ?? command + ) + ); +}; + +const checkIsDefaultOnWindows = async ( + scheme: string +): Promise => { + const id = `isDefault.${scheme}`; + const label = `${scheme}: is set to Rocket.Chat`; + const expected = `RocketChat.${scheme}`; + try { + const progId = await queryWindowsUserChoiceProgId(scheme); + if (progId === null) { + const command = await queryWindowsProtocolCommand(scheme); + if (command !== null && commandLaunchesRocketChat(command)) { + return { + id, + label, + status: 'pass', + details: + 'Windows has no UserChoice ProgId, but the effective protocol command launches Rocket.Chat.', + }; + } + + return { + id, + label, + status: 'fail', + details: + 'Windows has not been told which app to use for this link. Open default apps and pick Rocket.Chat.', + action: OPEN_DEFAULT_APPS_SETTINGS_ACTION, + }; + } + + return { + id, + label, + status: progId === expected ? 'pass' : 'fail', + details: + progId === expected + ? undefined + : `Currently handled by another app (${progId}). Open default apps to switch to Rocket.Chat.`, + action: + progId === expected ? undefined : OPEN_DEFAULT_APPS_SETTINGS_ACTION, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +const checkIsDefault = async ( + scheme: string +): Promise => { + if (process.platform === 'win32') { + return checkIsDefaultOnWindows(scheme); + } + + const id = `isDefault.${scheme}`; + const label = `${scheme}: is set to Rocket.Chat`; + try { + const isDefault = app.isDefaultProtocolClient(scheme); + return { + id, + label, + status: isDefault ? 'pass' : 'fail', + action: + !isDefault && process.platform === 'linux' + ? OPEN_DEFAULT_APPS_SETTINGS_ACTION + : undefined, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +// --------------------------------------------------------------------------- +// Windows helpers +// --------------------------------------------------------------------------- + +const WIN_REG_SZ_RE = /REG_SZ\s+(.+)/; + +/** + * Query a registry value, falling back from HKCU to HKLM. + * Returns the trimmed value string or null if missing/error. + */ +const queryRegValue = async ( + keyPath: string, + valueName: string | null +): Promise => { + const hives = ['HKCU', 'HKLM'] as const; + const args = valueName === null ? ['/ve'] : ['/v', valueName]; + + for (const hive of hives) { + try { + // eslint-disable-next-line no-await-in-loop + const { stdout } = await execFile('reg', [ + 'query', + `${hive}\\${keyPath}`, + ...args, + ]); + const match = WIN_REG_SZ_RE.exec(stdout); + if (match) { + return match[1].trim(); + } + } catch { + // hive miss — try next + } + } + return null; +}; + +const queryWindowsUserChoiceProgId = async ( + scheme: string +): Promise => + (await queryRegValue( + `Software\\Microsoft\\Windows\\Shell\\Associations\\URLAssociations\\${scheme}\\UserChoice`, + 'ProgId' + )) ?? + queryRegValue( + `Software\\Microsoft\\Windows\\Shell\\Associations\\URLAssociations\\${scheme}\\UserChoiceLatest\\ProgId`, + 'ProgId' + ); + +const queryWindowsProtocolCommand = (scheme: string): Promise => + queryRegValue(`Software\\Classes\\${scheme}\\shell\\open\\command`, null); + +const checkWindowsRegisteredApp = + async (): Promise => { + const id = 'windows.registeredApp'; + const label = 'Windows: Rocket.Chat is in RegisteredApplications'; + const expected = 'Software\\Rocket.Chat\\Capabilities'; + try { + const value = await queryRegValue( + 'Software\\RegisteredApplications', + 'Rocket.Chat' + ); + if (value === null) { + return { + id, + label, + status: 'fail', + details: 'Key not found in HKCU or HKLM', + }; + } + return { + id, + label, + status: value === expected ? 'pass' : 'fail', + details: + value !== expected + ? `Expected "${expected}", got "${value}"` + : undefined, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } + }; + +const checkWindowsCapability = async ( + scheme: string, + expectedProgId: string +): Promise => { + const id = `windows.capabilities.${scheme}`; + const label = `Windows: Capabilities URLAssociation for ${scheme}`; + try { + const value = await queryRegValue( + `Software\\Rocket.Chat\\Capabilities\\URLAssociations`, + scheme + ); + if (value === null) { + return { + id, + label, + status: 'fail', + details: 'Key not found in HKCU or HKLM', + }; + } + return { + id, + label, + status: value === expectedProgId ? 'pass' : 'fail', + details: + value !== expectedProgId + ? `Expected "${expectedProgId}", got "${value}"` + : undefined, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +const checkWindowsProgId = async ( + scheme: string +): Promise => { + const progId = `RocketChat.${scheme}`; + const id = `windows.progid.${scheme}`; + const label = `Windows: ${progId} ProgID points at Rocket.Chat.exe`; + try { + const value = await queryRegValue( + `Software\\Classes\\${progId}\\shell\\open\\command`, + null + ); + if (value === null) { + return { + id, + label, + status: 'fail', + details: 'Key not found in HKCU or HKLM', + }; + } + const passes = commandLaunchesRocketChat(value); + return { + id, + label, + status: passes ? 'pass' : 'fail', + details: !passes ? `Command value: "${value}"` : undefined, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +const getWindowsChecks = async (): Promise => { + const capabilityChecks = await Promise.all( + SCHEMES.map((scheme) => { + const progId = `RocketChat.${scheme}`; + return checkWindowsCapability(scheme, progId); + }) + ); + + const progIdChecks = await Promise.all( + SCHEMES.map((scheme) => checkWindowsProgId(scheme)) + ); + + const registeredAppCheck = await checkWindowsRegisteredApp(); + + return [registeredAppCheck, ...capabilityChecks, ...progIdChecks]; +}; + +// --------------------------------------------------------------------------- +// macOS helpers +// --------------------------------------------------------------------------- + +const getBundleBasename = (pathLike: string): string | null => { + const match = pathLike.match(/\/([^/]+\.app)(?:\/|$)/); + return match?.[1] ?? null; +}; + +const checkDarwinHandler = async ( + scheme: string +): Promise => { + const id = `darwin.handler.${scheme}`; + const label = `macOS: ${scheme}:// handler reports Rocket.Chat`; + try { + const info = await app.getApplicationInfoForProtocol(`${scheme}:1`); + // Compare bundle basenames so the check holds in dev (Electron.app === + // Electron.app, even across worktrees / sibling Electron installs) and in + // packaged builds (Rocket.Chat.app === Rocket.Chat.app). The intent is + // "the registered handler IS the same bundle as the currently running app". + const ourBundle = getBundleBasename(process.execPath); + const theirBundle = getBundleBasename(info.path); + const passes = + ourBundle !== null && theirBundle !== null && ourBundle === theirBundle; + return { + id, + label, + status: passes ? 'pass' : 'fail', + details: `Handler: "${info.name}" at ${info.path}`, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +const getDarwinChecks = (): Promise => + Promise.all(SCHEMES.map((scheme) => checkDarwinHandler(scheme))); + +// --------------------------------------------------------------------------- +// Linux helpers +// --------------------------------------------------------------------------- + +const checkLinuxXdg = async ( + scheme: string +): Promise => { + const id = `linux.xdg.${scheme}`; + const label = `Linux: xdg-mime default for ${scheme} is Rocket.Chat`; + try { + const { stdout } = await execFile('xdg-mime', [ + 'query', + 'default', + `x-scheme-handler/${scheme}`, + ]); + const trimmed = stdout.trim(); + const desktopIdLooksRocketChat = trimmed.toLowerCase().includes('rocket'); + const desktopExec = desktopIdLooksRocketChat + ? null + : await readLinuxDesktopExec(trimmed); + const passes = + desktopIdLooksRocketChat || + (desktopExec !== null && commandLaunchesRocketChat(desktopExec)); + return { + id, + label, + status: passes ? 'pass' : 'fail', + details: + desktopExec !== null + ? `${trimmed} Exec=${desktopExec}` + : trimmed || undefined, + action: passes ? undefined : OPEN_DEFAULT_APPS_SETTINGS_ACTION, + }; + } catch (err) { + return { + id, + label, + status: 'unknown', + details: err instanceof Error ? err.message : String(err), + }; + } +}; + +const getLinuxDesktopSearchDirs = (): string[] => { + const dataHome = + process.env.XDG_DATA_HOME || path.join(homedir(), '.local', 'share'); + const dataDirs = ( + process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share' + ).split(':'); + + return [dataHome, ...dataDirs].map((dir) => path.join(dir, 'applications')); +}; + +const readLinuxDesktopExec = async ( + desktopId: string +): Promise => { + if (!desktopId) { + return null; + } + + const candidates = path.isAbsolute(desktopId) + ? [desktopId] + : getLinuxDesktopSearchDirs().map((dir) => path.join(dir, desktopId)); + + for (const candidate of candidates) { + try { + // eslint-disable-next-line no-await-in-loop + const content = await readFile(candidate, 'utf8'); + const execLine = content + .split(/\r?\n/) + .find((line) => line.startsWith('Exec=')); + if (execLine) { + return execLine.slice('Exec='.length).trim(); + } + } catch { + // Try next XDG applications directory. + } + } + + return null; +}; + +const getLinuxChecks = (): Promise => + Promise.all(SCHEMES.map((scheme) => checkLinuxXdg(scheme))); + +// --------------------------------------------------------------------------- +// Main export +// --------------------------------------------------------------------------- + +export const getTelephonyDiagnostics = + async (): Promise => { + const isDefaultChecks = await Promise.all( + SCHEMES.map((scheme) => checkIsDefault(scheme)) + ); + + let platformChecks: TelephonyDiagnosticCheck[] = []; + try { + if (process.platform === 'win32') { + platformChecks = await getWindowsChecks(); + } else if (process.platform === 'darwin') { + platformChecks = await getDarwinChecks(); + } else if (process.platform === 'linux') { + platformChecks = await getLinuxChecks(); + } + } catch { + // Platform checks failed wholesale — already handled per-check; ignore here + } + + return { + platform: process.platform, + generatedAt: new Date().toISOString(), + checks: [...isDefaultChecks, ...platformChecks], + }; + }; diff --git a/src/telephony/dialpad.ts b/src/telephony/dialpad.ts new file mode 100644 index 0000000000..934ad02058 --- /dev/null +++ b/src/telephony/dialpad.ts @@ -0,0 +1,135 @@ +import type { WebContents } from 'electron'; + +import { DEEP_LINKS_SERVER_FOCUSED } from '../deepLinks/actions'; +import { select, dispatch, listen } from '../store'; +import { + TELEPHONY_SERVER_SELECT_OPEN, + TELEPHONY_SERVER_SELECT_CLOSE, +} from '../ui/actions'; +import { getWebContentsByServerUrl } from '../ui/main/serverView'; +import { TELEPHONY_PREFERRED_SERVER_SET } from './actions'; +import type { TelephonyLink } from './common'; + +const MODAL_TIMEOUT_MS = 120_000; +const WEB_CONTENTS_TIMEOUT_MS = 10_000; + +let telephonyDialpadOpenInProgress = false; + +const getTelephonyWebContents = ( + serverUrl: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + + const poll = (): void => { + const webContents = getWebContentsByServerUrl(serverUrl); + if (webContents) { + resolve(webContents); + return; + } + + if (Date.now() >= deadline) { + resolve(null); + return; + } + + setTimeout(poll, 100); + }; + + poll(); + }); + +const selectTelephonyServerUrl = async ( + link: TelephonyLink +): Promise => { + const servers = select(({ servers }) => servers); + + if (servers.length === 0) { + return null; + } + + if (servers.length === 1) { + return servers[0].url; + } + + const preferredServer = select( + ({ telephonyPreferredServer }) => telephonyPreferredServer + ); + + if ( + preferredServer && + servers.some((server) => server.url === preferredServer) + ) { + return preferredServer; + } + + const result = await new Promise<{ + serverUrl: string; + rememberChoice: boolean; + } | null>((resolve) => { + const timeout = setTimeout(() => { + unsubscribe(); + dispatch({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload: null }); + resolve(null); + }, MODAL_TIMEOUT_MS); + + const unsubscribe = listen(TELEPHONY_SERVER_SELECT_CLOSE, (action) => { + clearTimeout(timeout); + unsubscribe(); + resolve(action.payload); + }); + + dispatch({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: link.phoneNumber, rawUri: link.rawUri }, + }); + }); + + if (!result) { + return null; + } + + if (result.rememberChoice) { + dispatch({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: result.serverUrl, + }); + } + + return result.serverUrl; +}; + +export const openTelephonyDialpad = async ( + link: TelephonyLink +): Promise => { + if (telephonyDialpadOpenInProgress) { + return; + } + + telephonyDialpadOpenInProgress = true; + + try { + const serverUrl = await selectTelephonyServerUrl(link); + if (!serverUrl) { + return; + } + + dispatch({ type: DEEP_LINKS_SERVER_FOCUSED, payload: serverUrl }); + + const webContents = await getTelephonyWebContents( + serverUrl, + WEB_CONTENTS_TIMEOUT_MS + ); + if (!webContents) { + return; + } + + webContents.send('telephony/call-requested', { + phoneNumber: link.phoneNumber, + rawUri: link.rawUri, + }); + } finally { + telephonyDialpadOpenInProgress = false; + } +}; diff --git a/src/telephony/ipc.ts b/src/telephony/ipc.ts new file mode 100644 index 0000000000..146adffc42 --- /dev/null +++ b/src/telephony/ipc.ts @@ -0,0 +1,6 @@ +import { handle } from '../ipc/main'; +import { getTelephonyDiagnostics } from './diagnostics'; + +export const setupTelephonyIpc = (): void => { + handle('telephony/get-diagnostics', async () => getTelephonyDiagnostics()); +}; diff --git a/src/telephony/links.ts b/src/telephony/links.ts new file mode 100644 index 0000000000..7343ba5076 --- /dev/null +++ b/src/telephony/links.ts @@ -0,0 +1,41 @@ +import type { TelephonyLink } from './common'; + +const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; + +const getTelephonyTarget = (url: URL): string => + url.host || + url.pathname || + url.href.slice(url.protocol.length).split(/[?#]/)[0]; + +export const parseTelephonyLink = (input: string): TelephonyLink | null => { + if (/^--/.test(input)) { + return null; + } + + let url: URL; + + try { + url = new URL(input); + } catch { + return null; + } + + if (!TELEPHONY_PROTOCOLS.includes(url.protocol)) { + return null; + } + + let raw: string; + try { + raw = decodeURIComponent(getTelephonyTarget(url)); + } catch { + return null; + } + + const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); + + if (!phoneNumber) { + return null; + } + + return { phoneNumber, rawUri: input }; +}; diff --git a/src/telephony/main.spec.ts b/src/telephony/main.spec.ts new file mode 100644 index 0000000000..8e2d611e33 --- /dev/null +++ b/src/telephony/main.spec.ts @@ -0,0 +1,931 @@ +import { spawn } from 'child_process'; + +import { app, clipboard, globalShortcut, Notification, shell } from 'electron'; + +import { APP_SETTINGS_LOADED } from '../app/actions'; +import { dispatch, listen, select, watch } from '../store'; +import { SIDE_BAR_SETTINGS_BUTTON_CLICKED } from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { + TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, +} from './actions'; +import type { openTelephonyDialpad } from './dialpad'; +import { parseTelephonyLink } from './links'; +import { + createTelephonyLinkFromClipboardText, + registerTelephonyGlobalShortcut, + setupTelephonyDefaultHandlerPrompt, + setupTelephonyGlobalShortcut, + setupTelephonyProtocolHandlers, + teardownTelephonyDefaultHandlerPrompt, + teardownTelephonyGlobalShortcut, + teardownTelephonyProtocolHandlers, + triggerTelephonyGlobalShortcut, +} from './main'; +import { + defaultTelephonyGlobalShortcutConfig, + defaultTelephonyGlobalShortcutRegistrationStatus, + telephonyGlobalShortcutConfig, + telephonyGlobalShortcutRegistrationStatus, + telephonyPreferredServer, +} from './reducers'; + +jest.mock('electron', () => { + const NotificationMock = jest.fn(() => ({ + addListener: jest.fn(), + show: jest.fn(), + })); + + return { + app: { + addListener: jest.fn(), + removeListener: jest.fn(), + setAsDefaultProtocolClient: jest.fn(() => true), + removeAsDefaultProtocolClient: jest.fn(() => true), + }, + clipboard: { + readText: jest.fn(), + }, + globalShortcut: { + isRegistered: jest.fn(() => false), + register: jest.fn(), + unregister: jest.fn(), + }, + Notification: Object.assign(NotificationMock, { + isSupported: jest.fn(() => true), + }), + shell: { + openExternal: jest.fn(), + }, + }; +}); + +jest.mock('child_process', () => ({ + spawn: jest.fn(() => ({ on: jest.fn(), unref: jest.fn() })), +})); + +jest.mock('./dialpad', () => ({ + openTelephonyDialpad: jest.fn(() => Promise.resolve()), +})); + +jest.mock('./links', () => ({ + parseTelephonyLink: jest.fn(), +})); + +jest.mock('../logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('../store', () => ({ + dispatch: jest.fn(), + listen: jest.fn(), + select: jest.fn(), + watch: jest.fn(), +})); + +jest.mock('../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(), +})); + +const appMock = app as jest.Mocked; +const clipboardMock = clipboard as jest.Mocked; +const globalShortcutMock = globalShortcut as jest.Mocked; +const notificationMock = Notification as jest.Mocked; +const shellMock = shell as jest.Mocked; +const getOpenTelephonyDialpadMock = (): jest.MockedFunction< + typeof openTelephonyDialpad +> => { + const dialpad = jest.requireMock('./dialpad') as { + openTelephonyDialpad: jest.MockedFunction; + }; + return dialpad.openTelephonyDialpad; +}; +const parseTelephonyLinkMock = parseTelephonyLink as jest.MockedFunction< + typeof parseTelephonyLink +>; +const dispatchMock = dispatch as jest.MockedFunction; +const listenMock = listen as jest.MockedFunction; +const selectMock = select as jest.MockedFunction; +const spawnMock = spawn as jest.MockedFunction; +const watchMock = watch as jest.MockedFunction; +const getRootWindowMock = getRootWindow as jest.MockedFunction< + typeof getRootWindow +>; + +describe('telephony global shortcut main process pipeline', () => { + const rootWindow = { + isVisible: jest.fn(() => false), + showInactive: jest.fn(), + focus: jest.fn(), + }; + + beforeEach(() => { + teardownTelephonyGlobalShortcut(); + jest.clearAllMocks(); + parseTelephonyLinkMock.mockReturnValue(null); + getRootWindowMock.mockResolvedValue(rootWindow as any); + globalShortcutMock.isRegistered.mockReturnValue(false); + globalShortcutMock.register.mockReturnValue(true); + }); + + afterEach(() => { + teardownTelephonyGlobalShortcut(); + }); + + it('registers the configured accelerator and reports success', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(globalShortcutMock.register).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D', + expect.any(Function) + ); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: true, + accelerator: 'CommandOrControl+Shift+D', + error: null, + }, + }); + }); + + it('reads clipboard only when triggered and routes usable clipboard text', async () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(clipboardMock.readText).not.toHaveBeenCalled(); + + clipboardMock.readText.mockReturnValue(' +1 (800) 555-0199 '); + + await triggerTelephonyGlobalShortcut(); + + expect(rootWindow.showInactive).toHaveBeenCalled(); + expect(rootWindow.focus).toHaveBeenCalled(); + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledWith({ + phoneNumber: '+1 (800) 555-0199', + rawUri: '+1 (800) 555-0199', + }); + }); + + it('keeps common pasted phone number formats permissive', () => { + expect( + createTelephonyLinkFromClipboardText('Call +1 (800) 555-0199 x123') + ).toEqual({ + phoneNumber: 'Call +1 (800) 555-0199 x123', + rawUri: 'Call +1 (800) 555-0199 x123', + }); + }); + + it('opens the telephony path with empty input when clipboard is unusable', async () => { + clipboardMock.readText.mockReturnValue('not a phone number'); + + await triggerTelephonyGlobalShortcut(); + + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledWith({ + phoneNumber: '', + rawUri: '', + }); + }); + + it('skips parsing and opens empty input for empty clipboard text', () => { + expect(createTelephonyLinkFromClipboardText(' ')).toEqual({ + phoneNumber: '', + rawUri: '', + }); + expect(parseTelephonyLinkMock).not.toHaveBeenCalled(); + }); + + it('caps clipboard text before parsing or sending it to the renderer', () => { + expect(createTelephonyLinkFromClipboardText('1'.repeat(257))).toEqual({ + phoneNumber: '', + rawUri: '', + }); + expect(parseTelephonyLinkMock).not.toHaveBeenCalled(); + }); + + it('debounces repeated shortcut triggers', async () => { + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(1_100) + .mockReturnValueOnce(1_300); + clipboardMock.readText.mockReturnValue('+1 800 555 0199'); + + await triggerTelephonyGlobalShortcut(); + await triggerTelephonyGlobalShortcut(); + await triggerTelephonyGlobalShortcut(); + + expect(clipboardMock.readText).toHaveBeenCalledTimes(2); + expect(getOpenTelephonyDialpadMock()).toHaveBeenCalledTimes(2); + nowSpy.mockRestore(); + }); + + it('preserves parsed tel/callto links from clipboard', () => { + parseTelephonyLinkMock.mockReturnValue({ + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + }); + + expect(createTelephonyLinkFromClipboardText(' tel:+491234567890 ')).toEqual( + { + phoneNumber: '+491234567890', + rawUri: 'tel:+491234567890', + } + ); + }); + + it('unregisters old accelerator when disabled or changed', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+E', + }); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + + registerTelephonyGlobalShortcut({ enabled: false, accelerator: null }); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+E' + ); + }); + + it('ignores malformed persisted config without throwing', () => { + expect(() => registerTelephonyGlobalShortcut(null)).not.toThrow(); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: null, + error: null, + }, + }); + }); + + it('handles registration conflicts without throwing and shows feedback', () => { + globalShortcutMock.register.mockReturnValue(false); + + expect(() => + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }) + ).not.toThrow(); + + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: + 'Telephony shortcut CommandOrControl+Shift+D registration failed', + }, + }); + expect(notificationMock.isSupported).toHaveBeenCalled(); + expect(notificationMock).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('could not be registered'), + }) + ); + expect( + (notificationMock as unknown as jest.Mock).mock.results[0].value.show + ).toHaveBeenCalled(); + }); + + it('opens Settings when the registration failure notification is clicked', async () => { + globalShortcutMock.register.mockReturnValue(false); + + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + const notification = (notificationMock as unknown as jest.Mock).mock + .results[0].value; + const clickListener = notification.addListener.mock.calls.find( + ([event]: [string]) => event === 'click' + )?.[1] as (() => Promise) | undefined; + + await clickListener?.(); + + expect(rootWindow.focus).toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledWith({ + type: SIDE_BAR_SETTINGS_BUTTON_CLICKED, + }); + }); + + it('rejects reserved app accelerators before registering', () => { + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+C', + }); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+C', + error: + 'Telephony shortcut CommandOrControl+C is reserved by the app or operating system', + }, + }); + }); + + it('reports accelerators already registered by Electron before registering', () => { + globalShortcutMock.isRegistered.mockReturnValue(true); + + registerTelephonyGlobalShortcut({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + + expect(globalShortcutMock.register).not.toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenLastCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: + 'Telephony shortcut CommandOrControl+Shift+D is already registered', + }, + }); + }); + + it('watches config changes and unregisters on app close teardown', () => { + const unsubscribe = jest.fn(); + let watcher: Parameters[1] | undefined; + + watchMock.mockImplementation((_selector, callback) => { + watcher = callback as typeof watcher; + callback( + { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + undefined + ); + return unsubscribe; + }); + + setupTelephonyGlobalShortcut(); + + expect(appMock.addListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyGlobalShortcut + ); + expect(globalShortcutMock.register).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D', + expect.any(Function) + ); + + watcher?.( + { enabled: true, accelerator: 'CommandOrControl+Shift+E' }, + { enabled: true, accelerator: 'CommandOrControl+Shift+D' } + ); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + + teardownTelephonyGlobalShortcut(); + + expect(unsubscribe).toHaveBeenCalled(); + expect(appMock.removeListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyGlobalShortcut + ); + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+E' + ); + }); + + it('unregisters the current accelerator when Electron emits will-quit', () => { + let willQuitHandler: (() => void) | undefined; + appMock.addListener.mockImplementation(((event: string, listener) => { + if (event === 'will-quit') { + willQuitHandler = listener as () => void; + } + return appMock; + }) as typeof appMock.addListener); + watchMock.mockImplementation((_selector, callback) => { + callback( + { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + undefined + ); + return jest.fn(); + }); + + setupTelephonyGlobalShortcut(); + willQuitHandler?.(); + + expect(globalShortcutMock.unregister).toHaveBeenCalledWith( + 'CommandOrControl+Shift+D' + ); + }); +}); + +describe('telephony shortcut reducers', () => { + it('hydrates preferred server from persisted settings', () => { + expect( + telephonyPreferredServer(null, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyPreferredServer: 'https://chat.example.com', + }, + }) + ).toBe('https://chat.example.com'); + }); + + it('keeps shortcut config disabled by default and stores UI-provided config', () => { + expect( + telephonyGlobalShortcutConfig(undefined, { type: 'UNKNOWN' } as any) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { enabled: true, accelerator: 'CommandOrControl+Shift+D' }, + }) + ).toEqual({ enabled: true, accelerator: 'CommandOrControl+Shift+D' }); + }); + + it('hydrates shortcut config from persisted settings', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }, + }) + ).toEqual({ + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }); + }); + + it('normalizes malformed persisted shortcut config', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: null as any, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + }); + + it('rejects non-string and oversized persisted shortcut accelerators', () => { + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 123 as any, + }, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + + expect( + telephonyGlobalShortcutConfig(defaultTelephonyGlobalShortcutConfig, { + type: APP_SETTINGS_LOADED, + payload: { + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'A'.repeat(65), + }, + }, + }) + ).toEqual(defaultTelephonyGlobalShortcutConfig); + }); + + it('stores registration status for Settings UI feedback', () => { + expect( + telephonyGlobalShortcutRegistrationStatus(undefined, { + type: 'UNKNOWN', + } as any) + ).toBe(defaultTelephonyGlobalShortcutRegistrationStatus); + + expect( + telephonyGlobalShortcutRegistrationStatus( + defaultTelephonyGlobalShortcutRegistrationStatus, + { + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'conflict', + }, + } + ) + ).toEqual({ + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'conflict', + }); + }); +}); + +describe('telephony protocol handlers gate', () => { + beforeEach(() => { + teardownTelephonyProtocolHandlers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + teardownTelephonyProtocolHandlers(); + }); + + it('registers tel and callto when isTelephonyEnabled becomes true', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + expect(watchCallback).toBeInstanceOf(Function); + + watchCallback(true); + + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledWith('tel'); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledWith('callto'); + expect(appMock.removeAsDefaultProtocolClient).not.toHaveBeenCalled(); + }); + + it('unregisters tel and callto when isTelephonyEnabled becomes false', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + watchCallback(false); + + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledWith('tel'); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledWith( + 'callto' + ); + expect(appMock.setAsDefaultProtocolClient).not.toHaveBeenCalled(); + }); + + it('is idempotent — repeated setup calls only subscribe once', () => { + const unsubscribe = jest.fn(); + watchMock.mockReturnValue(unsubscribe); + + setupTelephonyProtocolHandlers(); + setupTelephonyProtocolHandlers(); + + expect(watchMock).toHaveBeenCalledTimes(1); + }); + + it('teardown unsubscribes the watcher and detaches will-quit listener', () => { + const unsubscribe = jest.fn(); + watchMock.mockReturnValue(unsubscribe); + + setupTelephonyProtocolHandlers(); + teardownTelephonyProtocolHandlers(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(appMock.removeListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyProtocolHandlers + ); + }); + + it('registers will-quit teardown listener on setup', () => { + watchMock.mockReturnValue(() => undefined); + + setupTelephonyProtocolHandlers(); + + expect(appMock.addListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyProtocolHandlers + ); + }); + + it('continues to second scheme when first scheme registration throws', () => { + watchMock.mockReturnValue(() => undefined); + appMock.setAsDefaultProtocolClient.mockImplementationOnce(() => { + throw new Error('registry locked'); + }); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + expect(() => watchCallback(true)).not.toThrow(); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenCalledTimes(2); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 1, + 'tel' + ); + expect(appMock.setAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 2, + 'callto' + ); + }); + + it('continues to second scheme when first scheme unregistration throws', () => { + watchMock.mockReturnValue(() => undefined); + appMock.removeAsDefaultProtocolClient.mockImplementationOnce(() => { + throw new Error('not registered'); + }); + + setupTelephonyProtocolHandlers(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + expect(() => watchCallback(false)).not.toThrow(); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenCalledTimes(2); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 1, + 'tel' + ); + expect(appMock.removeAsDefaultProtocolClient).toHaveBeenNthCalledWith( + 2, + 'callto' + ); + }); +}); + +describe('telephony default-handler prompt', () => { + const originalPlatform = process.platform; + const originalExecPath = process.execPath; + + beforeEach(() => { + teardownTelephonyDefaultHandlerPrompt(); + jest.clearAllMocks(); + selectMock.mockReturnValue(false); + watchMock.mockReturnValue(() => undefined); + listenMock.mockReturnValue(() => undefined); + spawnMock.mockReturnValue({ on: jest.fn(), unref: jest.fn() } as any); + }); + + afterEach(() => { + teardownTelephonyDefaultHandlerPrompt(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: originalExecPath, + writable: true, + configurable: true, + }); + }); + + it('dispatches TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN on first false→true transition', () => { + selectMock.mockReturnValue(false); + watchMock.mockReturnValue(() => undefined); + + setupTelephonyDefaultHandlerPrompt(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + watchCallback(true); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'telephony-default-handler-prompt/open', + }); + }); + + it('does NOT dispatch when telephony transitions from true to false', () => { + selectMock.mockReturnValue(true); + watchMock.mockReturnValue(() => undefined); + + setupTelephonyDefaultHandlerPrompt(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + watchCallback(false); + + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: 'telephony-default-handler-prompt/open', + }); + }); + + it('dispatches again on a subsequent false→true transition', () => { + selectMock.mockReturnValue(false); + watchMock.mockReturnValue(() => undefined); + + setupTelephonyDefaultHandlerPrompt(); + const watchCallback = watchMock.mock.calls[0][1] as ( + enabled: boolean + ) => void; + + watchCallback(true); + watchCallback(false); + watchCallback(true); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + expect(dispatchMock).toHaveBeenCalledWith({ + type: 'telephony-default-handler-prompt/open', + }); + }); + + it('does NOT dispatch when initial subscribe fires with enabled=false', () => { + selectMock.mockReturnValue(false); + watchMock.mockImplementation((_selector, callback) => { + (callback as (enabled: boolean) => void)(false); + return () => undefined; + }); + + setupTelephonyDefaultHandlerPrompt(); + + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: 'telephony-default-handler-prompt/open', + }); + }); + + it('does NOT dispatch when returning user already has telephony enabled (seed-then-subscribe)', () => { + // Seed phase: select returns true (returning user) + selectMock.mockReturnValue(true); + // watch fires immediately with enabled=true on subscribe + watchMock.mockImplementation((_selector, callback) => { + (callback as (enabled: boolean) => void)(true); + return () => undefined; + }); + + setupTelephonyDefaultHandlerPrompt(); + + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: 'telephony-default-handler-prompt/open', + }); + }); + + it('is idempotent — calling setup twice results in watch and listen called once each', () => { + setupTelephonyDefaultHandlerPrompt(); + setupTelephonyDefaultHandlerPrompt(); + + expect(watchMock).toHaveBeenCalledTimes(1); + expect(listenMock).toHaveBeenCalledTimes(1); + }); + + it('registers a will-quit listener bound to teardownTelephonyDefaultHandlerPrompt', () => { + setupTelephonyDefaultHandlerPrompt(); + + expect(appMock.addListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyDefaultHandlerPrompt + ); + }); + + it('teardown calls both unsubscribes, detaches will-quit listener, and resets tracker', () => { + const unsubscribeWatch = jest.fn(); + const unsubscribeListen = jest.fn(); + watchMock.mockReturnValue(unsubscribeWatch); + listenMock.mockReturnValue(unsubscribeListen); + + setupTelephonyDefaultHandlerPrompt(); + teardownTelephonyDefaultHandlerPrompt(); + + expect(unsubscribeWatch).toHaveBeenCalledTimes(1); + expect(unsubscribeListen).toHaveBeenCalledTimes(1); + expect(appMock.removeListener).toHaveBeenCalledWith( + 'will-quit', + teardownTelephonyDefaultHandlerPrompt + ); + }); + + it('OPEN_SETTINGS_CLICKED on win32 per-user install opens registeredAppUser deep link', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: + 'C:\\Users\\Jean\\AppData\\Local\\Programs\\Rocket.Chat\\Rocket.Chat.exe', + writable: true, + configurable: true, + }); + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(shellMock.openExternal).toHaveBeenCalledWith( + 'ms-settings:defaultapps?registeredAppUser=Rocket.Chat' + ); + }); + + it('OPEN_SETTINGS_CLICKED on win32 per-machine install opens registeredAppMachine deep link', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + Object.defineProperty(process, 'execPath', { + value: 'C:\\Program Files\\Rocket.Chat\\Rocket.Chat.exe', + writable: true, + configurable: true, + }); + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(shellMock.openExternal).toHaveBeenCalledWith( + 'ms-settings:defaultapps?registeredAppMachine=Rocket.Chat' + ); + }); + + it('OPEN_SETTINGS_CLICKED on darwin is a no-op (Launch Services handles it)', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + configurable: true, + }); + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(shellMock.openExternal).not.toHaveBeenCalled(); + }); + + it('OPEN_SETTINGS_CLICKED on linux with GNOME desktop spawns gnome-control-center', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + process.env.XDG_CURRENT_DESKTOP = 'GNOME'; + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(spawnMock).toHaveBeenCalledWith( + 'gnome-control-center', + ['default-apps'], + { detached: true, stdio: 'ignore' } + ); + expect(shellMock.openExternal).not.toHaveBeenCalled(); + + delete process.env.XDG_CURRENT_DESKTOP; + }); + + it('OPEN_SETTINGS_CLICKED on linux with KDE desktop spawns kcmshell5', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + process.env.XDG_CURRENT_DESKTOP = 'KDE'; + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(spawnMock).toHaveBeenCalledWith('kcmshell5', ['componentchooser'], { + detached: true, + stdio: 'ignore', + }); + expect(shellMock.openExternal).not.toHaveBeenCalled(); + + delete process.env.XDG_CURRENT_DESKTOP; + }); + + it('OPEN_SETTINGS_CLICKED on linux with unknown desktop does not spawn or call openExternal', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + configurable: true, + }); + process.env.XDG_CURRENT_DESKTOP = 'Sway'; + + setupTelephonyDefaultHandlerPrompt(); + const settingsCallback = listenMock.mock.calls[0][1] as () => void; + settingsCallback(); + + expect(spawnMock).not.toHaveBeenCalled(); + expect(shellMock.openExternal).not.toHaveBeenCalled(); + + delete process.env.XDG_CURRENT_DESKTOP; + }); +}); diff --git a/src/telephony/main.ts b/src/telephony/main.ts new file mode 100644 index 0000000000..fdbba3b88e --- /dev/null +++ b/src/telephony/main.ts @@ -0,0 +1,426 @@ +import { spawn } from 'child_process'; + +import { app, clipboard, globalShortcut, Notification, shell } from 'electron'; + +import { TELEPHONY_SCHEMES } from '../app/main/app'; +import { logger } from '../logging'; +import { dispatch, listen, select, watch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import { + SIDE_BAR_SETTINGS_BUTTON_CLICKED, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, +} from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED } from './actions'; +import type { TelephonyGlobalShortcutConfig } from './actions'; +import type { TelephonyLink } from './common'; +import { parseTelephonyLink } from './links'; +import { + MAX_CLIPBOARD_PHONE_LENGTH, + isReservedTelephonyShortcutAccelerator, + normalizeTelephonyShortcutAccelerator, +} from './shortcuts'; + +const selectTelephonyGlobalShortcutConfig = ({ + telephonyGlobalShortcutConfig, + isTelephonyEnabled, +}: RootState): TelephonyGlobalShortcutConfig => + isTelephonyEnabled ? telephonyGlobalShortcutConfig : DISABLED_SHORTCUT_CONFIG; + +const selectIsTelephonyEnabled = ({ isTelephonyEnabled }: RootState): boolean => + isTelephonyEnabled; + +let registeredAccelerator: string | null = null; +let unsubscribeFromShortcutConfig: (() => void) | null = null; +let unsubscribeFromTelephonyEnabled: (() => void) | null = null; +let lastTelephonyShortcutTriggeredAt = 0; + +const TELEPHONY_GLOBAL_SHORTCUT_DEBOUNCE_MS = 250; + +const EMPTY_TELEPHONY_LINK: TelephonyLink = { + phoneNumber: '', + rawUri: '', +}; + +const DISABLED_SHORTCUT_CONFIG: TelephonyGlobalShortcutConfig = { + enabled: false, + accelerator: null, +}; + +const normalizeTelephonyGlobalShortcutConfig = ( + config: TelephonyGlobalShortcutConfig | null | undefined +): TelephonyGlobalShortcutConfig => { + if (!config || typeof config !== 'object') { + return DISABLED_SHORTCUT_CONFIG; + } + + const accelerator = normalizeTelephonyShortcutAccelerator(config.accelerator); + + return { + enabled: config.enabled === true, + accelerator, + }; +}; + +const extractClipboardPhoneNumber = (text: string): string | null => { + const trimmedText = text.trim(); + const digitCount = (trimmedText.match(/\d/g) ?? []).length; + + if (digitCount < 3) { + return null; + } + + return trimmedText; +}; + +export const createTelephonyLinkFromClipboardText = ( + text: string +): TelephonyLink => { + const trimmedText = text.trim(); + if (!trimmedText || trimmedText.length > MAX_CLIPBOARD_PHONE_LENGTH) { + return EMPTY_TELEPHONY_LINK; + } + + const telephonyLink = parseTelephonyLink(trimmedText); + if (telephonyLink) { + return telephonyLink; + } + + const phoneNumber = extractClipboardPhoneNumber(trimmedText); + if (!phoneNumber) { + return EMPTY_TELEPHONY_LINK; + } + + return { + phoneNumber, + rawUri: trimmedText, + }; +}; + +const focusRootWindow = async (): Promise => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + + browserWindow.focus(); +}; + +export const triggerTelephonyGlobalShortcut = async (): Promise => { + const now = Date.now(); + if ( + lastTelephonyShortcutTriggeredAt && + now - lastTelephonyShortcutTriggeredAt < + TELEPHONY_GLOBAL_SHORTCUT_DEBOUNCE_MS + ) { + return; + } + lastTelephonyShortcutTriggeredAt = now; + + const telephonyLink = createTelephonyLinkFromClipboardText( + clipboard.readText() + ); + + const { openTelephonyDialpad } = await import('./dialpad'); + + await focusRootWindow(); + await openTelephonyDialpad(telephonyLink); +}; + +const notifyRegistrationFailure = ( + accelerator: string, + error: string +): void => { + logger.warn(error); + + try { + if (!Notification.isSupported()) { + return; + } + + const notification = new Notification({ + title: 'Rocket.Chat', + body: `Telephony shortcut ${accelerator} could not be registered. It may already be in use.`, + }); + notification.addListener('click', () => + focusRootWindow() + .catch((error) => { + logger.warn( + 'Failed to focus Rocket.Chat from telephony shortcut notification' + ); + logger.warn(error); + }) + .finally(() => { + dispatch({ type: SIDE_BAR_SETTINGS_BUTTON_CLICKED }); + }) + ); + notification.show(); + } catch (notificationError) { + logger.warn('Failed to show telephony shortcut registration feedback'); + logger.warn(notificationError); + } +}; + +const dispatchRegistrationStatus = ( + registered: boolean, + accelerator: string | null, + error: string | null +): void => { + dispatch({ + type: TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + payload: { + registered, + accelerator, + error, + }, + }); +}; + +export const unregisterTelephonyGlobalShortcut = (): void => { + if (registeredAccelerator) { + globalShortcut.unregister(registeredAccelerator); + registeredAccelerator = null; + } + + dispatchRegistrationStatus(false, null, null); +}; + +export const registerTelephonyGlobalShortcut = ( + config: TelephonyGlobalShortcutConfig | null | undefined +): void => { + const { enabled, accelerator } = + normalizeTelephonyGlobalShortcutConfig(config); + + unregisterTelephonyGlobalShortcut(); + + if (!enabled || !accelerator) { + return; + } + + try { + if (isReservedTelephonyShortcutAccelerator(accelerator)) { + const error = `Telephony shortcut ${accelerator} is reserved by the app or operating system`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + if (globalShortcut.isRegistered?.(accelerator)) { + const error = `Telephony shortcut ${accelerator} is already registered`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + const registered = globalShortcut.register(accelerator, () => { + void triggerTelephonyGlobalShortcut().catch((error) => { + logger.error('Failed to handle telephony global shortcut', error); + }); + }); + + if (!registered) { + const error = `Telephony shortcut ${accelerator} registration failed`; + dispatchRegistrationStatus(false, accelerator, error); + notifyRegistrationFailure(accelerator, error); + return; + } + + registeredAccelerator = accelerator; + dispatchRegistrationStatus(true, accelerator, null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const failureMessage = `Telephony shortcut ${accelerator} registration failed: ${message}`; + dispatchRegistrationStatus(false, accelerator, failureMessage); + notifyRegistrationFailure(accelerator, failureMessage); + } +}; + +export const setupTelephonyGlobalShortcut = (): void => { + if (unsubscribeFromShortcutConfig) { + return; + } + + unsubscribeFromShortcutConfig = watch( + selectTelephonyGlobalShortcutConfig, + (config) => { + registerTelephonyGlobalShortcut(config); + } + ); + + app.addListener('will-quit', teardownTelephonyGlobalShortcut); +}; + +export const teardownTelephonyGlobalShortcut = (): void => { + app.removeListener('will-quit', teardownTelephonyGlobalShortcut); + lastTelephonyShortcutTriggeredAt = 0; + + if (unsubscribeFromShortcutConfig) { + unsubscribeFromShortcutConfig(); + unsubscribeFromShortcutConfig = null; + } + + unregisterTelephonyGlobalShortcut(); +}; + +const applyTelephonyProtocolRegistration = (enabled: boolean): void => { + for (const scheme of TELEPHONY_SCHEMES) { + try { + if (enabled) { + app.setAsDefaultProtocolClient(scheme); + } else { + app.removeAsDefaultProtocolClient(scheme); + } + } catch (error) { + logger.warn( + `Failed to ${ + enabled ? 'register' : 'unregister' + } telephony protocol handler for ${scheme}:` + ); + logger.warn(error); + } + } +}; + +export const setupTelephonyProtocolHandlers = (): void => { + if (unsubscribeFromTelephonyEnabled) { + return; + } + + unsubscribeFromTelephonyEnabled = watch( + selectIsTelephonyEnabled, + (enabled) => { + applyTelephonyProtocolRegistration(enabled); + } + ); + + app.addListener('will-quit', teardownTelephonyProtocolHandlers); +}; + +export const teardownTelephonyProtocolHandlers = (): void => { + app.removeListener('will-quit', teardownTelephonyProtocolHandlers); + + if (unsubscribeFromTelephonyEnabled) { + unsubscribeFromTelephonyEnabled(); + unsubscribeFromTelephonyEnabled = null; + } +}; + +let unsubscribeFromDefaultHandlerPrompt: (() => void) | null = null; +let unsubscribeFromDefaultHandlerSettingsListener: (() => void) | null = null; +let lastTelephonyEnabledForPrompt = false; + +const WINDOWS_REGISTERED_APP_NAME = 'Rocket.Chat'; + +const isWindowsPerMachineInstall = (): boolean => + process.execPath.toLowerCase().includes('\\program files'); + +const buildWindowsDefaultAppsUri = (): string => { + const param = isWindowsPerMachineInstall() + ? 'registeredAppMachine' + : 'registeredAppUser'; + return `ms-settings:defaultapps?${param}=${encodeURIComponent( + WINDOWS_REGISTERED_APP_NAME + )}`; +}; + +const openSystemDefaultAppsSettings = (): void => { + if (process.platform === 'win32') { + try { + void shell.openExternal(buildWindowsDefaultAppsUri()); + } catch (error) { + logger.warn('Failed to open Windows default apps settings'); + logger.warn(error); + } + } else if (process.platform === 'darwin') { + // macOS: Launch Services already claimed tel: via app.setAsDefaultProtocolClient; + // no System Settings pane exists for default tel handler. + } else if (process.platform === 'linux') { + try { + const desktop = (process.env.XDG_CURRENT_DESKTOP ?? '') + .toUpperCase() + .trim(); + + if ( + desktop.includes('GNOME') || + desktop.includes('UNITY') || + desktop.includes('CINNAMON') + ) { + spawn('gnome-control-center', ['default-apps'], { + detached: true, + stdio: 'ignore', + }).unref(); + } else if (desktop.includes('KDE') || desktop.includes('PLASMA')) { + const child = spawn('kcmshell5', ['componentchooser'], { + detached: true, + stdio: 'ignore', + }); + child.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + spawn('kcmshell6', ['componentchooser'], { + detached: true, + stdio: 'ignore', + }).unref(); + } + }); + child.unref(); + } else { + logger.info( + `No known default-apps settings command for desktop environment: ${desktop}` + ); + } + } catch (error) { + logger.warn('Failed to open Linux default apps settings'); + logger.warn(error); + } + } else { + logger.info( + `openSystemDefaultAppsSettings: no-op on platform ${process.platform}` + ); + } +}; + +export const setupTelephonyDefaultHandlerPrompt = (): void => { + if (unsubscribeFromDefaultHandlerPrompt) { + return; + } + + lastTelephonyEnabledForPrompt = select(selectIsTelephonyEnabled); + + unsubscribeFromDefaultHandlerPrompt = watch( + selectIsTelephonyEnabled, + (enabled) => { + const shouldPrompt = enabled && !lastTelephonyEnabledForPrompt; + lastTelephonyEnabledForPrompt = enabled; + if (shouldPrompt) { + dispatch({ type: TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN }); + } + } + ); + + unsubscribeFromDefaultHandlerSettingsListener = listen( + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, + () => { + openSystemDefaultAppsSettings(); + } + ); + + app.addListener('will-quit', teardownTelephonyDefaultHandlerPrompt); +}; + +export const teardownTelephonyDefaultHandlerPrompt = (): void => { + app.removeListener('will-quit', teardownTelephonyDefaultHandlerPrompt); + + if (unsubscribeFromDefaultHandlerPrompt) { + unsubscribeFromDefaultHandlerPrompt(); + unsubscribeFromDefaultHandlerPrompt = null; + } + + if (unsubscribeFromDefaultHandlerSettingsListener) { + unsubscribeFromDefaultHandlerSettingsListener(); + unsubscribeFromDefaultHandlerSettingsListener = null; + } + + lastTelephonyEnabledForPrompt = false; +}; diff --git a/src/telephony/preload.ts b/src/telephony/preload.ts new file mode 100644 index 0000000000..35de60ee26 --- /dev/null +++ b/src/telephony/preload.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron'; + +type TelephonyPayload = { phoneNumber: string; rawUri: string }; + +let telephonyCallback: ((payload: TelephonyPayload) => void) | null = null; +let pendingPayload: TelephonyPayload | null = null; + +export const onTelephonyCallRequested = ( + callback: (payload: TelephonyPayload) => void +): void => { + telephonyCallback = callback; + if (pendingPayload) { + callback(pendingPayload); + pendingPayload = null; + } +}; + +let listening = false; + +export const listenToTelephonyRequests = (): void => { + if (listening) { + return; + } + listening = true; + + ipcRenderer.on( + 'telephony/call-requested', + (_event, payload: TelephonyPayload) => { + if (telephonyCallback) { + telephonyCallback(payload); + } else { + pendingPayload = payload; + } + } + ); +}; diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts new file mode 100644 index 0000000000..b1b606b595 --- /dev/null +++ b/src/telephony/reducers.ts @@ -0,0 +1,105 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../app/actions'; +import type { ActionOf } from '../store/actions'; +import { + TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED, + TELEPHONY_PREFERRED_SERVER_SET, +} from './actions'; +import type { + TelephonyGlobalShortcutConfig, + TelephonyGlobalShortcutRegistrationStatus, +} from './actions'; +import { normalizeTelephonyShortcutAccelerator } from './shortcuts'; + +type TelephonyPreferredServerAction = + | ActionOf + | ActionOf; + +type TelephonyGlobalShortcutConfigAction = + | ActionOf + | ActionOf; + +type TelephonyGlobalShortcutRegistrationStatusAction = ActionOf< + typeof TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED +>; + +export const defaultTelephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig = + { + enabled: false, + accelerator: null, + }; + +export const defaultTelephonyGlobalShortcutRegistrationStatus: TelephonyGlobalShortcutRegistrationStatus = + { + registered: false, + accelerator: null, + error: null, + }; + +const normalizeTelephonyGlobalShortcutConfig = ( + config: Partial | null | undefined +): TelephonyGlobalShortcutConfig => { + if (!config || typeof config !== 'object') { + return defaultTelephonyGlobalShortcutConfig; + } + + const accelerator = normalizeTelephonyShortcutAccelerator(config.accelerator); + + return { + enabled: config.enabled === true && Boolean(accelerator), + accelerator, + }; +}; + +export const telephonyPreferredServer: Reducer< + string | null, + TelephonyPreferredServerAction +> = (state = null, action) => { + switch (action.type) { + case TELEPHONY_PREFERRED_SERVER_SET: + return action.payload; + + case APP_SETTINGS_LOADED: { + const { telephonyPreferredServer = state } = action.payload; + return telephonyPreferredServer; + } + + default: + return state; + } +}; + +export const telephonyGlobalShortcutConfig: Reducer< + TelephonyGlobalShortcutConfig, + TelephonyGlobalShortcutConfigAction +> = (state = defaultTelephonyGlobalShortcutConfig, action) => { + switch (action.type) { + case TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET: + return normalizeTelephonyGlobalShortcutConfig(action.payload); + + case APP_SETTINGS_LOADED: { + const { telephonyGlobalShortcutConfig = state } = action.payload; + return normalizeTelephonyGlobalShortcutConfig( + telephonyGlobalShortcutConfig + ); + } + + default: + return state; + } +}; + +export const telephonyGlobalShortcutRegistrationStatus: Reducer< + TelephonyGlobalShortcutRegistrationStatus, + TelephonyGlobalShortcutRegistrationStatusAction +> = (state = defaultTelephonyGlobalShortcutRegistrationStatus, action) => { + switch (action.type) { + case TELEPHONY_GLOBAL_SHORTCUT_REGISTRATION_CHANGED: + return action.payload; + + default: + return state; + } +}; diff --git a/src/telephony/renderer/preload.spec.ts b/src/telephony/renderer/preload.spec.ts new file mode 100644 index 0000000000..e5d31df5ea --- /dev/null +++ b/src/telephony/renderer/preload.spec.ts @@ -0,0 +1,164 @@ +jest.mock('electron', () => ({ + ipcRenderer: { + on: jest.fn(), + }, +})); + +type TelephonyPayload = { phoneNumber: string; rawUri: string }; + +describe('telephony/preload', () => { + let listenToTelephonyRequests: () => void; + let onTelephonyCallRequested: ( + cb: (payload: TelephonyPayload) => void + ) => void; + let ipcRendererOn: jest.Mock; + + // Simulate an IPC event by extracting the registered handler and calling it. + const fireIpcEvent = (payload: TelephonyPayload): void => { + const entry = ipcRendererOn.mock.calls.find( + ([channel]: [string]) => channel === 'telephony/call-requested' + ); + if (!entry) + throw new Error('No handler registered for telephony/call-requested'); + const handler = entry[1] as (_event: unknown, p: TelephonyPayload) => void; + handler({}, payload); + }; + + beforeEach(() => { + jest.resetModules(); + + // Re-acquire mock and module after reset so state is fresh each test. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ipcRenderer } = require('electron') as { + ipcRenderer: { on: jest.Mock }; + }; + ipcRendererOn = ipcRenderer.on; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../preload') as { + listenToTelephonyRequests: () => void; + onTelephonyCallRequested: ( + cb: (payload: TelephonyPayload) => void + ) => void; + }; + listenToTelephonyRequests = mod.listenToTelephonyRequests; + onTelephonyCallRequested = mod.onTelephonyCallRequested; + }); + + it('registers ONE handler for channel telephony/call-requested on ipcRenderer', () => { + listenToTelephonyRequests(); + + expect(ipcRendererOn).toHaveBeenCalledTimes(1); + expect(ipcRendererOn).toHaveBeenCalledWith( + 'telephony/call-requested', + expect.any(Function) + ); + }); + + it('calling listenToTelephonyRequests twice still registers only ONE handler', () => { + listenToTelephonyRequests(); + listenToTelephonyRequests(); + + expect(ipcRendererOn).toHaveBeenCalledTimes(1); + }); + + it('buffers payload when IPC event arrives before callback is registered, then flushes on registration', () => { + listenToTelephonyRequests(); + + const payload: TelephonyPayload = { + phoneNumber: '1234', + rawUri: 'tel:1234', + }; + fireIpcEvent(payload); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + + // Buffered payload flushed synchronously on registration. + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(payload); + + // Firing another event after registration should call the callback directly (not buffer). + const payload2: TelephonyPayload = { + phoneNumber: '5678', + rawUri: 'tel:5678', + }; + fireIpcEvent(payload2); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenLastCalledWith(payload2); + }); + + it('calls callback directly when it is registered before the IPC event arrives', () => { + listenToTelephonyRequests(); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + + const payload: TelephonyPayload = { + phoneNumber: '9999', + rawUri: 'tel:9999', + }; + fireIpcEvent(payload); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(payload); + }); + + it('delivers empty phone payloads so the renderer can open an empty dial pad', () => { + listenToTelephonyRequests(); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + + const payload: TelephonyPayload = { + phoneNumber: '', + rawUri: '', + }; + fireIpcEvent(payload); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(payload); + }); + + it('replacing callback: next IPC event fires the new callback only', () => { + listenToTelephonyRequests(); + + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + onTelephonyCallRequested(cb1); + onTelephonyCallRequested(cb2); + + const payload: TelephonyPayload = { + phoneNumber: '0000', + rawUri: 'callto:0000', + }; + fireIpcEvent(payload); + + expect(cb1).not.toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledWith(payload); + }); + + it('pendingPayload is cleared after flush — second onTelephonyCallRequested call does not re-deliver it', () => { + listenToTelephonyRequests(); + + const payload: TelephonyPayload = { + phoneNumber: '1111', + rawUri: 'tel:1111', + }; + fireIpcEvent(payload); + + const cb1 = jest.fn(); + onTelephonyCallRequested(cb1); + // cb1 receives the buffered payload. + expect(cb1).toHaveBeenCalledTimes(1); + + // Register a second callback — pendingPayload should be null now. + const cb2 = jest.fn(); + onTelephonyCallRequested(cb2); + + expect(cb2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telephony/shortcuts.ts b/src/telephony/shortcuts.ts new file mode 100644 index 0000000000..7f5de780fe --- /dev/null +++ b/src/telephony/shortcuts.ts @@ -0,0 +1,39 @@ +export const MAX_CLIPBOARD_PHONE_LENGTH = 256; +export const MAX_TELEPHONY_SHORTCUT_ACCELERATOR_LENGTH = 64; + +const normalizeAccelerator = (accelerator: string): string => + accelerator + .replace(/\s+/g, '') + .replace(/cmd/gi, 'command') + .replace(/ctrl/gi, 'control') + .toLowerCase(); + +const RESERVED_ACCELERATORS = new Set( + ['C', 'V', 'X', 'A', 'Z', 'Q', 'W', 'N', ','].flatMap((key) => [ + `commandorcontrol+${key.toLowerCase()}`, + `command+${key.toLowerCase()}`, + `control+${key.toLowerCase()}`, + ]) +); + +export const normalizeTelephonyShortcutAccelerator = ( + accelerator: unknown +): string | null => { + if (typeof accelerator !== 'string') { + return null; + } + + const trimmedAccelerator = accelerator.trim(); + if ( + !trimmedAccelerator || + trimmedAccelerator.length > MAX_TELEPHONY_SHORTCUT_ACCELERATOR_LENGTH + ) { + return null; + } + + return trimmedAccelerator; +}; + +export const isReservedTelephonyShortcutAccelerator = ( + accelerator: string +): boolean => RESERVED_ACCELERATORS.has(normalizeAccelerator(accelerator)); diff --git a/src/ui/actions.ts b/src/ui/actions.ts index f12e243e82..b9e49b437e 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -96,6 +96,8 @@ export const SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED = 'settings/set-minimize-on-close-opt-in-changed'; export const SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED = 'settings/set-is-tray-icon-enabled-changed'; +export const SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED = + 'settings/set-is-telephony-enabled-changed'; export const SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED = 'settings/set-is-side-bar-enabled-changed'; export const SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED = @@ -156,6 +158,14 @@ export const WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR = 'webview/force-reload-with-cache-clear'; export const OPEN_SERVER_INFO_MODAL = 'server-info-modal/open'; export const CLOSE_SERVER_INFO_MODAL = 'server-info-modal/close'; +export const TELEPHONY_SERVER_SELECT_OPEN = 'telephony-server-select/open'; +export const TELEPHONY_SERVER_SELECT_CLOSE = 'telephony-server-select/close'; +export const TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN = + 'telephony-default-handler-prompt/open'; +export const TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE = + 'telephony-default-handler-prompt/close'; +export const TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED = + 'telephony-default-handler-prompt/open-settings-clicked'; export type UiActionTypeToPayloadMap = { [ABOUT_DIALOG_DISMISSED]: void; @@ -250,6 +260,7 @@ export type UiActionTypeToPayloadMap = { [SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED]: boolean; [SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED]: boolean; [SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED]: boolean; + [SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_SIDE_BAR_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED]: boolean; @@ -307,4 +318,15 @@ export type UiActionTypeToPayloadMap = { supportedVersions?: Server['supportedVersions']; }; [CLOSE_SERVER_INFO_MODAL]: void; + [TELEPHONY_SERVER_SELECT_OPEN]: { + phoneNumber: string; + rawUri: string; + }; + [TELEPHONY_SERVER_SELECT_CLOSE]: { + serverUrl: string; + rememberChoice: boolean; + } | null; + [TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN]: void; + [TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE]: void; + [TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED]: void; }; diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index bab5933158..fe439b5550 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -1,21 +1,17 @@ import { Box, FieldGroup } from '@rocket.chat/fuselage'; import { AvailableBrowsers } from './features/AvailableBrowsers'; -import { ClearPermittedScreenCaptureServers } from './features/ClearPermittedScreenCaptureServers'; import { FlashFrame } from './features/FlashFrame'; import { HardwareAcceleration } from './features/HardwareAcceleration'; -import { InternalVideoChatWindow } from './features/InternalVideoChatWindow'; import { MenuBar } from './features/MenuBar'; import { MinimizeOnClose } from './features/MinimizeOnClose'; import { NTLMCredentials } from './features/NTLMCredentials'; import { OutlookCalendarSyncInterval } from './features/OutlookCalendarSyncInterval'; import { ReportErrors } from './features/ReportErrors'; -import { ScreenCaptureFallback } from './features/ScreenCaptureFallback'; import { SideBar } from './features/SideBar'; import { ThemeAppearance } from './features/ThemeAppearance'; import { TransparentWindow } from './features/TransparentWindow'; import { TrayIcon } from './features/TrayIcon'; -import { VideoCallWindowPersistence } from './features/VideoCallWindowPersistence'; export const GeneralTab = () => ( @@ -23,9 +19,6 @@ export const GeneralTab = () => ( - {process.platform === 'win32' && } - - {process.platform === 'darwin' && } {process.platform === 'win32' && } @@ -35,7 +28,6 @@ export const GeneralTab = () => ( - {!process.mas && } ); diff --git a/src/ui/components/SettingsView/SettingsView.tsx b/src/ui/components/SettingsView/SettingsView.tsx index e5ccce02d3..b2c070aa88 100644 --- a/src/ui/components/SettingsView/SettingsView.tsx +++ b/src/ui/components/SettingsView/SettingsView.tsx @@ -10,6 +10,7 @@ import { DOWNLOADS_BACK_BUTTON_CLICKED } from '../../actions'; import { CertificatesTab } from './CertificatesTab'; import { DeveloperTab } from './DeveloperTab'; import { GeneralTab } from './GeneralTab'; +import { VoiceVideoTab } from './VoiceVideoTab'; export const SettingsView = () => { const isVisible = useSelector( @@ -81,6 +82,12 @@ export const SettingsView = () => { > {t('settings.certificates')} + setCurrentTab('voiceVideo')} + > + {t('settings.voiceVideo')} + {isDeveloperModeEnabled && ( { {(currentTab === 'general' && ) || (currentTab === 'certificates' && ) || + (currentTab === 'voiceVideo' && ) || (currentTab === 'developer' && )} diff --git a/src/ui/components/SettingsView/VoiceVideoTab.tsx b/src/ui/components/SettingsView/VoiceVideoTab.tsx new file mode 100644 index 0000000000..d747cff3d7 --- /dev/null +++ b/src/ui/components/SettingsView/VoiceVideoTab.tsx @@ -0,0 +1,44 @@ +import { Accordion, Box, FieldGroup } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { ClearPermittedScreenCaptureServers } from './features/ClearPermittedScreenCaptureServers'; +import { InternalVideoChatWindow } from './features/InternalVideoChatWindow'; +import { ScreenCaptureFallback } from './features/ScreenCaptureFallback'; +import { Telephony } from './features/Telephony'; +import { TelephonyGlobalShortcut } from './features/TelephonyGlobalShortcut'; +import { TelephonyServer } from './features/TelephonyServer'; +import { VideoCallWindowPersistence } from './features/VideoCallWindowPersistence'; + +export const VoiceVideoTab = () => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + {process.platform === 'win32' && } + {!process.mas && } + + + + + + ); +}; diff --git a/src/ui/components/SettingsView/features/AvailableBrowsers.tsx b/src/ui/components/SettingsView/features/AvailableBrowsers.tsx index 5cc452db6b..6ea66aef8c 100644 --- a/src/ui/components/SettingsView/features/AvailableBrowsers.tsx +++ b/src/ui/components/SettingsView/features/AvailableBrowsers.tsx @@ -69,7 +69,7 @@ export const AvailableBrowsers = (props: AvailableBrowsersProps) => { ); return ( - + ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +describe('Telephony', () => { + it('renders unchecked when isTelephonyEnabled=false', () => { + const store = makeStore({ isTelephonyEnabled: false }); + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + expect(toggle).not.toBeChecked(); + }); + + it('renders checked when isTelephonyEnabled=true', () => { + const store = makeStore({ isTelephonyEnabled: true }); + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeChecked(); + }); + + it('dispatches SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED on toggle', () => { + const store = makeStore({ isTelephonyEnabled: false }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const toggle = screen.getByRole('checkbox'); + fireEvent.click(toggle); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/Telephony.tsx b/src/ui/components/SettingsView/features/Telephony.tsx new file mode 100644 index 0000000000..b11b4fe033 --- /dev/null +++ b/src/ui/components/SettingsView/features/Telephony.tsx @@ -0,0 +1,60 @@ +import { + ToggleSwitch, + Field, + FieldRow, + FieldLabel, + FieldHint, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useCallback, useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED } from '../../../actions'; +import { TelephonyDiagnostics } from './TelephonyDiagnostics'; + +type TelephonyProps = { + className?: string; +}; + +export const Telephony = (props: TelephonyProps) => { + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + const handleChange = useCallback( + (event: ChangeEvent) => { + const isChecked = event.currentTarget.checked; + dispatch({ + type: SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED, + payload: isChecked, + }); + }, + [dispatch] + ); + + const isTelephonyEnabledId = useId(); + + return ( + + + + {t('settings.options.telephony.title')} + + + + + {t('settings.options.telephony.description')} + + {isTelephonyEnabled && } + + ); +}; diff --git a/src/ui/components/SettingsView/features/TelephonyDiagnostics.tsx b/src/ui/components/SettingsView/features/TelephonyDiagnostics.tsx new file mode 100644 index 0000000000..a682635e48 --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyDiagnostics.tsx @@ -0,0 +1,280 @@ +import { + Accordion, + Box, + Button, + ButtonGroup, + Divider, + Tag, + Throbber, +} from '@rocket.chat/fuselage'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import { invoke } from '../../../../ipc/renderer'; +import type { RootAction } from '../../../../store/actions'; +import type { + TelephonyDiagnosticCheck, + TelephonyDiagnostics as TelephonyDiagnosticsData, +} from '../../../../telephony/diagnostics'; +import { TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED } from '../../../actions'; + +type DiagnosticStatus = TelephonyDiagnosticCheck['status']; + +const STATUS_TAG_VARIANT: Record< + DiagnosticStatus, + 'primary' | 'danger' | 'warning' +> = { + pass: 'primary', + fail: 'danger', + unknown: 'warning', +}; + +const STATUS_FALLBACK: Record = { + pass: 'Pass', + fail: 'Fail', + unknown: 'Unknown', +}; + +const statusLabelKey = (status: DiagnosticStatus): string => + `telephony.diagnostics.status.${status}`; + +const formatDetailsForDisplay = (details: string): string => + details.replace(/\s+at\s+\/[^\s].*$/, ''); + +const PLATFORM_LABEL: Record = { + darwin: 'macOS', + win32: 'Windows', + linux: 'Linux', + aix: 'AIX', + freebsd: 'FreeBSD', + openbsd: 'OpenBSD', + sunos: 'SunOS', +}; + +const formatPlatformForDisplay = (platform: string): string => + PLATFORM_LABEL[platform] ?? platform; + +export const TelephonyDiagnostics = () => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const [diagnostics, setDiagnostics] = + useState(null); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + + const fetchDiagnostics = useCallback(async () => { + setLoading(true); + try { + const result = await invoke('telephony/get-diagnostics'); + setDiagnostics(result); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchDiagnostics(); + }, [fetchDiagnostics]); + + const handleRefresh = useCallback(() => { + void fetchDiagnostics(); + }, [fetchDiagnostics]); + + const handleCopy = useCallback(() => { + if (!diagnostics) return; + void navigator.clipboard.writeText(JSON.stringify(diagnostics, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [diagnostics]); + + const handleOpenSettings = useCallback(() => { + dispatch({ type: TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED }); + }, [dispatch]); + + const summaryTag = useMemo(() => { + if (loading || !diagnostics) { + return ( + + + {t('telephony.diagnostics.summary.checking')} + + + ); + } + const failureCount = diagnostics.checks.filter( + (c) => c.status === 'fail' + ).length; + const unknownCount = diagnostics.checks.filter( + (c) => c.status === 'unknown' + ).length; + if (failureCount > 0) { + return ( + + + {t('telephony.diagnostics.summary.issues', { count: failureCount })} + + + ); + } + if (unknownCount > 0) { + return ( + + + {t('telephony.diagnostics.summary.warnings', { + count: unknownCount, + })} + + + ); + } + return ( + + + {t('telephony.diagnostics.summary.healthy')} + + + ); + }, [loading, diagnostics, t]); + + const accordionTitle = ( + + + + {t('telephony.diagnostics.title')} + + + {t('telephony.diagnostics.subtitle')} + + + {summaryTag} + + ); + + return ( + + + + + + + + + {loading && ( + + + + )} + {!loading && diagnostics && ( + + {diagnostics.checks.map((check, index) => ( + + {index > 0 && } + + + + {t(`telephony.diagnostics.checks.${check.id}`, { + defaultValue: check.label, + })} + + {check.details && ( + + {formatDetailsForDisplay(check.details)} + + )} + + + {check.status !== 'pass' && + check.action === 'openDefaultAppsSettings' && ( + + + + )} + + {t(statusLabelKey(check.status), { + defaultValue: STATUS_FALLBACK[check.status], + })} + + + + + ))} + + + + {t('telephony.diagnostics.platform', { + defaultValue: 'Platform', + })} + {': '} + {formatPlatformForDisplay(diagnostics.platform)} + + + {t('telephony.diagnostics.lastChecked', { + defaultValue: 'Last checked', + })} + {': '} + {new Date(diagnostics.generatedAt).toLocaleString()} + + + + )} + + + ); +}; diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx new file mode 100644 index 0000000000..7ce4844b7e --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.spec.tsx @@ -0,0 +1,285 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET } from '../../../../telephony/actions'; +import { TelephonyGlobalShortcut } from './TelephonyGlobalShortcut'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { defaultValue?: string } & Record + ) => { + if (!options) return key; + const { defaultValue, ...rest } = options; + if (defaultValue !== undefined && Object.keys(rest).length === 0) { + return defaultValue; + } + const interpolated = Object.entries(rest) + .map(([name, value]) => `${name}=${String(value)}`) + .join(' '); + return interpolated ? `${key} ${interpolated}` : key; + }, + }), +})); + +jest.mock('@rocket.chat/fuselage', () => ({ + Box: ({ + children, + is: component = 'div', + display: _display, + alignItems: _alignItems, + flexGrow: _flexGrow, + ...props + }: any) => { + const Component = component; + return {children}; + }, + Button: ({ children, mis: _mis, ...props }: any) => ( + + ), + Field: ({ children }: any) =>
{children}
, + FieldHint: ({ children, ...props }: any) =>
{children}
, + FieldLabel: ({ children, ...props }: any) => ( + + ), + FieldRow: ({ children }: any) =>
{children}
, + TextInput: (props: any) => , +})); + +type PartialState = Pick< + RootState, + | 'telephonyGlobalShortcutConfig' + | 'telephonyGlobalShortcutRegistrationStatus' + | 'isTelephonyEnabled' +>; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const defaultState: PartialState = { + isTelephonyEnabled: true, + telephonyGlobalShortcutConfig: { + enabled: false, + accelerator: null, + }, + telephonyGlobalShortcutRegistrationStatus: { + registered: false, + accelerator: null, + error: null, + }, +}; + +const originalPlatform = process.platform; + +describe('TelephonyGlobalShortcut', () => { + beforeAll(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('saves an accelerator captured from a key chord', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'd', + ctrlKey: true, + shiftKey: true, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + }); + + it('captures a pressed key chord and renders it in the input', () => { + const store = makeStore(defaultState); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'd', + ctrlKey: true, + shiftKey: true, + }); + + expect(input).toHaveValue('Ctrl+Shift+D'); + }); + + it('shows capture placeholder while the shortcut input is focused', () => { + const store = makeStore(defaultState); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + + fireEvent.focus(input); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.capturePlaceholder' + ); + + fireEvent.blur(input); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + }); + + it('clears the accelerator and disables registration', () => { + const store = makeStore({ + ...defaultState, + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('telephony-shortcut-clear')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: false, + accelerator: null, + }, + }); + }); + + it('does not save reserved copy/paste accelerators', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'c', + ctrlKey: true, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(screen.getByText(/Ctrl\+C/)).toBeInTheDocument(); + }); + + it('does not save accelerators used by the app menu', () => { + const store = makeStore(defaultState); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'n', + ctrlKey: true, + }); + fireEvent.click(screen.getByTestId('telephony-shortcut-save')); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('restores the saved accelerator when capture is cancelled with Escape', () => { + const store = makeStore({ + ...defaultState, + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + }); + + render( + + + + ); + + const input = screen.getByTestId('telephony-shortcut-input'); + fireEvent.keyDown(input, { + key: 'e', + ctrlKey: true, + shiftKey: true, + }); + expect(input).toHaveValue('Ctrl+Shift+E'); + + fireEvent.keyDown(input, { key: 'Escape' }); + + expect(input).toHaveValue('Ctrl+Shift+D'); + expect(input).toHaveAttribute( + 'placeholder', + 'settings.options.telephonyShortcut.placeholder' + ); + }); + + it('shows registration failure feedback from main process status', () => { + const store = makeStore({ + isTelephonyEnabled: true, + telephonyGlobalShortcutConfig: { + enabled: true, + accelerator: 'CommandOrControl+Shift+D', + }, + telephonyGlobalShortcutRegistrationStatus: { + registered: false, + accelerator: 'CommandOrControl+Shift+D', + error: 'Shortcut already in use', + }, + }); + + render( + + + + ); + + expect(screen.getByText('Shortcut already in use')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx new file mode 100644 index 0000000000..ae0e9e5cd3 --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyGlobalShortcut.tsx @@ -0,0 +1,230 @@ +import { + Box, + Button, + Field, + FieldHint, + FieldLabel, + FieldRow, + TextInput, +} from '@rocket.chat/fuselage'; +import type { KeyboardEvent } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { formatAcceleratorForDisplay } from '../../../../telephony/acceleratorDisplay'; +import { TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET } from '../../../../telephony/actions'; +import { + isReservedTelephonyShortcutAccelerator, + normalizeTelephonyShortcutAccelerator, +} from '../../../../telephony/shortcuts'; + +const normalizeShortcutText = (value: string): string | null => + normalizeTelephonyShortcutAccelerator(value); + +const keyToAcceleratorPart = (key: string): string | null => { + if (['Control', 'Meta', 'Shift', 'Alt'].includes(key)) { + return null; + } + + if (key === ' ') { + return 'Space'; + } + + if (/^[a-z]$/i.test(key)) { + return key.toUpperCase(); + } + + return key.length === 1 ? key.toUpperCase() : key; +}; + +const eventToAccelerator = (event: KeyboardEvent) => { + const key = keyToAcceleratorPart(event.key); + if (!key) { + return null; + } + + const parts = []; + + if (event.ctrlKey || event.metaKey) { + parts.push('CommandOrControl'); + } + + if (event.altKey) { + parts.push('Alt'); + } + + if (event.shiftKey) { + parts.push('Shift'); + } + + parts.push(key); + + return parts.join('+'); +}; + +export const TelephonyGlobalShortcut = () => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const telephonyGlobalShortcutConfig = useSelector( + ({ telephonyGlobalShortcutConfig }: RootState) => + telephonyGlobalShortcutConfig + ); + const telephonyGlobalShortcutRegistrationStatus = useSelector( + ({ telephonyGlobalShortcutRegistrationStatus }: RootState) => + telephonyGlobalShortcutRegistrationStatus + ); + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); + const [draftAccelerator, setDraftAccelerator] = useState( + telephonyGlobalShortcutConfig.accelerator ?? '' + ); + const [isCapturingShortcut, setIsCapturingShortcut] = useState(false); + const [validationError, setValidationError] = useState(null); + + useEffect(() => { + setDraftAccelerator(telephonyGlobalShortcutConfig.accelerator ?? ''); + }, [telephonyGlobalShortcutConfig.accelerator]); + + const saveShortcut = useCallback( + (value: string) => { + const accelerator = normalizeShortcutText(value); + if (accelerator && isReservedTelephonyShortcutAccelerator(accelerator)) { + setValidationError( + t('settings.options.telephonyShortcut.reservedByApp', { + accelerator: formatAcceleratorForDisplay(accelerator), + }) + ); + return; + } + + setValidationError(null); + dispatch({ + type: TELEPHONY_GLOBAL_SHORTCUT_CONFIG_SET, + payload: { + enabled: Boolean(accelerator), + accelerator, + }, + }); + }, + [dispatch, t] + ); + + const handleFocus = useCallback(() => { + setIsCapturingShortcut(true); + }, []); + + const handleBlur = useCallback(() => { + setIsCapturingShortcut(false); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const accelerator = eventToAccelerator(event); + if (event.key === 'Escape') { + event.preventDefault(); + setDraftAccelerator(telephonyGlobalShortcutConfig.accelerator ?? ''); + setIsCapturingShortcut(false); + setValidationError(null); + return; + } + + if (!accelerator) { + return; + } + + event.preventDefault(); + setDraftAccelerator(accelerator); + setIsCapturingShortcut(false); + setValidationError(null); + }, + [telephonyGlobalShortcutConfig.accelerator] + ); + + const handleSave = useCallback(() => { + saveShortcut(draftAccelerator); + }, [draftAccelerator, saveShortcut]); + + const handleClear = useCallback(() => { + setDraftAccelerator(''); + saveShortcut(''); + }, [saveShortcut]); + + const isRegistered = + telephonyGlobalShortcutConfig.enabled && + telephonyGlobalShortcutRegistrationStatus.registered && + telephonyGlobalShortcutRegistrationStatus.accelerator === + telephonyGlobalShortcutConfig.accelerator; + + return ( + + + {t('settings.options.telephonyShortcut.title')} + + + + {t('settings.options.telephonyShortcut.description')} + + + + + + + + + + {telephonyGlobalShortcutRegistrationStatus.error && ( + + + {telephonyGlobalShortcutRegistrationStatus.error} + + + )} + {validationError && ( + + {validationError} + + )} + {isRegistered && ( + + + {t('settings.options.telephonyShortcut.registered')} + + + )} + + ); +}; diff --git a/src/ui/components/SettingsView/features/TelephonyServer.spec.tsx b/src/ui/components/SettingsView/features/TelephonyServer.spec.tsx new file mode 100644 index 0000000000..0a7c0dc8cc --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyServer.spec.tsx @@ -0,0 +1,198 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { Key } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../../../../telephony/actions'; +import { TelephonyServer } from './TelephonyServer'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Fuselage Select is a custom ARIA dropdown — mock it to a native onChange(e.target.value)} + > + {options.map(([val, label]) => ( + + ))} + + ), + }; +}); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +describe('TelephonyServer', () => { + it('renders nothing when servers is empty', () => { + const store = makeStore({ servers: [], telephonyPreferredServer: null }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when servers.length === 1', () => { + const store = makeStore({ + servers: [{ url: 'https://chat.example.com', title: 'Example' }], + telephonyPreferredServer: null, + }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders Select with N+1 options when servers.length >= 2', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + const options = select.querySelectorAll('option'); + // auto + 2 servers = 3 + expect(options).toHaveLength(3); + expect(options[0]).toHaveValue('auto'); + expect(options[1]).toHaveValue('https://chat.alpha.com'); + expect(options[2]).toHaveValue('https://chat.beta.com'); + }); + + it('shows telephonyPreferredServer as current Select value', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: 'https://chat.beta.com', + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + expect(select.value).toBe('https://chat.beta.com'); + }); + + it('shows "auto" as Select value when telephonyPreferredServer is null', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + expect(select.value).toBe('auto'); + }); + + it('onChange to a server URL dispatches TELEPHONY_PREFERRED_SERVER_SET with the URL', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + fireEvent.change(select, { target: { value: 'https://chat.alpha.com' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: 'https://chat.alpha.com', + }); + }); + + it('onChange to "auto" dispatches TELEPHONY_PREFERRED_SERVER_SET with null payload', () => { + const store = makeStore({ + servers: [ + { url: 'https://chat.alpha.com', title: 'Alpha' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: 'https://chat.alpha.com', + }); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + const select = screen.getByTestId('telephony-select'); + fireEvent.change(select, { target: { value: 'auto' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: null, + }); + }); + + it('falls back to hostname when server has no title', () => { + const store = makeStore({ + servers: [ + { url: 'https://example.rocketchat.com' }, + { url: 'https://chat.beta.com', title: 'Beta' }, + ], + telephonyPreferredServer: null, + }); + render( + + + + ); + const option = screen.getByRole('option', { + name: 'example.rocketchat.com', + }); + expect(option).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/features/TelephonyServer.tsx b/src/ui/components/SettingsView/features/TelephonyServer.tsx new file mode 100644 index 0000000000..a67a8f813e --- /dev/null +++ b/src/ui/components/SettingsView/features/TelephonyServer.tsx @@ -0,0 +1,110 @@ +import { css } from '@rocket.chat/css-in-js'; +import { + Box, + Field, + FieldLabel, + FieldHint, + Select, +} from '@rocket.chat/fuselage'; +import { useCallback, useMemo } from 'react'; +import type { Key } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import type { RootAction } from '../../../../store/actions'; +import type { RootState } from '../../../../store/rootReducer'; +import { TELEPHONY_PREFERRED_SERVER_SET } from '../../../../telephony/actions'; + +const safeHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + +const selectWrapperStyle = css` + max-width: 100%; + + .rcx-select { + white-space: nowrap; + } + + .rcx-select > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +`; + +export const TelephonyServer = () => { + const servers = useSelector(({ servers }: RootState) => servers); + const telephonyPreferredServer = useSelector( + ({ telephonyPreferredServer }: RootState) => telephonyPreferredServer + ); + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); + const dispatch = useDispatch>(); + const { t } = useTranslation(); + + const handleChange = useCallback( + (value: Key) => { + const stringValue = String(value); + dispatch({ + type: TELEPHONY_PREFERRED_SERVER_SET, + payload: stringValue === 'auto' ? null : stringValue, + }); + }, + [dispatch] + ); + + const options = useMemo( + (): [string, string][] => [ + ['auto', t('settings.options.telephonyServer.auto')], + ...servers.map((s): [string, string] => [ + s.url, + s.title ?? safeHostname(s.url), + ]), + ], + [servers, t] + ); + + if (servers.length <= 1) { + return null; + } + + return ( + + + + {t('settings.options.telephonyServer.title')} + + {t('settings.options.telephonyServer.description')} + + + +