diff --git a/.gitignore b/.gitignore index 932cbb258e..fa7bd2f20a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,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 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 e5681946f2..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: 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..03823406e3 100644 --- a/build/msiProjectCreated.js +++ b/build/msiProjectCreated.js @@ -93,6 +93,204 @@ 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" + If Err.Number <> 0 Then + writeErr = Err.Description + Err.Clear + Err.Raise 1, "WriteTelephonyCapabilities", "Failed to write HKLM\\SOFTWARE\\Classes\\RocketChat.tel: " & writeErr + End If + 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" + If Err.Number <> 0 Then + writeErr = Err.Description + Err.Clear + Err.Raise 1, "WriteTelephonyCapabilities", "Failed to write HKLM\\SOFTWARE\\RegisteredApplications\\Rocket.Chat: " & writeErr + End If + ]]> + + + + `; // -- 2. Scheduling entries (only during install, not uninstall) -- @@ -105,9 +303,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 +371,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 9d19612af8..5182b87515 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -1,6 +1,13 @@ { "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"] }, diff --git a/package.json b/package.json index 1c52e6ab4f..23971e8979 100644 --- a/package.json +++ b/package.json @@ -111,10 +111,10 @@ "@rollup/plugin-json": "~6.1.0", "@rollup/plugin-node-resolve": "~15.2.3", "@rollup/plugin-replace": "~5.0.5", - "@testing-library/dom": "^9.3.4", - "@testing-library/jest-dom": "^6.4.8", - "@testing-library/react": "^14.3.1", - "@testing-library/user-event": "^14.5.2", + "@testing-library/dom": "~10.4.1", + "@testing-library/jest-dom": "~6.9.1", + "@testing-library/react": "~16.3.2", + "@testing-library/user-event": "~14.5.2", "@types/archiver": "~7.0.0", "@types/dompurify": "~3.2.0", "@types/electron-devtools-installer": "~2.2.5", @@ -153,7 +153,8 @@ "ts-jest": "~29.1.4", "ts-node": "~10.9.2", "typescript": "~5.7.3", - "xvfb-maybe": "~0.2.1" + "xvfb-maybe": "~0.2.1", + "yaml": "^1.10.2" }, "optionalDependencies": { "fsevents": "2.3.3" 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..6d86c9d32f --- /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').replace(/\r\n/g, '\n'); + 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..a01d4610e1 --- /dev/null +++ b/qa/scripts/validate-flows.mjs @@ -0,0 +1,245 @@ +#!/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(/^---\r?\n([\s\S]*?)\r?\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`); + } + } + // qase_id must be present but may be null for not-yet-imported flows. + if (!('qase_id' in frontmatter.qase)) { + errors.push(`${file}: frontmatter.qase.qase_id 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 6767e3bf70..b9685bd2ac 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -3,6 +3,7 @@ import type { Certificate } from 'electron'; import { DEFAULT_E2E_PDF_PREVIEW_SIZE_LIMIT_MB } from '../constants'; 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 = { @@ -107,7 +108,13 @@ type PersistableValues_4_13_0 = PersistableValues_4_11_0 & { isDebugLoggingEnabled: boolean; }; -type PersistableValues_4_15_0 = PersistableValues_4_13_0 & { +type PersistableValues_4_14_0 = PersistableValues_4_13_0 & { + isTelephonyEnabled: boolean; + telephonyPreferredServer: string | null; + telephonyGlobalShortcutConfig: TelephonyGlobalShortcutConfig; +}; + +type PersistableValues_4_15_0 = PersistableValues_4_14_0 & { e2ePdfPreviewSizeLimit: number; }; @@ -206,7 +213,23 @@ export const migrations = { ...before, isDebugLoggingEnabled: false, }), - '>=4.15.0': (before: PersistableValues_4_13_0): PersistableValues_4_15_0 => ({ + '>=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: + (before as Partial) + .telephonyGlobalShortcutConfig?.enabled ?? false, + accelerator: + (before as Partial) + .telephonyGlobalShortcutConfig?.accelerator ?? null, + }, + }), + '>=4.15.0': (before: PersistableValues_4_14_0): PersistableValues_4_15_0 => ({ ...before, e2ePdfPreviewSizeLimit: DEFAULT_E2E_PDF_PREVIEW_SIZE_LIMIT_MB, }), 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 fce653bb9f..a5c2eaeab6 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -47,6 +47,8 @@ export const electronBuilderJsonInformation = { ).flatMap((p) => p.schemes), }; +export const TELEPHONY_SCHEMES = ['tel', 'callto'] as const; + let isScreenCaptureFallbackForced = false; export const getPlatformName = (): string => { @@ -88,6 +90,9 @@ export const relaunchApp = (...args: string[]): void => { export const performElectronStartup = (): void => { for (const scheme of electronBuilderJsonInformation.protocols) { + if ((TELEPHONY_SCHEMES as readonly string[]).includes(scheme)) { + continue; + } app.setAsDefaultProtocolClient(scheme); } app.setAppUserModelId(electronBuilderJsonInformation.appId); diff --git a/src/app/selectors.ts b/src/app/selectors.ts index 52c7a74e57..fa1f910a95 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -87,4 +87,8 @@ export const selectPersistableValues = createStructuredSelector({ e2ePdfPreviewSizeLimit, 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 index 4d349d8da1..03814f51dd 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -1,14 +1,19 @@ -import { dialog } from 'electron'; +import { app } from 'electron'; import { ServerUrlResolutionStatus } from '../servers/common'; import { resolveServerUrl } from '../servers/main'; -import { select, dispatch } from '../store'; +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, @@ -22,7 +27,6 @@ jest.mock('electron', () => ({ getPath: jest.fn(), getName: jest.fn(() => 'Rocket.Chat'), }, - dialog: { showMessageBox: jest.fn() }, })); jest.mock('../store'); jest.mock('../ui/main/serverView'); @@ -36,17 +40,18 @@ jest.mock('../app/main/app', () => ({ 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 dialogMock = dialog as jest.Mocked; 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; @@ -56,6 +61,41 @@ describe('deepLinks/main.ts', () => { 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'); @@ -122,6 +162,22 @@ describe('deepLinks/main.ts', () => { }); }); + 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({ @@ -134,6 +190,7 @@ describe('deepLinks/main.ts', () => { describe('performTelephonyCall', () => { const mockWebContents = { send: jest.fn(), + isDestroyed: jest.fn(() => false), }; const mockLink: TelephonyLink = { @@ -152,7 +209,7 @@ describe('deepLinks/main.ts', () => { await performTelephonyCall(mockLink); expect(getWebContentsByServerUrlMock).not.toHaveBeenCalled(); - expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); }); it('should auto-select when there is 1 server', async () => { @@ -172,7 +229,23 @@ describe('deepLinks/main.ts', () => { rawUri: 'tel:+491234567890', } ); - expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + 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 () => { @@ -183,20 +256,16 @@ describe('deepLinks/main.ts', () => { ]) .mockReturnValueOnce(null); - dialogMock.showMessageBox.mockResolvedValue({ - response: 0, - checkboxChecked: false, - } as any); + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: false, + }); await performTelephonyCall(mockLink); - expect(dialogMock.showMessageBox).toHaveBeenCalledWith(mockRootWindow, { - type: 'question', - title: 'Select Server', - message: 'Which server should handle this call?', - buttons: ['Server 1', 'Server 2'], - checkboxLabel: 'Remember this choice', - checkboxChecked: false, + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: '+491234567890', rawUri: 'tel:+491234567890' }, }); expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( @@ -218,7 +287,7 @@ describe('deepLinks/main.ts', () => { await performTelephonyCall(mockLink); - expect(dialogMock.showMessageBox).not.toHaveBeenCalled(); + expect(listenMock).not.toHaveBeenCalled(); expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( 'https://server2.com' ); @@ -236,14 +305,16 @@ describe('deepLinks/main.ts', () => { ]) .mockReturnValueOnce('https://stale-server.com'); - dialogMock.showMessageBox.mockResolvedValue({ - response: 1, - checkboxChecked: false, - } as any); + simulateModalResponse({ + serverUrl: 'https://server2.com', + rememberChoice: false, + }); await performTelephonyCall(mockLink); - expect(dialogMock.showMessageBox).toHaveBeenCalled(); + expect(dispatchMock).toHaveBeenCalledWith( + expect.objectContaining({ type: TELEPHONY_SERVER_SELECT_OPEN }) + ); expect(getWebContentsByServerUrlMock).toHaveBeenCalledWith( 'https://server2.com' ); @@ -257,10 +328,10 @@ describe('deepLinks/main.ts', () => { ]) .mockReturnValueOnce(null); - dialogMock.showMessageBox.mockResolvedValue({ - response: 0, - checkboxChecked: true, - } as any); + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: true, + }); await performTelephonyCall(mockLink); @@ -278,17 +349,19 @@ describe('deepLinks/main.ts', () => { ]) .mockReturnValueOnce(null); - dialogMock.showMessageBox.mockResolvedValue({ - response: 1, - checkboxChecked: false, - } as any); + simulateModalResponse({ + serverUrl: 'https://server2.com', + rememberChoice: false, + }); await performTelephonyCall(mockLink); - expect(dispatchMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalledWith( + expect.objectContaining({ type: TELEPHONY_PREFERRED_SERVER_SET }) + ); }); - it('should use hostname as button label when server title is missing', async () => { + it('should dispatch open action even when server titles are missing', async () => { selectMock .mockReturnValueOnce([ { url: 'https://server1.com' }, @@ -296,19 +369,17 @@ describe('deepLinks/main.ts', () => { ]) .mockReturnValueOnce(null); - dialogMock.showMessageBox.mockResolvedValue({ - response: 0, - checkboxChecked: false, - } as any); + simulateModalResponse({ + serverUrl: 'https://server1.com', + rememberChoice: false, + }); await performTelephonyCall(mockLink); - expect(dialogMock.showMessageBox).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - buttons: ['server1.com', 'server2.com'], - }) - ); + expect(dispatchMock).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_OPEN, + payload: { phoneNumber: '+491234567890', rawUri: 'tel:+491234567890' }, + }); }); it('should poll for webContents when not immediately available', async () => { @@ -337,6 +408,88 @@ describe('deepLinks/main.ts', () => { 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', () => { @@ -348,6 +501,7 @@ describe('deepLinks/main.ts', () => { const mockWebContents = { send: jest.fn(), + isDestroyed: jest.fn(() => false), loadURL: jest.fn(), }; @@ -385,6 +539,84 @@ describe('deepLinks/main.ts', () => { 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(); @@ -411,8 +643,129 @@ describe('deepLinks/main.ts', () => { // Normal deep link path taken expect(resolveServerUrlMock).toHaveBeenCalled(); - // Telephony dialog NOT shown (telephony branch skipped) - expect(dialogMock.showMessageBox).not.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(), + isDestroyed: jest.fn(() => false), + 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(); }); }); }); diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index 478cd575cf..e082f52201 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -1,5 +1,5 @@ import type { WebContents } from 'electron'; -import { app, dialog } from 'electron'; +import { app } from 'electron'; import { electronBuilderJsonInformation, @@ -8,7 +8,8 @@ import { import { ServerUrlResolutionStatus } from '../servers/common'; import { resolveServerUrl } from '../servers/main'; import { select, dispatch } from '../store'; -import { TELEPHONY_PREFERRED_SERVER_SET } from '../telephony/actions'; +import { openTelephonyDialpad } from '../telephony/dialpad'; +import { parseTelephonyLink } from '../telephony/links'; import { askForServerAddition, warnAboutInvalidServerUrl, @@ -17,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}:`; @@ -55,90 +63,22 @@ const parseDeepLink = ( return null; }; -const TELEPHONY_PROTOCOLS = ['tel:', 'callto:']; - -export type TelephonyLink = { phoneNumber: string; rawUri: string }; - -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; - } - - const raw = url.pathname || url.href.slice(url.protocol.length); - const phoneNumber = raw.replace(/^\/+/, '').replace(/[\s\-().]/g, ''); - - if (!phoneNumber) { - return null; - } - - return { phoneNumber, rawUri: input }; -}; - -export const performTelephonyCall = async ( - link: TelephonyLink -): Promise => { - const servers = select(({ servers }) => servers); - - if (servers.length === 0) { - return; - } +export const getDeepLinkArgs = (argv: string[]): string[] => + argv + .slice(app.isPackaged ? 1 : 2) + .filter((arg) => parseTelephonyLink(arg) || parseDeepLink(arg)); - let serverUrl: string; +export let processDeepLinksInArgs = async (): Promise => undefined; - if (servers.length === 1) { - serverUrl = servers[0].url; - } else { - const preferredServer = select( - ({ telephonyPreferredServer }) => telephonyPreferredServer - ); +const focusRootWindow = async (): Promise => { + const browserWindow = await getRootWindow(); - if (preferredServer && servers.some((s) => s.url === preferredServer)) { - serverUrl = preferredServer; - } else { - const { response, checkboxChecked } = await dialog.showMessageBox( - await getRootWindow(), - { - type: 'question', - title: 'Select Server', - message: 'Which server should handle this call?', - buttons: servers.map((s) => s.title ?? new URL(s.url).hostname), - checkboxLabel: 'Remember this choice', - checkboxChecked: false, - } - ); - - serverUrl = servers[response].url; - - if (checkboxChecked) { - dispatch({ - type: TELEPHONY_PREFERRED_SERVER_SET, - payload: serverUrl, - }); - } - } + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); } - - const webContents = await getWebContents(serverUrl); - webContents.send('telephony/call-requested', { - phoneNumber: link.phoneNumber, - rawUri: link.rawUri, - }); + browserWindow.focus(); }; -export let processDeepLinksInArgs = async (): Promise => undefined; - type AuthenticationParams = { host: string; token: string; @@ -191,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) { @@ -200,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, @@ -265,7 +222,13 @@ const performConference = async ({ host, path }: InviteParams): Promise => const processDeepLink = async (deepLink: string): Promise => { const telephonyLink = parseTelephonyLink(deepLink); if (telephonyLink) { - await performTelephonyCall(telephonyLink); + const isTelephonyEnabled = select( + ({ isTelephonyEnabled }) => isTelephonyEnabled + ); + if (!isTelephonyEnabled) { + return; + } + await openTelephonyDialpad(telephonyLink); return; } @@ -320,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(); @@ -343,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 @@ -352,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 cf136cd33b..0018199390 100644 --- a/src/i18n/ar.i18n.json +++ b/src/i18n/ar.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "الإبلاغ عن الأخطاء إلى Rocket.Chat", + "description": "أبلغ عن المشكلات بشكل مجهول إلى مطوري التطبيق. تشمل المعلومات المشتركة رقم إصدار التطبيق ونوع نظام التشغيل وعنوان URL لمساحة العمل ولغة الجهاز ونوع الخطأ. لا تتم مشاركة أي محتوى أو أسماء مستخدمين.", + "masDescription": "يتم تعطيل هذا الخيار عند التثبيت من Mac App Store. يتم الإبلاغ عن الأخطاء من خلال عملية الإبلاغ عن الأخطاء في Mac App Store." + }, + "flashFrame": { + "title": "وميض النافذة", + "titleDarwin": "ارتداد أيقونة الإرساء", + "description": "اجعل النافذة تومض عند استلام رسالة جديدة.", + "onLinux": "بعض توزيعات Linux لا تدعم هذه الميزة.", + "descriptionDarwin": "ترتد أيقونة الإرساء عند استلام رسالة جديدة." + }, + "hardwareAcceleration": { + "title": "تسريع الأجهزة", + "description": "يحسّن العرض المرئي والأداء. عطّله إذا واجهت مشكلات في العرض أو عدم استقرار.", + "hint": "يعيد تحميل التطبيق عند التغيير." + }, + "videoCallScreenCaptureFallback": { + "title": "البديل لالتقاط الشاشة في مكالمات الفيديو", + "description": "عطّل Windows Graphics Capture لكي تعمل مشاركة الشاشة في جلسات سطح المكتب البعيد.", + "hint": "تتم إعادة تشغيل التطبيق عند تغيير هذا الخيار.", + "forcedDescription": "مفروض حاليًا لأن التطبيق اكتشف جلسة سطح مكتب بعيد. يتحكم المفتاح في عمليات التشغيل المستقبلية عند التشغيل محليًا." + }, + "internalVideoChatWindow": { + "title": "مكالمات الفيديو داخل التطبيق", + "description": "افتح مكالمات الفيديو داخل نافذة التطبيق بدلاً من المتصفح. لا يمكن فتح مكالمات Google Meet وJitsi إلا في المتصفح.", + "masDescription": "يتم تعطيل هذا الخيار عند التثبيت من Mac App Store. لأسباب أمنية، تُفتح مكالمات الفيديو دائمًا في المتصفح افتراضيًا." + }, + "minimizeOnClose": { + "title": "التصغير عند الإغلاق", + "description": "صغّر التطبيق ولا تغلقه عند إغلاق النافذة.", + "disabledHint": "يجب تعطيل أيقونة شريط النظام." + }, + "menubar": { + "title": "شريط القوائم", + "description": "أظهر شريط القوائم في أعلى النافذة.", + "disabledHint": "لا يمكن تعطيل شريط القوائم عند تعطيل شريط مساحة العمل. ستصبح إعدادات التطبيق غير قابلة للوصول." + }, + "sidebar": { + "title": "شريط مساحة العمل", + "description": "أظهر قائمة مساحات العمل مع أزرار التنزيلات والإعدادات.", + "disabledHint": "لا يمكن تعطيل شريط مساحة العمل عند تعطيل شريط القوائم. ستصبح إعدادات التطبيق غير قابلة للوصول." + }, + "trayIcon": { + "title": "أيقونة شريط النظام", + "titleDarwin": "إضافة شريط القوائم", + "description": "أظهر أيقونة في شريط النظام. بدلاً من الإغلاق، يُخفى التطبيق في شريط النظام عند الإغلاق عند التمكين.", + "descriptionDarwin": "أظهر أيقونة التطبيق في شريط القوائم." + }, + "availableBrowsers": { + "title": "المتصفح الافتراضي", + "description": "اختر المتصفح الذي تُفتح فيه الروابط الخارجية.", + "systemDefault": "افتراضي النظام", + "loading": "جارٍ تحميل المتصفحات...", + "current": "يُستخدم حاليًا:" + }, + "telephonyServer": { + "title": "مساحة عمل الهاتف", + "description": "اختر مساحة العمل التي تتعامل مع المكالمات الواردة (روابط tel: وcallto:).", + "auto": "تلقائي (اسأل في كل مرة)" + }, + "clearPermittedScreenCaptureServers": { + "title": "مسح أذونات التقاط الشاشة", + "description": "امسح الأذونات حتى لو تم اختيار “عدم السؤال مرة أخرى” سابقًا للمكالمات." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "السماح بالتقاط الشاشة في مكالمات الفيديو", + "description": "اسمح بالتقاط الشاشة في مكالمات الفيديو. يطلب الإذن في كل مكالمة فيديو." + }, + "ntlmCredentials": { + "title": "بيانات اعتماد NTLM", + "description": "اسمح باستخدام بيانات اعتماد NTLM عند الاتصال بمساحة عمل.", + "domains": "النطاقات التي ستُستخدم كبيانات اعتماد. مفصولة بفاصلة. استخدم * لمطابقة جميع الخوادم." + }, + "videoCallWindowPersistence": { + "title": "الاحتفاظ بموضع نافذة مكالمة الفيديو", + "description": "تذكّر موضع وحجم نافذة مكالمة الفيديو بين الجلسات." + }, "transparentWindow": { "title": "تأثير النافذة الشفافة", - "description": "تمكين تأثير الاهتزاز/الشفافية الأصلي للنافذة. يتطلب إعادة التشغيل للتطبيق." + "description": "تمكين تأثير الاهتزاز/الشفافية الأصلي للنافذة.", + "hint": "يتطلب إعادة التشغيل للتطبيق." }, "themeAppearance": { "title": "المظهر", @@ -18,9 +97,52 @@ "auto": "اتباع النظام", "light": "فاتح", "dark": "داكن" + }, + "telephonyServer": { + "title": "خادم الاتصالات الهاتفية", + "description": "اختر مساحة العمل التي تفتح عند استخدام اختصار الاتصالات الهاتفية أو رابط tel: أو callto:.", + "auto": "تلقائي (السؤال في كل مرة)" + }, + "telephonyShortcut": { + "title": "اختصار الاتصالات الهاتفية العام", + "description": "استخدم هذا الاختصار من أي مكان لإحضار Rocket.Chat إلى المقدمة وفتح لوحة الاتصال الهاتفية. إذا كانت الحافظة تحتوي على نص يبدو كرقم هاتف، فسيقوم Rocket.Chat بتعبئته مسبقًا.", + "placeholder": "CommandOrControl+Shift+D", + "capturePlaceholder": "اضغط على المفاتيح...", + "save": "حفظ", + "clear": "مسح", + "registered": "تم تسجيل الاختصار", + "reservedByApp": "{{accelerator}} مستخدم بالفعل بواسطة Rocket.Chat. اختر تركيبة أخرى.", + "reservedByOS": "{{accelerator}} محجوز بواسطة نظام التشغيل لديك. اختر تركيبة أخرى." + }, + "outlookCalendarSyncInterval": { + "title": "فترة مزامنة تقويم Outlook", + "description": "عدد مرات التحقق من تحديثات أحداث التقويم بالدقائق (1–60)." + }, + "verboseOutlookLogging": { + "title": "تسجيل مفصّل لتقويم Outlook", + "description": "فعّل سجلات تصحيح Exchange/NTLM المفصّلة لاستكشاف مشكلات تكامل تقويم Outlook وإصلاحها." + }, + "detailedEventsLogging": { + "title": "تسجيل مفصّل للأحداث", + "description": "سجّل بيانات الأحداث الكاملة المتبادلة بين Outlook وRocket.Chat أثناء مزامنة التقويم. مفيد لتشخيص مشكلات المزامنة." + }, + "debugLogging": { + "title": "تسجيل مفصّل", + "description": "يكتب كل مخرجات وحدة التحكم إلى ملف السجل. عند التعطيل، تُحفظ الأخطاء والرسائل المهمة فقط، مما يبقي السجلات أصغر وأكثر تركيزًا." + }, + "e2ePdfPreviewSizeLimit": { + "title": "حد حجم معاينة PDF في الغرف المشفّرة (ميغابايت)", + "description": "تُحمّل الملفات المشفّرة في الذاكرة للمعاينات. الملفات الأكبر من هذا الحد سيتم تنزيلها مباشرة." } } }, + "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 1b0fcebf66..8afd335b0d 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": { @@ -206,84 +246,166 @@ } }, "settings": { - "title": "Einstellungen", + "title": "App-Einstellungen", "general": "Allgemein", "certificates": "Zertifikate", + "developer": "Entwickler", + "voiceVideo": "Sprache & Video", + "sections": { + "appUi": "App-Oberfläche", + "systemUi": "System-Oberfläche", + "systemBehavior": "Systemverhalten", + "calling": "Anrufe", + "other": "Sonstiges & Technik", + "logging": "Protokollierung", + "telephony": "Telefonie", + "videoCalls": "Videoanrufe" + }, "options": { "report": { - "title": "Fehler an Entwickler melden", - "description": "Melden Sie Fehler anonym an die Entwickler. Zu den freigegebenen Informationen gehören App-Versionsnummer, Betriebssystemtyp, Server-URL, Gerätesprache und Fehlertyp. Es werden keine Inhalte oder Benutzernamen geteilt.", - "masDescription": "Diese Option ist bei der Installation aus dem Mac App Store deaktiviert, die Fehler werden über den Fehlerberichtsprozess des Mac App Store gemeldet." + "title": "Fehler an Rocket.Chat melden", + "description": "Fehler anonym an die App-Entwickler melden. Zu den freigegebenen Informationen gehören App-Versionsnummer, Betriebssystemtyp, Workspace-URL, Gerätesprache und Fehlertyp. Es werden keine Inhalte oder Benutzernamen geteilt.", + "masDescription": "Diese Option ist bei der Installation aus dem Mac App Store deaktiviert. Fehler werden über den Fehlerberichtsprozess des Mac App Store gemeldet." }, "flashFrame": { - "title": "Flashframe aktivieren", - "titleDarwin": "Schalten Sie Dock Bounce bei Alarm um", - "description": "Lässt das Fenster blinken, um die Aufmerksamkeit des Benutzers zu erregen.", + "title": "Fenster aufblinken lassen", + "titleDarwin": "Dock-Symbol animieren", + "description": "Fenster aufblinken lassen, wenn eine neue Nachricht empfangen wird.", "onLinux": "Einige Linux-Distributionen unterstützen diese Funktion nicht.", - "descriptionDarwin": "Lässt das App-Symbol im Dock hüpfen, um die Aufmerksamkeit des Benutzers zu erregen." + "descriptionDarwin": "Das Dock-Symbol springt auf, wenn eine neue Nachricht empfangen wird." }, "hardwareAcceleration": { "title": "Hardware-Beschleunigung", - "description": "Aktiviert die Verwendung der Hardwarebeschleunigung, sofern verfügbar. Die Anwendung wird bei Änderung neu geladen." + "description": "Verbessert die Darstellung und Leistung. Deaktivieren, wenn grafische Fehler oder Instabilität auftreten.", + "hint": "Die App wird bei Änderung neu geladen." }, "videoCallScreenCaptureFallback": { "title": "Alternative Bildschirmaufnahme für Videoanrufe", - "description": "Deaktiviert Windows Graphics Capture, damit das Teilen in RDP-Sitzungen funktioniert. Die App startet neu, wenn Sie diese Option ändern.", - "forcedDescription": "Bereits aktiv, weil die Anwendung eine RDP-Sitzung erkannt hat. Der Schalter bestimmt das Verhalten bei zukünftigen lokalen Starts." + "description": "Deaktiviert Windows Graphics Capture, damit die Bildschirmfreigabe in Remote-Desktop-Sitzungen funktioniert.", + "hint": "Die App startet neu, wenn diese Option geändert wird.", + "forcedDescription": "Derzeit erzwungen, weil die App eine Remote-Desktop-Sitzung erkannt hat. Der Schalter bestimmt das Verhalten bei zukünftigen lokalen Starts." }, "internalVideoChatWindow": { - "title": "Öffnen Sie den Video-Chat mit dem Anwendungsfenster", - "description": "Wenn diese Option aktiviert ist, wird der Video-Chat im Anwendungsfenster anstelle des Standardbrowsers geöffnet. Allerdings wird für Google Meet und Jitsi die Bildschirmaufzeichnung in Electron-Anwendungen nicht unterstützt, daher werden sie unabhängig von dieser Einstellung immer im Browser geöffnet.", - "masDescription": "Diese Option ist deaktiviert, wenn sie aus dem Mac App Store installiert wird. Aus Sicherheitsgründen wird der Video-Chat standardmäßig über den Browser geöffnet." + "title": "Videoanrufe innerhalb der App", + "description": "Videoanrufe im App-Fenster anstatt im Browser öffnen. Google Meet- und Jitsi-Anrufe können nur im Browser geöffnet werden.", + "masDescription": "Diese Option ist deaktiviert, wenn sie aus dem Mac App Store installiert wird. Aus Sicherheitsgründen werden Videoanrufe standardmäßig im Browser geöffnet." }, "minimizeOnClose": { "title": "Beim Schließen minimieren", - "description": "Beim Schließen wird die App minimiert, andernfalls wird die Anwendung beendet. Das Taskleistensymbol muss deaktiviert werden, damit dies wirksam wird." + "description": "App beim Schließen minimieren, anstatt sie zu beenden.", + "disabledHint": "Das Tray-Symbol muss deaktiviert sein." }, "menubar": { "title": "Menüleiste", "description": "Menüleiste oben im Fenster anzeigen.", - "disabledHint": "Die Menüleiste kann nicht deaktiviert werden, wenn die Seitenleiste bereits deaktiviert ist. Die Einstellungen wären sonst nicht erreichbar." + "disabledHint": "Die Menüleiste kann nicht deaktiviert werden, wenn die Workspace-Leiste deaktiviert ist. Die App-Einstellungen wären sonst nicht erreichbar." }, "sidebar": { - "title": "Seitenleiste", - "description": "Seitenleiste auf der linken Seite des Fensters mit Serverliste, Downloads und Einstellungen anzeigen.", - "disabledHint": "Die Seitenleiste kann nicht deaktiviert werden, wenn die Menüleiste bereits deaktiviert ist. Die Einstellungen wären sonst nicht erreichbar." + "title": "Workspace-Leiste", + "description": "Workspace-Liste mit Download- und Einstellungsschaltflächen anzeigen.", + "disabledHint": "Die Workspace-Leiste kann nicht deaktiviert werden, wenn die Menüleiste deaktiviert ist. Die App-Einstellungen wären sonst nicht erreichbar." }, "trayIcon": { "title": "Taskleistensymbol", - "description": "Zeigt ein Symbol in der Systemleiste an. Wenn das Taskleistensymbol aktiv ist, wird die App beim Schließen in der Taskleiste minimiert. Andernfalls wird die Anwendung beendet." + "titleDarwin": "Menüleisten-Extra", + "description": "Symbol in der Systemleiste anzeigen. Wenn aktiviert, wird die App beim Schließen in die Taskleiste minimiert anstatt beendet.", + "descriptionDarwin": "App-Symbol in der Menüleiste anzeigen." }, "availableBrowsers": { "title": "Standard-Browser", - "description": "Wählen Sie aus, welcher Browser externe Links aus dieser App öffnen soll. Systemstandard verwendet die Einstellungen Ihres Betriebssystems.", + "description": "Wählen Sie, welcher Browser externe Links aus dieser App öffnen soll.", "systemDefault": "Systemstandard", "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" + "title": "Bildschirmaufnahme-Berechtigungen löschen", + "description": "Berechtigungen löschen, auch wenn zuvor „Nicht mehr fragen“ für Anrufe ausgewählt wurde." + }, + "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.", - "domains": "Domänen, die die Anmeldeinformationen verwenden werden. Durch Komma getrennt. Verwenden Sie *, um alle Domänen abzugleichen." + "description": "Verwendung von NTLM-Anmeldeinformationen bei der Verbindung mit einem Workspace erlauben.", + "domains": "Domänen, die die Anmeldeinformationen verwenden werden. Durch Komma getrennt. Verwenden Sie *, um alle Server abzugleichen." }, "videoCallWindowPersistence": { - "title": "Videoanruf-Fensterposition speichern", - "description": "Position und Größe der Videoanruf-Fenster zwischen Sitzungen speichern und wiederherstellen" + "title": "Videoanruf-Fensterposition beibehalten", + "description": "Position und Größe des Videoanruf-Fensters zwischen Sitzungen speichern." }, "transparentWindow": { - "title": "Transparentes Fenstereffekt", - "description": "Natives Vibrancy-/Transparenzeffekt für das Fenster aktivieren. Erfordert Neustart zur Anwendung." + "title": "Transparenter Fenstereffekt", + "description": "Natives Vibrancy-Effekt für das App-Fenster aktivieren.", + "hint": "Erfordert App-Neustart." }, "themeAppearance": { "title": "Design", - "description": "Wählen Sie das Farbschema für die Anwendung.", + "description": "Farbschema der App.", "auto": "System folgen", "light": "Hell", "dark": "Dunkel" + }, + "telephonyServer": { + "title": "Telefonie-Workspace", + "description": "Wählen Sie, welcher Workspace eingehende Telefonanrufe (tel:- und callto:-Links) übernimmt.", + "auto": "Automatisch (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." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Bildschirmaufnahme bei Videoanrufen erlauben", + "description": "Bildschirmaufnahme bei Videoanrufen erlauben. Bei jedem Videoanruf wird eine Berechtigungsanfrage angezeigt." + }, + "outlookCalendarSyncInterval": { + "title": "Outlook-Kalender-Synchronisierungsintervall", + "description": "Häufigkeit der Aktualisierungsprüfungen für Kalenderereignisse in Minuten (1–60)." + }, + "verboseOutlookLogging": { + "title": "Ausführliche Outlook-Kalender-Protokollierung", + "description": "Detaillierte Exchange/NTLM-Debug-Protokolle zur Fehlersuche bei Problemen mit der Outlook-Kalender-Integration aktivieren." + }, + "detailedEventsLogging": { + "title": "Detaillierte Ereignisprotokollierung", + "description": "Vollständige Ereignisdaten protokollieren, die zwischen Outlook und Rocket.Chat während der Kalendersynchronisierung ausgetauscht werden. Hilfreich bei der Diagnose von Synchronisierungsproblemen." + }, + "debugLogging": { + "title": "Ausführliche Protokollierung", + "description": "Schreibt alle Konsolenausgaben in die Protokolldatei. Wenn deaktiviert, werden nur Fehler und wichtige Meldungen gespeichert, was die Protokolle kleiner und übersichtlicher hält." + }, + "e2ePdfPreviewSizeLimit": { + "title": "PDF-Vorschau-Größenlimit in verschlüsselten Räumen (MB)", + "description": "Verschlüsselte Dateien werden für Vorschauen in den Arbeitsspeicher geladen. Dateien, die größer als dieses Limit sind, werden direkt heruntergeladen." } } }, @@ -312,12 +434,13 @@ "disableGpu": "GPU deaktivieren", "documentation": "Dokumentation", "downloads": "Downloads", - "settings": "Einstellungen", + "settings": "App-Einstellungen", "editMenu": "&Bearbeiten", "fileMenu": "&Datei", "forward": "&Nach vorne", "helpMenu": "&Hilfe", "hide": "Ausblenden {{- appName}}", + "hideOthers": "Andere ausblenden", "learnMore": "Mehr erfahren", "minimize": "Minimieren", "openDevTools": "&DevTools öffnen", @@ -326,6 +449,7 @@ "quit": "{{- appName}} &beenden", "redo": "Wieder&holen", "reload": "&Neu laden", + "reloadClearingCache": "Neu laden erzwingen", "reportIssue": "Problem melden", "resetAppData": "Appdaten löschen", "resetZoom": "Originalgröße", @@ -337,6 +461,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", @@ -364,6 +493,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.", @@ -372,13 +506,24 @@ "sidebar": { "addNewServer": "Neuen Server hinzufügen", "downloads": "Downloads", - "settings": "Einstellungen", + "settings": "App-Einstellungen", "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": { @@ -413,6 +558,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", @@ -421,6 +567,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:", @@ -446,5 +720,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" + } + } } } \ No newline at end of file diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 7a53bef1a8..0f9bf7d179 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?", @@ -241,112 +246,141 @@ } }, "settings": { - "title": "Settings", + "title": "App settings", "general": "General", "certificates": "Certificates", "developer": "Developer", + "voiceVideo": "Voice & Video", "sections": { - "logging": "Logging" + "appUi": "App UI", + "systemUi": "System UI", + "systemBehavior": "System behavior", + "calling": "Calling", + "other": "Other & technical", + "logging": "Logging", + "telephony": "Telephony", + "videoCalls": "Video calls" }, "options": { "report": { - "title": "Report errors to developers", - "description": "Report errors anonymously to the developers. Shared information include app version number, operating system type, server URL, device language and error type. No content or usernames are shared.", - "masDescription": "This option is disabled when installed from the Mac App Store, the errors will be reported through the Mac App Store error report process." + "title": "Report errors to Rocket.Chat", + "description": "Anonymously report issues to app developers. Shared information includes app version number, operating system type, workspace URL, device language and error type. No content or usernames are shared.", + "masDescription": "This option is disabled when installed from the Mac App Store. Errors are reported through the Mac App Store error report process." }, "flashFrame": { - "title": "Enable Flash Frame", - "titleDarwin": "Toggle Dock Bounce on alert", - "description": "Flashes the window to attract user's attention.", + "title": "Flash frame", + "titleDarwin": "Bounce dock icon", + "description": "Flash window when a new message is received.", "onLinux": "Some Linux distros don't have support for this feature.", - "descriptionDarwin": "Bounces the app icon in the dock to attract user's attention." + "descriptionDarwin": "Dock icon bounces when a new message is received." }, "hardwareAcceleration": { - "title": "Hardware Acceleration", - "description": "Enables the use of hardware acceleration when available. The application will reload on change." + "title": "Hardware acceleration", + "description": "Improves visual rendering and performance. Disable if you experience visual glitches or instability.", + "hint": "Reloads app on change." }, "videoCallScreenCaptureFallback": { - "title": "Fallback Screen Capture for Video Calls", - "description": "Disable Windows Graphics Capture so screen sharing works in Remote Desktop sessions. The app restarts when you change this option.", + "title": "Video call screen capture fallback", + "description": "Disable Windows Graphics Capture so screen sharing works in Remote Desktop sessions.", + "hint": "App restarts when this option is changed.", "forcedDescription": "Currently enforced because the app detected a Remote Desktop session. Toggle now controls future launches when running locally." }, "internalVideoChatWindow": { - "title": "Open Video Chat in Application Window", - "description": "If enabled, the Video Chat will open in the application's window instead of the default browser. However, for Google Meet and Jitsi, screen recording is not supported in Electron applications, so they will always open in the browser regardless of this setting.", - "masDescription": "This option is disabled when installed from the Mac App Store. For security reasons, Video Chat will always open in the browser by default." + "title": "Video calls inside app", + "description": "Open video calls inside app window instead of browser. Google Meet and Jitsi calls can only open in browser.", + "masDescription": "This option is disabled when installed from the Mac App Store. For security reasons, video calls always open in the browser by default." }, "minimizeOnClose": { "title": "Minimize on close", - "description": "When closed the app will be minimized, otherwise it will quit the application. Tray Icon need to be disabled to this take effect." + "description": "Minimize and do not quit app when closing.", + "disabledHint": "Tray icon must be disabled." }, "menubar": { "title": "Menu bar", "description": "Show menu bar on the top of the window.", - "disabledHint": "Cannot disable menu bar when sidebar is disabled. Settings would become inaccessible." + "disabledHint": "Cannot disable menu bar when the workspace bar is disabled. App settings would become inaccessible." }, "sidebar": { - "title": "Sidebar", - "description": "Show sidebar on the left of the window with the Server List, Downloads and Settings.", - "disabledHint": "Cannot disable sidebar when menu bar is disabled. Settings would become inaccessible." + "title": "Workspace bar", + "description": "Show workspace list with downloads and settings buttons.", + "disabledHint": "Cannot disable workspace bar when the menu bar is disabled. App settings would become inaccessible." }, "trayIcon": { - "title": "Tray Icon", - "description": "Show tray icon on the system tray. If tray icon is active the app will be hidden to tray on close. Otherwise it will quit the application." + "title": "Tray icon", + "titleDarwin": "Menu bar extra", + "description": "Show tray icon on system tray. Instead of quitting, app is hidden to tray on close when enabled.", + "descriptionDarwin": "Show app icon in menu bar." }, "availableBrowsers": { - "title": "Default Browser", - "description": "Choose which browser will open external links from this app. System Default uses your operating system settings.", - "systemDefault": "System Default", + "title": "Default browser", + "description": "Choose which browser to open external links in.", + "systemDefault": "System default", "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 server handles incoming phone calls (tel: and callto: links).", + "title": "Telephony workspace", + "description": "Choose which workspace handles incoming phone calls (tel: and callto: links).", "auto": "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." + "title": "Clear screen capture permissions", + "description": "Clear permissions even if “do not ask again” was previously selected for calling." }, "allowScreenCaptureOnVideoCalls": { - "title": "Allow Screen Capture on Video Calls", - "description": "Allow screen capture on video calls. This will ask for permission on each video call." + "title": "Allow screen capture on video calls", + "description": "Allow screen capture on video calls. Asks for permission on each video call." }, "ntlmCredentials": { - "title": "NTLM Credentials", - "description": "Allow NTLM Credentials to be used when connecting to a server.", - "domains": "Domains that will use the credentials. Separated by comma. Use * to match all domains." + "title": "NTLM credentials", + "description": "Allow NTLM credentials to be used when connecting to a workspace.", + "domains": "Domains that will be used as the credentials. Separated by comma. Use * for match all servers." }, "videoCallWindowPersistence": { - "title": "Remember video call window position", - "description": "Save and restore the position and size of video call windows between sessions" + "title": "Retain video call window position", + "description": "Remember position and size of video call window between sessions." }, "transparentWindow": { "title": "Transparent window effect", - "description": "Enable native vibrancy/transparency effect for the window. Requires restart to apply." + "description": "Enable native vibrancy on app window.", + "hint": "Requires app restart." }, "themeAppearance": { "title": "Theme", - "description": "Choose the color theme for the application.", - "auto": "Follow system", + "description": "App color theme.", + "auto": "Match system", "light": "Light", "dark": "Dark" }, "outlookCalendarSyncInterval": { - "title": "Outlook Calendar Sync Interval", - "description": "How often to sync Outlook calendar events, in minutes (1-60)." + "title": "Outlook Calendar sync interval", + "description": "Frequency of calendar events update checks in minutes (1–60)." }, "verboseOutlookLogging": { - "title": "Verbose Outlook Calendar Logging", + "title": "Verbose Outlook Calendar logging", "description": "Enable detailed Exchange/NTLM debug logs for troubleshooting Outlook Calendar integration issues." }, "detailedEventsLogging": { - "title": "Detailed Events Logging", + "title": "Detailed events logging", "description": "Log full event data exchanged between Outlook and Rocket.Chat during calendar sync. Useful for diagnosing sync issues." }, "debugLogging": { - "title": "Verbose Logging", + "title": "Verbose logging", "description": "Writes all console output to the log file. When disabled, only errors and important messages are saved, keeping logs smaller and focused." }, "e2ePdfPreviewSizeLimit": { @@ -380,7 +414,7 @@ "disableGpu": "Disable GPU", "documentation": "Documentation", "downloads": "Downloads", - "settings": "Settings", + "settings": "App settings", "editMenu": "&Edit", "fileMenu": "&File", "forward": "&Forward", @@ -452,7 +486,7 @@ "sidebar": { "addNewServer": "Add new server", "downloads": "Downloads", - "settings": "Settings", + "settings": "App settings", "menuTitle": "Customize and control app", "item": { "reload": "Reload", @@ -667,5 +701,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" + } + } } } \ No newline at end of file diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json index 8a2ab0b3a8..01cb5f8754 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": { @@ -225,88 +230,138 @@ } }, "settings": { - "title": "Configuración", + "title": "Ajustes de la app", "general": "General", "certificates": "Certificados", + "sections": { + "appUi": "Interfaz de la app", + "systemUi": "Interfaz del sistema", + "systemBehavior": "Comportamiento del sistema", + "calling": "Llamadas", + "other": "Otros y técnico", + "logging": "Registros" + }, "options": { "report": { - "title": "Informar errores a los desarrolladores", - "description": "Informar errores de forma anónima a los desarrolladores. La información compartida incluye el número de versión de la aplicación, el tipo de sistema operativo, la URL del servidor, el idioma del dispositivo y el tipo de error. No se comparten contenidos ni nombres de usuario.", - "masDescription": "Esta opción está desactivada cuando se instala desde la Mac App Store; los errores se informarán a través del proceso de informe de errores de la Mac App Store." + "title": "Informar errores a Rocket.Chat", + "description": "Informar errores de forma anónima a los desarrolladores. La información compartida incluye el número de versión de la aplicación, el tipo de sistema operativo, la URL del espacio de trabajo, el idioma del dispositivo y el tipo de error. No se comparten contenidos ni nombres de usuario.", + "masDescription": "Esta opción está desactivada cuando se instala desde la Mac App Store. Los errores se informan a través del proceso de informe de errores de la Mac App Store." }, "flashFrame": { - "title": "Habilitar destello de ventana", - "titleDarwin": "Alternar rebote del Dock en alerta", - "description": "Hace destellar la ventana para atraer la atención del usuario.", + "title": "Destello de ventana", + "titleDarwin": "Rebotar icono del Dock", + "description": "Hace destellar la ventana cuando se recibe un nuevo mensaje.", "onLinux": "Algunas distribuciones de Linux no admiten esta función.", - "descriptionDarwin": "Hace rebotar el icono de la aplicación en el dock para atraer la atención del usuario." + "descriptionDarwin": "El icono del Dock rebota cuando se recibe un nuevo mensaje." }, "hardwareAcceleration": { "title": "Aceleración de hardware", - "description": "Habilita el uso de la aceleración de hardware cuando esté disponible. La aplicación se reiniciará al realizar cambios." + "description": "Mejora el renderizado visual y el rendimiento. Desactívala si experimentas fallos visuales o inestabilidad.", + "hint": "Recarga la app al cambiar." }, "videoCallScreenCaptureFallback": { "title": "Modo alternativo de captura en videollamadas", - "description": "Desactiva Windows Graphics Capture para que el uso compartido funcione en sesiones RDP. La app se reinicia cuando cambias esta opción.", - "forcedDescription": "Actualmente está aplicado porque la app detectó una sesión RDP. El interruptor controla el comportamiento en siguientes arranques locales." + "description": "Desactiva Windows Graphics Capture para que el uso compartido de pantalla funcione en sesiones de Escritorio Remoto.", + "hint": "La app se reinicia cuando se cambia esta opción.", + "forcedDescription": "Actualmente está aplicado porque la app detectó una sesión de Escritorio Remoto. El interruptor controla el comportamiento en siguientes arranques locales." }, "internalVideoChatWindow": { - "title": "Abrir videollamada en la ventana de la aplicación", - "description": "Si está activado, la videollamada se abrirá en la ventana de la aplicación en lugar del navegador predeterminado. Sin embargo, para Google Meet y Jitsi, la grabación de pantalla no es compatible en aplicaciones Electron, por lo que siempre se abrirán en el navegador independientemente de esta configuración.", - "masDescription": "Esta opción está desactivada cuando se instala desde la Mac App Store; por razones de seguridad, las videollamadas se abrirán en el navegador de forma predeterminada." + "title": "Videollamadas dentro de la app", + "description": "Abrir videollamadas en la ventana de la app en lugar del navegador. Las llamadas de Google Meet y Jitsi solo pueden abrirse en el navegador.", + "masDescription": "Esta opción está desactivada cuando se instala desde la Mac App Store. Por razones de seguridad, las videollamadas siempre se abren en el navegador de forma predeterminada." }, "minimizeOnClose": { "title": "Minimizar al cerrar", - "description": "Cuando se cierra la aplicación, se minimiza en lugar de cerrarse por completo. El icono de la bandeja debe estar desactivado para que esto surta efecto." + "description": "Minimizar la app y no cerrarla al hacer clic en el botón de cerrar.", + "disabledHint": "El icono de la bandeja debe estar desactivado." }, "menubar": { "title": "Barra de menú", "description": "Mostrar la barra de menú en la parte superior de la ventana.", - "disabledHint": "No se puede desactivar la barra de menú cuando la barra lateral está desactivada. La configuración sería inaccesible." + "disabledHint": "No se puede desactivar la barra de menú cuando la barra de espacios de trabajo está desactivada. Los ajustes de la app serían inaccesibles." }, "sidebar": { - "title": "Barra lateral", - "description": "Mostrar la barra lateral en el lado izquierdo de la ventana con la lista de servidores, las descargas y la configuración.", - "disabledHint": "No se puede desactivar la barra lateral cuando la barra de menú está desactivada. La configuración sería inaccesible." + "title": "Barra de espacios de trabajo", + "description": "Mostrar la lista de espacios de trabajo con botones de descargas y ajustes.", + "disabledHint": "No se puede desactivar la barra de espacios de trabajo cuando la barra de menú está desactivada. Los ajustes de la app serían inaccesibles." }, "trayIcon": { "title": "Icono de la bandeja", - "description": "Muestra un icono en la bandeja del sistema. Si el icono de la bandeja está activo, la aplicación se minimizará a la bandeja al cerrarla. De lo contrario, cerrará la aplicación." + "titleDarwin": "Extra en la barra de menú", + "description": "Mostrar el icono de la app en la bandeja del sistema. Al cerrarse, la app se oculta a la bandeja en lugar de salir cuando está activado.", + "descriptionDarwin": "Mostrar el icono de la app en la barra de menú." }, "availableBrowsers": { - "title": "Navegador Predeterminado", - "description": "Elija qué navegador abrirá los enlaces externos de esta aplicación. Predeterminado del Sistema usa la configuración de su sistema operativo.", - "systemDefault": "Predeterminado del Sistema", + "title": "Navegador predeterminado", + "description": "Elige qué navegador abrirá los enlaces externos.", + "systemDefault": "Predeterminado del sistema", "loading": "Cargando navegadores...", "current": "Usando actualmente:" }, + "telephonyServer": { + "title": "Espacio de trabajo de telefonía", + "description": "Elige qué espacio de trabajo gestiona las llamadas telefónicas entrantes (enlaces tel: y callto:).", + "auto": "Automático (preguntar cada vez)" + }, + "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", + "reservedByApp": "{{accelerator}} ya está en uso por Rocket.Chat. Elige otra combinación.", + "reservedByOS": "{{accelerator}} está reservado por tu sistema operativo. Elige otra combinación." + }, "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." + "description": "Borrar los permisos aunque se haya seleccionado «no volver a preguntar» anteriormente para las llamadas." }, "allowScreenCaptureOnVideoCalls": { "title": "Permitir captura de pantalla en videollamadas", - "description": "Permitir la captura de pantalla en videollamadas. Esto solicitará permiso en cada videollamada." + "description": "Permitir la captura de pantalla en videollamadas. Solicita permiso en cada videollamada." }, "ntlmCredentials": { "title": "Credenciales NTLM", - "description": "Permitir que se utilicen las credenciales NTLM al conectarse a un servidor.", - "domains": "Dominios que utilizarán las credenciales. Separados por coma. Use * para coincidir con todos los dominios." + "description": "Permitir que se utilicen las credenciales NTLM al conectarse a un espacio de trabajo.", + "domains": "Dominios que utilizarán las credenciales. Separados por coma. Use * para coincidir con todos los servidores." }, "videoCallWindowPersistence": { - "title": "Recordar posición de la ventana de videollamada", - "description": "Guardar y restaurar la posición y el tamaño de las ventanas de videollamada entre sesiones" + "title": "Conservar posición de la ventana de videollamada", + "description": "Recordar la posición y el tamaño de la ventana de videollamada entre sesiones." }, "transparentWindow": { "title": "Efecto de ventana transparente", - "description": "Habilitar efecto nativo de vibración/transparencia para la ventana. Requiere reinicio para aplicar." + "description": "Activar el efecto nativo de vibración en la ventana de la app.", + "hint": "Requiere reinicio." }, "themeAppearance": { "title": "Tema", - "description": "Elige el tema de color para la aplicación.", + "description": "Tema de color de la app.", "auto": "Seguir sistema", "light": "Claro", "dark": "Oscuro" + }, + "outlookCalendarSyncInterval": { + "title": "Intervalo de sincronización del Calendario de Outlook", + "description": "Frecuencia de comprobación de actualizaciones de eventos del calendario en minutos (1–60)." + }, + "verboseOutlookLogging": { + "title": "Registro detallado del Calendario de Outlook", + "description": "Activar registros de depuración detallados de Exchange/NTLM para diagnosticar problemas de integración con el Calendario de Outlook." + }, + "detailedEventsLogging": { + "title": "Registro detallado de eventos", + "description": "Registrar los datos completos de eventos intercambiados entre Outlook y Rocket.Chat durante la sincronización del calendario. Útil para diagnosticar problemas de sincronización." + }, + "debugLogging": { + "title": "Registro detallado", + "description": "Escribe toda la salida de consola en el archivo de registro. Cuando está desactivado, solo se guardan los errores y mensajes importantes, manteniendo los registros más pequeños y enfocados." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Límite de tamaño de vista previa de PDF en salas cifradas (MB)", + "description": "Los archivos cifrados se cargan en memoria para las vistas previas. Los archivos que superen este límite se descargarán directamente." } } }, @@ -335,7 +390,7 @@ "disableGpu": "Deshabilitar GPU", "documentation": "Documentación", "downloads": "Descargas", - "settings": "Configuración", + "settings": "Ajustes de la app", "editMenu": "&Editar", "fileMenu": "&Archivo", "forward": "&Avanzar", @@ -401,7 +456,7 @@ "sidebar": { "addNewServer": "Agregar nuevo servidor", "downloads": "Descargas", - "settings": "Configuración", + "settings": "Ajustes de la app", "item": { "reload": "Recargar servidor", "remove": "Eliminar servidor", diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json index b8aa5dee0f..71d367f6a1 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": { @@ -213,75 +218,142 @@ } }, "settings": { - "title": "Asetukset", + "title": "Sovelluksen asetukset", "general": "Yleiset", "certificates": "Varmenteet", + "sections": { + "appUi": "Sovelluksen käyttöliittymä", + "systemUi": "Järjestelmän käyttöliittymä", + "systemBehavior": "Järjestelmän toiminta", + "calling": "Puhelut", + "other": "Muut ja tekniset", + "logging": "Lokikirjaus" + }, "options": { "report": { - "title": "Ilmoita virheistä kehittäjille", - "description": "Ilmoita virheistä kehittäjille nimettömästi. Jaettuja tietoja ovat mm. sovelluksen versionumero, käyttöjärjestelmän tyyppi, palvelimen URL-osoite, laitteen kieli ja virheen tyyppi. Sisältöä tai käyttäjätunnuksia ei jaeta.", - "masDescription": "Tämä ei ole käytössä, kun asennus on tehty Macin App Storesta. Virheistä ilmoitetaan Macin App Storen virheilmotusprosessilla." + "title": "Ilmoita virheistä Rocket.Chatille", + "description": "Ilmoita virheistä sovelluskehittäjille nimettömästi. Jaettuja tietoja ovat mm. sovelluksen versionumero, käyttöjärjestelmän tyyppi, työtilan URL-osoite, laitteen kieli ja virheen tyyppi. Sisältöä tai käyttäjätunnuksia ei jaeta.", + "masDescription": "Tämä ei ole käytössä, kun asennus on tehty Macin App Storesta. Virheistä ilmoitetaan Macin App Storen virheilmoitusprosessilla." }, "flashFrame": { - "title": "Ota käyttöön Flash-kehys", - "titleDarwin": "Vaihda Dockin ponnahdusta hälytyksen yhteydessä", - "description": "Herättää käyttäjän huomion väläyttämällä ikkunaa.", + "title": "Väläytä kehys", + "titleDarwin": "Pompputa Dock-kuvaketta", + "description": "Väläyttää ikkunaa, kun uusi viesti saapuu.", "onLinux": "Jotkin Linux-jakelut eivät tue tätä ominaisuutta.", - "descriptionDarwin": "Herättää käyttäjän huomion ponnauttamalla sovelluskuvaketta Dockissa." + "descriptionDarwin": "Dock-kuvake pomppaa, kun uusi viesti saapuu." }, "hardwareAcceleration": { "title": "Laitteistokiihdytys", - "description": "Ottaa käyttöön laitteistokiihdytyksen, jos se on käytettävissä. Sovellus latautuu uudelleen muutoksen yhteydessä." + "description": "Parantaa visuaalista renderöintiä ja suorituskykyä. Poista käytöstä, jos koet visuaalisia häiriöitä tai epävakautta.", + "hint": "Lataa sovelluksen uudelleen muutoksen yhteydessä." }, "videoCallScreenCaptureFallback": { "title": "Videopuhelujen varanäyttökaappaus", - "description": "Poistaa Windows Graphics Capture -tilan, jotta jakaminen toimii RDP-istunnoissa. Sovellus käynnistyy uudelleen, kun tämä vaihtoehto muuttuu.", - "forcedDescription": "Tällä hetkellä pakotettu, koska sovellus havaitsi RDP-istunnon. Vaihtoehto määrittää seuraavien paikallisten käynnistysten käytöksen." + "description": "Poistaa Windows Graphics Capture -tilan käytöstä, jotta näytön jakaminen toimii etätyöpöytäistunnoissa.", + "hint": "Sovellus käynnistyy uudelleen, kun tämä vaihtoehto muuttuu.", + "forcedDescription": "Tällä hetkellä pakotettu, koska sovellus havaitsi etätyöpöytäistunnon. Valitsin määrittää seuraavien paikallisten käynnistysten toiminnan." }, "internalVideoChatWindow": { - "title": "Avaa videokeskustelu sovellusikkunaan", - "description": "Jos käytössä, videokeskustelu avautuu sovelluksen ikkunaan oletusselaimen sijaan. Kuitenkin Google Meet ja Jitsi -palveluissa ruuduntallennus ei ole tuettu Electron-sovelluksissa, joten ne avautuvat aina selaimessa tästä asetuksesta riippumatta.", - "masDescription": "Asetus ei ole käytössä, kun asennus on tehty Macin App Storesta. Suojaussyistä videokeskustelu avautuu oletusarvoisesti selaimeen." + "title": "Videopuhelut sovelluksessa", + "description": "Avaa videopuhelut sovelluksen ikkunassa selaimen sijaan. Google Meet- ja Jitsi-puhelut voidaan avata vain selaimessa.", + "masDescription": "Asetus ei ole käytössä, kun asennus on tehty Macin App Storesta. Suojaussyistä videopuhelut avautuvat oletusarvoisesti selaimeen." }, "minimizeOnClose": { "title": "Pienennä suljettaessa", - "description": "Sovellus pienennetään suljettaessa, muutoin sovellus suljetaan. Ilmaisinalueen kuvakkeen on oltava poissa käytöstä, jotta tämä onnistuu." + "description": "Pienennä sovellus sulkematta sitä, kun sulkupainike painetaan.", + "disabledHint": "Ilmaisinalueen kuvake on poistettava käytöstä." }, "menubar": { "title": "Valikkopalkki", "description": "Näytä valikkopalkki ikkunan yläreunassa.", - "disabledHint": "Valikkopalkkinsa poisto ei ole mahdollista, kun sivupalkki on pois käytöstä. Asetukset tulisivat saavuttamattomiksi." + "disabledHint": "Valikkopalkki ei voi olla pois käytöstä, kun työtilabalkki on pois käytöstä. Sovelluksen asetukset muuttuisivat saavuttamattomiksi." }, "sidebar": { - "title": "Sivupalkki", - "description": "Näytä ikkunan vasemmassa reunassa sivupalkki, jossa näkyvät palvelinluettelo, lataukset ja asetukset.", - "disabledHint": "Sivupalkin poisto ei ole mahdollista, kun valikkopalkki on pois käytöstä. Asetukset tulisivat saavuttamattomiksi." + "title": "Työtilabalkki", + "description": "Näytä työtila­luettelo lataukset- ja asetukset-painikkeilla.", + "disabledHint": "Työtilabalkki ei voi olla pois käytöstä, kun valikkopalkki on pois käytöstä. Sovelluksen asetukset muuttuisivat saavuttamattomiksi." }, "trayIcon": { "title": "Ilmaisinalueen kuvake", - "description": "Näytä ilmaisinalueen kuvake ilmaisinalueella. Jos ilmaisinalueen kuvake on käytössä, sovellus piilotetaan suljettaessa ilmaisinalueelle. Muutoin sovellus suljetaan." + "titleDarwin": "Valikkorivin lisäkuvake", + "description": "Näytä kuvake järjestelmän ilmaisinalueella. Kun käytössä, sovellus piilotetaan ilmaisinalueelle suljettaessa eikä sammuteta.", + "descriptionDarwin": "Näytä sovelluksen kuvake valikkorivissä." }, "availableBrowsers": { "title": "Oletusselain", - "description": "Valitse selain, joka avaa ulkoiset linkit tästä sovelluksesta. Järjestelmän oletus käyttää käyttöjärjestelmäsi asetuksia.", + "description": "Valitse selain, joka avaa ulkoiset linkit tästä sovelluksesta.", "systemDefault": "Järjestelmän oletus", "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." + "title": "Tyhjennä näyttökaappausoikeudet", + "description": "Tyhjennä oikeudet, vaikka “älä kysy uudelleen” olisi aiemmin valittu puheluiden yhteydessä." }, "transparentWindow": { "title": "Läpinäkyvä ikkuna-efekti", - "description": "Ota käyttöön natiivi värinäkyvyys/läpinäkyvyys-efekti ikkunalle. Vaatii uudelleenkäynnistyksen toimiakseen." + "description": "Ota käyttöön natiivi värinäkyvyys/läpinäkyvyys-efekti ikkunalle.", + "hint": "Vaatii sovelluksen uudelleenkäynnistyksen." }, "themeAppearance": { "title": "Teema", - "description": "Valitse sovelluksen väriteema.", + "description": "Sovelluksen väriteema.", "auto": "Seuraa järjestelmää", "light": "Vaalea", "dark": "Tumma" + }, + "telephonyServer": { + "title": "Puhelintyötila", + "description": "Valitse työtila, joka käsittelee saapuvat puhelut (tel:- ja callto:-linkit).", + "auto": "Automaattinen (kysy aina)" + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Salli näyttökaappaus videopuheluissa", + "description": "Sallii näyttökaappauksen videopuheluissa. Pyytää luvan jokaisen videopuhelun yhteydessä." + }, + "ntlmCredentials": { + "title": "NTLM-tunnistautumistiedot", + "description": "Sallii NTLM-tunnistautumistietojen käytön työtilaan yhdistettäessä.", + "domains": "Toimialueet, joita käytetään tunnistautumistietoina. Pilkulla erotettu. Käytä * kaikkien palvelimien vastaamiseen." + }, + "videoCallWindowPersistence": { + "title": "Säilytä videopuheluikkunan sijainti", + "description": "Muistaa videopuheluikkunan sijainnin ja koon istuntojen välillä." + }, + "outlookCalendarSyncInterval": { + "title": "Outlook-kalenterin synkronointiväli", + "description": "Kalenteritapahtumien päivitystarkistusten tiheys minuuteissa (1–60)." + }, + "verboseOutlookLogging": { + "title": "Yksityiskohtainen Outlook-kalenterin lokikirjaus", + "description": "Ottaa käyttöön yksityiskohtaiset Exchange/NTLM-virheenkorjauslokit Outlook-kalenterin integraatio-ongelmien selvittämiseksi." + }, + "detailedEventsLogging": { + "title": "Yksityiskohtainen tapahtumalokikirjaus", + "description": "Kirjaa kaikki tapahtumatiedot, joita vaihdetaan Outlookin ja Rocket.Chatin välillä kalenterin synkronoinnin aikana. Hyödyllinen synkronointiongelmien diagnosoinnissa." + }, + "debugLogging": { + "title": "Yksityiskohtainen lokikirjaus", + "description": "Kirjoittaa kaiken konsolitulosteen lokitiedostoon. Kun ei käytössä, vain virheet ja tärkeät viestit tallennetaan, pitäen lokit pienempinä ja kohdennetumpina." + }, + "e2ePdfPreviewSizeLimit": { + "title": "PDF-esikatselun kokoraja salatuissa huoneissa (Mt)", + "description": "Salatut tiedostot ladataan muistiin esikatselua varten. Tätä rajaa suuremmat tiedostot ladataan suoraan." } } }, @@ -309,7 +381,7 @@ "disableGpu": "Poista grafiikkasuoritin käytöstä", "documentation": "Oppaat", "downloads": "Lataukset", - "settings": "Asetukset", + "settings": "Sovelluksen asetukset", "editMenu": "M&uokkaa", "fileMenu": "Tie&dosto", "forward": "V&älitä", @@ -370,7 +442,7 @@ "sidebar": { "addNewServer": "Lisää uusi palvelin", "downloads": "Lataukset", - "settings": "Asetukset", + "settings": "Sovelluksen asetukset", "item": { "reload": "Lataa palvelin uudelleen", "remove": "Poista palvelin", diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json index b85041dd7a..44a826fb2e 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": { @@ -213,75 +218,137 @@ } }, "settings": { - "title": "Paramètres", + "title": "Paramètres de l'app", "general": "Général", "certificates": "Certificats", + "sections": { + "appUi": "Interface de l'app", + "systemUi": "Interface système", + "systemBehavior": "Comportement système", + "calling": "Appels", + "other": "Autre & technique", + "logging": "Journalisation" + }, "options": { "report": { - "title": "Signaler les erreurs aux développeurs", - "description": "Signaler les erreurs de manière anonyme aux développeurs. Les informations partagées incluent le numéro de version de l'application, le type de système d'exploitation, l'URL du serveur, la langue de l'appareil et le type d'erreur. Aucun contenu ou nom d'utilisateur n'est partagé.", - "masDescription": "Cette option est désactivée lorsqu'elle est installée à partir du Mac App Store, les erreurs seront signalisées via le processus de rapport d'erreur du Mac App Store." + "title": "Signaler les erreurs à Rocket.Chat", + "description": "Signaler les erreurs de manière anonyme aux développeurs. Les informations partagées incluent le numéro de version de l'application, le type de système d'exploitation, l'URL de l'espace de travail, la langue de l'appareil et le type d'erreur. Aucun contenu ou nom d'utilisateur n'est partagé.", + "masDescription": "Cette option est désactivée lorsqu'elle est installée à partir du Mac App Store. Les erreurs sont signalées via le processus de rapport d'erreur du Mac App Store." }, "flashFrame": { - "title": "Activer le cadre Flash", - "titleDarwin": "Basculer le Dock Bounce en cas d'alerte", - "description": "Clignote la fenêtre pour attirer l'attention de l'utilisateur.", + "title": "Clignotement de la fenêtre", + "titleDarwin": "Rebond de l'icône du Dock", + "description": "Faire clignoter la fenêtre lors de la réception d'un nouveau message.", "onLinux": "Certaines distributions Linux ne prennent pas en charge cette fonctionnalité.", - "descriptionDarwin": "Fait rebondir l'icône de l'application dans le dock pour attirer l'attention de l'utilisateur." + "descriptionDarwin": "L'icône du Dock rebondit lors de la réception d'un nouveau message." }, "hardwareAcceleration": { "title": "Accélération matérielle", - "description": "Active l'utilisation de l'accélération matérielle lorsqu'elle est disponible. L'application se rechargera en cas de changement." + "description": "Améliore le rendu visuel et les performances. Désactivez en cas de problèmes visuels ou d'instabilité.", + "hint": "Recharge l'application lors du changement." }, "videoCallScreenCaptureFallback": { "title": "Solution de capture d'écran pour les appels vidéo", - "description": "Désactive Windows Graphics Capture pour permettre le partage d'écran dans les sessions RDP. L'application redémarre lorsque vous modifiez cette option.", - "forcedDescription": "Actuellement appliqué car l'application a détecté une session RDP. Le commutateur contrôle le comportement lors des prochains démarrages locaux." + "description": "Désactive Windows Graphics Capture pour permettre le partage d'écran dans les sessions Bureau à distance.", + "hint": "L'application redémarre lorsque vous modifiez cette option.", + "forcedDescription": "Actuellement appliqué car l'application a détecté une session Bureau à distance. Le commutateur contrôle le comportement lors des prochains démarrages locaux." }, "internalVideoChatWindow": { - "title": "Ouvrir le chat vidéo à l'aide de la fenêtre de l'application", - "description": "Si cette option est activée, le chat vidéo s'ouvrira dans la fenêtre de l'application au lieu du navigateur par défaut. Cependant, pour Google Meet et Jitsi, l'enregistrement d'écran n'est pas pris en charge dans les applications Electron, donc ils s'ouvriront toujours dans le navigateur, quel que soit ce paramètre.", - "masDescription": "Cette option est désactivée lorsqu'elle est installée à partir du Mac App Store, pour des raisons de sécurité, elle ouvrira le chat vidéo en utilisant le navigateur par défaut." + "title": "Appels vidéo dans l'application", + "description": "Ouvrir les appels vidéo dans la fenêtre de l'application plutôt que dans le navigateur. Les appels Google Meet et Jitsi ne peuvent s'ouvrir que dans le navigateur.", + "masDescription": "Cette option est désactivée lorsqu'elle est installée à partir du Mac App Store. Pour des raisons de sécurité, les appels vidéo s'ouvrent toujours dans le navigateur par défaut." }, "minimizeOnClose": { "title": "Minimiser à la fermeture", - "description": "Une fois fermée, l'application sera minimisée, sinon elle quittera l'application. L'icône de la barre d'état doit être désactivée pour que cela prenne effet." + "description": "Minimiser l'application sans la quitter lors de la fermeture.", + "disabledHint": "L'icône de la barre d'état système doit être désactivée." }, "menubar": { "title": "Barre de menu", "description": "Afficher la barre de menus en haut de la fenêtre.", - "disabledHint": "Impossible de désactiver la barre de menu quand la barre latérale est désactivée. Les paramètres deviendraient inaccessibles." + "disabledHint": "Impossible de désactiver la barre de menu quand la barre des espaces de travail est désactivée. Les paramètres de l'app deviendraient inaccessibles." }, "sidebar": { - "title": "Barre latérale", - "description": "Afficher la barre latérale à gauche de la fenêtre avec la liste des serveurs, les téléchargements et les paramètres.", - "disabledHint": "Impossible de désactiver la barre latérale quand la barre de menu est désactivée. Les paramètres deviendraient inaccessibles." + "title": "Barre des espaces de travail", + "description": "Afficher la liste des espaces de travail avec les boutons de téléchargements et paramètres.", + "disabledHint": "Impossible de désactiver la barre des espaces de travail quand la barre de menu est désactivée. Les paramètres de l'app deviendraient inaccessibles." }, "trayIcon": { "title": "Icône de la barre d'état système", - "description": "Afficher l'icône dans la barre d'état système. Si l'icône est active, l'application sera masquée dans la barre d'état lors de la fermeture. Sinon, elle sera complètement fermée." + "titleDarwin": "Extra de la barre de menu", + "description": "Afficher l'icône dans la barre d'état système. Si l'icône est active, l'application est masquée dans la barre d'état lors de la fermeture plutôt que quittée.", + "descriptionDarwin": "Afficher l'icône de l'application dans la barre de menu." }, "availableBrowsers": { "title": "Navigateur par défaut", - "description": "Choisissez quel navigateur ouvrira les liens externes de cette application. La valeur par défaut du système utilise les paramètres de votre système d'exploitation.", + "description": "Choisissez quel navigateur ouvrira les liens externes de cette application.", "systemDefault": "Par défaut du système", "loading": "Chargement des navigateurs...", "current": "Actuellement utilisé:" }, + "telephonyServer": { + "title": "Espace de travail téléphonie", + "description": "Choisissez quel espace de travail gère les appels téléphoniques entrants (liens tel: et 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." + "description": "Effacer les autorisations même si « ne plus demander » avait été sélectionné précédemment pour les appels." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Autoriser la capture d'écran lors des appels vidéo", + "description": "Autoriser la capture d'écran lors des appels vidéo. Demande une autorisation à chaque appel vidéo." + }, + "ntlmCredentials": { + "title": "Identifiants NTLM", + "description": "Autoriser l'utilisation des identifiants NTLM lors de la connexion à un espace de travail.", + "domains": "Domaines qui seront utilisés pour les identifiants. Séparés par des virgules. Utilisez * pour correspondre à tous les serveurs." + }, + "videoCallWindowPersistence": { + "title": "Conserver la position de la fenêtre d'appel vidéo", + "description": "Mémoriser la position et la taille de la fenêtre d'appel vidéo entre les sessions." }, "transparentWindow": { "title": "Effet de fenêtre transparente", - "description": "Activer l'effet natif de vibrance/transparence pour la fenêtre. Nécessite un redémarrage pour s'appliquer." + "description": "Activer l'effet natif de vibrance sur la fenêtre de l'application.", + "hint": "Nécessite un redémarrage." }, "themeAppearance": { "title": "Thème", - "description": "Choisissez le thème de couleur pour l'application.", + "description": "Thème de couleur de l'application.", "auto": "Suivre le système", "light": "Clair", "dark": "Sombre" + }, + "outlookCalendarSyncInterval": { + "title": "Intervalle de synchronisation du calendrier Outlook", + "description": "Fréquence des vérifications de mise à jour des événements du calendrier en minutes (1–60)." + }, + "verboseOutlookLogging": { + "title": "Journalisation détaillée du calendrier Outlook", + "description": "Activer les journaux de débogage Exchange/NTLM détaillés pour résoudre les problèmes d'intégration du calendrier Outlook." + }, + "detailedEventsLogging": { + "title": "Journalisation détaillée des événements", + "description": "Enregistrer les données complètes des événements échangés entre Outlook et Rocket.Chat lors de la synchronisation du calendrier. Utile pour diagnostiquer les problèmes de synchronisation." + }, + "debugLogging": { + "title": "Journalisation détaillée", + "description": "Enregistre toutes les sorties de la console dans le fichier journal. Lorsque désactivé, seuls les erreurs et messages importants sont sauvegardés, maintenant des journaux plus compacts et ciblés." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Limite de taille de prévisualisation PDF dans les salons chiffrés (Mo)", + "description": "Les fichiers chiffrés sont chargés en mémoire pour les prévisualisations. Les fichiers dépassant cette limite seront téléchargés directement." } } }, @@ -310,7 +377,7 @@ "disableGpu": "Désactiver le GPU", "documentation": "Documentation", "downloads": "Téléchargements", - "settings": "Paramètres", + "settings": "Paramètres de l'app", "editMenu": "&Éditer", "fileMenu": "&Fichier", "forward": "&Suivant", @@ -371,7 +438,7 @@ "sidebar": { "addNewServer": "Ajouter un nouveau serveur", "downloads": "Téléchargements", - "settings": "Paramètres", + "settings": "Paramètres de l'app", "item": { "reload": "Recharger le serveur", "remove": "Retirer le serveur", diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json index 30f3a15869..83b2f554fb 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": { @@ -234,65 +239,90 @@ } }, "settings": { - "title": "Beállítások", + "title": "Alkalmazásbeállítások", "general": "Általános", "certificates": "Tanúsítványok", "developer": "Fejlesztő", "sections": { + "appUi": "Alkalmazás felülete", + "systemUi": "Rendszer felülete", + "systemBehavior": "Rendszerviselkedés", + "calling": "Hívások", + "other": "Egyéb és technikai", "logging": "Naplózás" }, "options": { "report": { - "title": "Hibák jelentése a fejlesztőknek", - "description": "A hibák névtelenül történő jelentése a fejlesztőknek. A megosztott információk az alkalmazás verziószámát, az operációs rendszer típusát, a kiszolgáló URL-ét, az eszköz nyelvét és a hiba típusát tartalmazzák. Tartalom vagy felhasználónevek nem kerülnek megosztásra.", + "title": "Hibák jelentése a Rocket.Chat-nek", + "description": "A hibák névtelenül történő jelentése az alkalmazás fejlesztőinek. A megosztott információk az alkalmazás verziószámát, az operációs rendszer típusát, a munkaterület URL-ét, az eszköz nyelvét és a hiba típusát tartalmazzák. Tartalom vagy felhasználónevek nem kerülnek megosztásra.", "masDescription": "Ez a beállítás a Mac alkalmazásboltból történő telepítéskor le van tiltva. A hibák a Mac alkalmazásbolt hibajelentési folyamatán keresztül lesznek bejelentve." }, "flashFrame": { - "title": "Keret villogtatásának engedélyezése", - "titleDarwin": "A dokk pattogásának be- és kikapcsolása riasztáskor", - "description": "Villogtatja az ablakot, hogy felhívja a felhasználó figyelmét.", + "title": "Keret villogtatása", + "titleDarwin": "Dokk ikon pattogtatása", + "description": "Villogtatja az ablakot, ha új üzenet érkezik.", "onLinux": "Egyes Linux disztribúciók nem támogatják ezt a funkciót.", - "descriptionDarwin": "Pattogtatja az alkalmazás ikonját a dokkban, hogy felhívja a felhasználó figyelmét." + "descriptionDarwin": "A dokk ikon pattog, ha új üzenet érkezik." }, "hardwareAcceleration": { "title": "Hardveres gyorsítás", - "description": "Engedélyezi a hardveres gyorsítás használatát, ha elérhető. Az alkalmazás újratöltődik a megváltoztatásakor." + "description": "Javítja a vizuális megjelenítést és a teljesítményt. Tiltsa le, ha vizuális hibákat vagy instabilitást tapasztal.", + "hint": "A módosításkor az alkalmazás újratöltődik." }, "videoCallScreenCaptureFallback": { "title": "Tartalék képernyőrögzítés a videohívásokhoz", - "description": "A Windows Graphics Capture funkció letiltása, hogy a képernyőmegosztás működjön a távoli asztal munkameneteiben. Az alkalmazás újraindul, ha megváltoztatja ezt a beállítást.", + "description": "A Windows Graphics Capture funkció letiltása, hogy a képernyőmegosztás működjön a távoli asztal munkameneteiben.", + "hint": "Az alkalmazás újraindul, ha megváltoztatja ezt a beállítást.", "forcedDescription": "Jelenleg érvényben van, mert az alkalmazás távoli asztali munkamenetet észlelt. A kapcsoló mostantól a jövőbeli indításokat vezérli, amikor helyileg fut." }, "internalVideoChatWindow": { - "title": "Videocsevegés megnyitása az alkalmazásablakban", - "description": "Ha engedélyezve van, akkor a videócsevegés az alkalmazás ablakában nyílik meg az alapértelmezett böngésző helyett. Azonban a Google Meet és a Jitsi esetében a képernyő rögzítése nem támogatott az Electron alkalmazásokban, így azok ettől a beállítástól függetlenül mindig a böngészőben nyílnak meg.", - "masDescription": "Ez a beállítás le van tiltva a Mac App Store-ból történő telepítéskor. Biztonsági okokból a videócsevegés alapértelmezetten mindig a böngészőben nyílik meg." + "title": "Videohívások az alkalmazáson belül", + "description": "Videohívások megnyitása az alkalmazásablakban, böngésző helyett. A Google Meet és a Jitsi hívások csak böngészőben nyithatók meg.", + "masDescription": "Ez a beállítás le van tiltva a Mac App Store-ból történő telepítéskor. Biztonsági okokból a videóhívások alapértelmezetten mindig a böngészőben nyílnak meg." }, "minimizeOnClose": { "title": "Kis méret bezáráskor", - "description": "Bezáráskor az alkalmazás kis méretű lesz, egyébként kilép az alkalmazásból. A tálca ikonját le kell tiltani, hogy ez érvényesüljön." + "description": "Bezáráskor az alkalmazás kis méretű lesz, nem lép ki.", + "disabledHint": "A tálcaikon letiltása szükséges." }, "menubar": { "title": "Menüsáv", "description": "Menüsáv megjelenítése az ablak tetején.", - "disabledHint": "Nem lehet letiltani a menüsort, ha az oldalsáv le van tiltva. A beállítások elérhetetlenné válnának." + "disabledHint": "Nem lehet letiltani a menüsort, ha a munkaterületsáv le van tiltva. Az alkalmazásbeállítások elérhetetlenné válnának." }, "sidebar": { - "title": "Oldalsáv", - "description": "Oldalsáv megjelenítése az ablak bal oldalán a kiszolgálók listájával, a letöltésekkel és a beállításokkal.", - "disabledHint": "Nem lehet letiltani az oldalsávot, ha a menüsor le van tiltva. A beállítások elérhetetlenné válnának." + "title": "Munkaterületsáv", + "description": "Munkaterületek listájának megjelenítése a letöltések és beállítások gombjaival.", + "disabledHint": "Nem lehet letiltani a munkaterületsávot, ha a menüsor le van tiltva. Az alkalmazásbeállítások elérhetetlenné válnának." }, "trayIcon": { "title": "Tálcaikon", - "description": "Tálcaikon megjelenítése a rendszer tálcáján. Ha a tálcaikon aktív, akkor az alkalmazás a tálcára lesz elrejtve a bezáráskor. Egyébként kilép az alkalmazásból." + "titleDarwin": "Menüsáv extra", + "description": "Tálcaikon megjelenítése a rendszer tálcáján. Ha a tálcaikon aktív, akkor az alkalmazás a tálcára lesz elrejtve a bezáráskor. Egyébként kilép az alkalmazásból.", + "descriptionDarwin": "Az alkalmazás ikonjának megjelenítése a menüsávon." }, "availableBrowsers": { "title": "Alapértelmezett böngésző", - "description": "Annak kiválasztása, hogy melyik böngésző nyissa meg a külső hivatkozásokat az alkalmazásból. A rendszer alapértelmezettje az operációs rendszer beállításait használja.", + "description": "Annak kiválasztása, hogy melyik böngésző nyissa meg a külső hivatkozásokat az alkalmazásból.", "systemDefault": "Rendszer alapértelmezettje", "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." @@ -312,7 +342,8 @@ }, "transparentWindow": { "title": "Átlátszó ablak hatás", - "description": "Natív vibrálás vagy átlátszóság hatás engedélyezése az ablakhoz. Újraindítást igényel az alkalmazáshoz." + "description": "Natív vibrálás vagy átlátszóság hatás engedélyezése az ablakhoz.", + "hint": "Újraindítást igényel az alkalmazáshoz." }, "themeAppearance": { "title": "Téma", @@ -324,6 +355,27 @@ "verboseOutlookLogging": { "title": "Részletes Outlook naptár naplózás", "description": "Részletes Exchange/NTLM hibakeresési naplók engedélyezése az Outlook naptár integrációs problémáinak elhárításához." + }, + "telephonyServer": { + "title": "Telefonos munkaterület", + "description": "Annak kiválasztása, hogy melyik munkaterület kezelje a bejövő telefonhívásokat (tel: és callto: hivatkozások).", + "auto": "Automatikus (minden alkalommal megkérdezi)" + }, + "outlookCalendarSyncInterval": { + "title": "Outlook naptár szinkronizálási időköz", + "description": "A naptáresemények frissítési ellenőrzésének gyakorisága percben megadva (1–60)." + }, + "detailedEventsLogging": { + "title": "Részletes eseménynaplózás", + "description": "Az Outlook és a Rocket.Chat között a naptár-szinkronizálás során kicserélt teljes eseményadatok naplózása. Hasznos a szinkronizálási problémák elhárításához." + }, + "debugLogging": { + "title": "Részletes naplózás", + "description": "Az összes konzolkimenet naplófájlba írása. Ha le van tiltva, csak a hibák és a fontos üzenetek kerülnek mentésre, így a naplók kisebbek és áttekinthetőbbek maradnak." + }, + "e2ePdfPreviewSizeLimit": { + "title": "PDF előnézet méretkorlátja titkosított szobákban (MB)", + "description": "A titkosított fájlok az előnézetek megjelenítéséhez a memóriába töltődnek. Az ennél a korlátnál nagyobb fájlok közvetlenül letöltődnek." } } }, @@ -352,7 +404,7 @@ "disableGpu": "GPU letiltása", "documentation": "Dokumentáció", "downloads": "Letöltések", - "settings": "Beállítások", + "settings": "Alkalmazásbeállítások", "editMenu": "S&zerkesztés", "fileMenu": "&Fájl", "forward": "&Előre", @@ -424,7 +476,7 @@ "sidebar": { "addNewServer": "Új kiszolgáló hozzáadása", "downloads": "Letöltések", - "settings": "Beállítások", + "settings": "Alkalmazásbeállítások", "menuTitle": "Az alkalmazás személyre szabása és vezérlése", "item": { "reload": "Újratöltés", @@ -634,4 +686,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 b32540f4a2..67cd5fdd31 100644 --- a/src/i18n/it-IT.i18n.json +++ b/src/i18n/it-IT.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "Segnala errori a Rocket.Chat", + "description": "Segnala in modo anonimo i problemi agli sviluppatori dell'app. Le informazioni condivise includono il numero di versione dell'app, il tipo di sistema operativo, l'URL dello spazio di lavoro, la lingua del dispositivo e il tipo di errore. Nessun contenuto o nome utente viene condiviso.", + "masDescription": "Questa opzione è disabilitata quando installata dal Mac App Store. Gli errori vengono segnalati tramite il processo di segnalazione errori del Mac App Store." + }, + "flashFrame": { + "title": "Lampeggia finestra", + "titleDarwin": "Rimbalza icona del dock", + "description": "Fai lampeggiare la finestra quando arriva un nuovo messaggio.", + "onLinux": "Alcune distribuzioni Linux non supportano questa funzione.", + "descriptionDarwin": "L'icona del dock rimbalza quando arriva un nuovo messaggio." + }, + "hardwareAcceleration": { + "title": "Accelerazione hardware", + "description": "Migliora il rendering visivo e le prestazioni. Disabilitala se riscontri problemi grafici o instabilità.", + "hint": "Ricarica l'app al cambiamento." + }, + "videoCallScreenCaptureFallback": { + "title": "Ripiego per la cattura schermo nelle videochiamate", + "description": "Disabilita Windows Graphics Capture in modo che la condivisione dello schermo funzioni nelle sessioni di Desktop remoto.", + "hint": "L'app si riavvia quando questa opzione viene modificata.", + "forcedDescription": "Attualmente applicato perché l'app ha rilevato una sessione di Desktop remoto. L'interruttore controlla gli avvii futuri quando l'esecuzione avviene in locale." + }, + "internalVideoChatWindow": { + "title": "Videochiamate nell'app", + "description": "Apri le videochiamate nella finestra dell'app invece che nel browser. Le chiamate Google Meet e Jitsi possono aprirsi solo nel browser.", + "masDescription": "Questa opzione è disabilitata quando installata dal Mac App Store. Per motivi di sicurezza, le videochiamate si aprono sempre nel browser per impostazione predefinita." + }, + "minimizeOnClose": { + "title": "Riduci a icona alla chiusura", + "description": "Riduci a icona senza chiudere l'app quando si chiude la finestra.", + "disabledHint": "L'icona nella barra delle applicazioni deve essere disabilitata." + }, + "menubar": { + "title": "Barra dei menu", + "description": "Mostra la barra dei menu nella parte superiore della finestra.", + "disabledHint": "Impossibile disabilitare la barra dei menu quando la barra dello spazio di lavoro è disabilitata. Le impostazioni dell'app diventerebbero inaccessibili." + }, + "sidebar": { + "title": "Barra dello spazio di lavoro", + "description": "Mostra l'elenco degli spazi di lavoro con i pulsanti per download e impostazioni.", + "disabledHint": "Impossibile disabilitare la barra dello spazio di lavoro quando la barra dei menu è disabilitata. Le impostazioni dell'app diventerebbero inaccessibili." + }, + "trayIcon": { + "title": "Icona nella barra delle applicazioni", + "titleDarwin": "Elemento extra nella barra dei menu", + "description": "Mostra l'icona nella barra delle applicazioni del sistema. Invece di chiudersi, alla chiusura l'app viene nascosta nella barra delle applicazioni quando è abilitata.", + "descriptionDarwin": "Mostra l'icona dell'app nella barra dei menu." + }, + "availableBrowsers": { + "title": "Browser predefinito", + "description": "Scegli in quale browser aprire i collegamenti esterni.", + "systemDefault": "Predefinito di sistema", + "loading": "Caricamento browser...", + "current": "Attualmente in uso:" + }, + "telephonyServer": { + "title": "Spazio di lavoro per telefonia", + "description": "Scegli quale spazio di lavoro gestisce le chiamate in arrivo (collegamenti tel: e callto:).", + "auto": "Automatico (chiedi ogni volta)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Cancella autorizzazioni cattura schermo", + "description": "Cancella le autorizzazioni anche se in precedenza era stato selezionato “non chiedere più” per le chiamate." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Consenti cattura schermo nelle videochiamate", + "description": "Consenti la cattura dello schermo nelle videochiamate. Richiede l'autorizzazione a ogni videochiamata." + }, + "ntlmCredentials": { + "title": "Credenziali NTLM", + "description": "Consenti l'uso delle credenziali NTLM durante la connessione a uno spazio di lavoro.", + "domains": "Domini che verranno usati come credenziali. Separati da virgola. Usa * per corrispondere a tutti i server." + }, + "videoCallWindowPersistence": { + "title": "Mantieni la posizione della finestra delle videochiamate", + "description": "Ricorda la posizione e la dimensione della finestra delle videochiamate tra le sessioni." + }, "transparentWindow": { "title": "Effetto finestra trasparente", - "description": "Abilita l'effetto nativo di vibrazione/trasparenza per la finestra. Richiede il riavvio per applicare." + "description": "Abilita l'effetto nativo di vibrazione/trasparenza per la finestra.", + "hint": "Richiede il riavvio per applicare." }, "themeAppearance": { "title": "Tema", @@ -18,9 +97,51 @@ "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." + }, + "outlookCalendarSyncInterval": { + "title": "Intervallo di sincronizzazione del calendario di Outlook", + "description": "Frequenza dei controlli di aggiornamento degli eventi del calendario in minuti (1–60)." + }, + "verboseOutlookLogging": { + "title": "Registrazione dettagliata del calendario di Outlook", + "description": "Abilita registri di debug Exchange/NTLM dettagliati per risolvere i problemi di integrazione del calendario di Outlook." + }, + "detailedEventsLogging": { + "title": "Registrazione dettagliata degli eventi", + "description": "Registra i dati completi degli eventi scambiati tra Outlook e Rocket.Chat durante la sincronizzazione del calendario. Utile per diagnosticare i problemi di sincronizzazione." + }, + "debugLogging": { + "title": "Registrazione dettagliata", + "description": "Scrive tutto l'output della console nel file di registro. Quando è disabilitata, vengono salvati solo gli errori e i messaggi importanti, mantenendo i registri più piccoli e mirati." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Limite di dimensione dell'anteprima PDF nelle stanze crittografate (MB)", + "description": "I file crittografati vengono caricati in memoria per le anteprime. I file più grandi di questo limite verranno scaricati direttamente." } } }, + "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 346349467a..6cd487abef 100644 --- a/src/i18n/ja.i18n.json +++ b/src/i18n/ja.i18n.json @@ -101,6 +101,11 @@ "answerCall": "通話に応答する", "recordMessage": "メッセージを録音する" } + }, + "telephonySelectServer": { + "title": "サーバーを選択", + "message": "この通話をどのサーバーで処理しますか?", + "rememberChoice": "この選択を記憶する" } }, "documentViewer": { @@ -121,10 +126,99 @@ } }, "settings": { + "title": "アプリの設定", + "general": "一般", + "sections": { + "appUi": "アプリUI", + "systemUi": "システムUI", + "systemBehavior": "システムの動作", + "calling": "通話", + "other": "その他・技術", + "logging": "ログ" + }, "options": { + "report": { + "title": "エラーをRocket.Chatに報告する", + "description": "アプリの問題を匿名で開発者に報告します。共有される情報にはアプリのバージョン番号、OSの種類、ワークスペースのURL、端末の言語、エラーの種類が含まれます。コンテンツやユーザー名は共有されません。", + "masDescription": "Mac App Storeからインストールされている場合、このオプションは無効です。エラーはMac App Storeのエラー報告プロセスを通じて報告されます。" + }, + "flashFrame": { + "title": "フラッシュフレーム", + "titleDarwin": "Dockアイコンをバウンス", + "description": "新しいメッセージを受信したときにウィンドウをフラッシュします。", + "onLinux": "一部のLinuxディストリビューションではこの機能をサポートしていません。", + "descriptionDarwin": "新しいメッセージを受信したときにDockアイコンがバウンスします。" + }, + "hardwareAcceleration": { + "title": "ハードウェアアクセラレーション", + "description": "映像レンダリングとパフォーマンスを向上させます。画面の乱れや不安定さが生じる場合は無効にしてください。", + "hint": "変更するとアプリが再起動します。" + }, + "videoCallScreenCaptureFallback": { + "title": "ビデオ通話の画面キャプチャーフォールバック", + "description": "リモートデスクトップセッションで画面共有が機能するようにWindows Graphics Captureを無効にします。", + "hint": "このオプションを変更するとアプリが再起動します。", + "forcedDescription": "アプリがリモートデスクトップセッションを検出したため、現在強制適用されています。このトグルはローカルで実行する際の将来の起動を制御します。" + }, + "internalVideoChatWindow": { + "title": "アプリ内でビデオ通話", + "description": "ブラウザーではなくアプリウィンドウ内でビデオ通話を開きます。Google Meet と Jitsi の通話はブラウザーでのみ開くことができます。", + "masDescription": "Mac App Storeからインストールされている場合、このオプションは無効です。セキュリティ上の理由から、ビデオ通話は既定でブラウザーで開きます。" + }, + "minimizeOnClose": { + "title": "閉じるときに最小化", + "description": "閉じるときにアプリを終了せずに最小化します。", + "disabledHint": "トレイアイコンを無効にする必要があります。" + }, + "menubar": { + "title": "メニューバー", + "description": "ウィンドウ上部にメニューバーを表示します。", + "disabledHint": "ワークスペースバーが無効になっている場合はメニューバーを無効にできません。アプリの設定にアクセスできなくなります。" + }, + "sidebar": { + "title": "ワークスペースバー", + "description": "ダウンロードと設定ボタン付きのワークスペース一覧を表示します。", + "disabledHint": "メニューバーが無効になっている場合はワークスペースバーを無効にできません。アプリの設定にアクセスできなくなります。" + }, + "trayIcon": { + "title": "トレイアイコン", + "titleDarwin": "メニューバーエクストラ", + "description": "システムトレイにトレイアイコンを表示します。有効にすると、閉じるときにアプリが終了せずにトレイに格納されます。", + "descriptionDarwin": "メニューバーにアプリアイコンを表示します。" + }, + "availableBrowsers": { + "title": "既定のブラウザー", + "description": "外部リンクを開くブラウザーを選択します。", + "systemDefault": "システムの既定", + "loading": "ブラウザーを読み込み中...", + "current": "現在使用中:" + }, + "telephonyServer": { + "title": "電話ワークスペース", + "description": "着信通話(tel:、callto: リンク)を処理するワークスペースを選択します。", + "auto": "自動(毎回確認)" + }, + "clearPermittedScreenCaptureServers": { + "title": "画面キャプチャーの権限をクリア", + "description": "通話で「今後確認しない」を選択済みの場合でも権限をクリアします。" + }, + "allowScreenCaptureOnVideoCalls": { + "title": "ビデオ通話での画面キャプチャーを許可", + "description": "ビデオ通話での画面キャプチャーを許可します。ビデオ通話ごとに権限を確認します。" + }, + "ntlmCredentials": { + "title": "NTLM認証情報", + "description": "ワークスペースへの接続時にNTLM認証情報の使用を許可します。", + "domains": "認証情報として使用するドメイン。カンマ区切りで入力してください。* を使用するとすべてのサーバーに一致します。" + }, + "videoCallWindowPersistence": { + "title": "ビデオ通話ウィンドウの位置を保持", + "description": "セッション間でビデオ通話ウィンドウの位置とサイズを記憶します。" + }, "transparentWindow": { "title": "透明ウィンドウ効果", - "description": "ウィンドウのネイティブなビブランシー/透明効果を有効にします。適用するには再起動が必要です。" + "description": "ウィンドウのネイティブなビブランシー/透明効果を有効にします。", + "hint": "適用するには再起動が必要です。" }, "themeAppearance": { "title": "テーマ", @@ -132,6 +226,41 @@ "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 またはオペレーティングシステムによって予約されています。" + }, + "outlookCalendarSyncInterval": { + "title": "Outlookカレンダーの同期間隔", + "description": "カレンダーイベントの更新確認頻度(分単位、1〜60)。" + }, + "verboseOutlookLogging": { + "title": "Outlookカレンダーの詳細ログ", + "description": "Outlookカレンダー連携の問題解決のためにExchange/NTLMデバッグログを有効にします。" + }, + "detailedEventsLogging": { + "title": "イベントの詳細ログ", + "description": "カレンダー同期時にOutlookとRocket.Chat間でやり取りされるイベントデータ全体をログに記録します。同期の問題の診断に役立ちます。" + }, + "debugLogging": { + "title": "詳細ログ", + "description": "すべてのコンソール出力をログファイルに書き込みます。無効にすると、エラーと重要なメッセージのみが保存され、ログが小さくまとまります。" + }, + "e2ePdfPreviewSizeLimit": { + "title": "暗号化ルームでのPDFプレビューサイズ制限(MB)", + "description": "暗号化されたファイルはプレビューのためにメモリーに読み込まれます。この制限を超えるファイルは直接ダウンロードされます。" } } }, @@ -158,6 +287,7 @@ "cut": "切り取り (&T)", "developerMode": "開発者モード", "documentation": "ドキュメント", + "settings": "アプリの設定", "editMenu": "編集 (&E)", "fileMenu": "ファイル (&F)", "forward": "進む(&F)", @@ -207,6 +337,7 @@ }, "sidebar": { "addNewServer": "新しいサーバーを追加", + "settings": "アプリの設定", "item": { "reload": "サーバーを再読み込み", "remove": "サーバーを削除", diff --git a/src/i18n/nb-NO.i18n.json b/src/i18n/nb-NO.i18n.json index b8d22ec0e8..fc8f4006ef 100644 --- a/src/i18n/nb-NO.i18n.json +++ b/src/i18n/nb-NO.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "Rapporter feil til Rocket.Chat", + "description": "Rapporter problemer anonymt til apputviklerne. Delt informasjon inkluderer appversjonsnummer, operativsystemtype, arbeidsområde-URL, enhetsspråk og feiltype. Ingen innhold eller brukernavn deles.", + "masDescription": "Dette alternativet er deaktivert ved installasjon fra Mac App Store. Feil rapporteres gjennom Mac App Store sin feilrapporteringsprosess." + }, + "flashFrame": { + "title": "Blink i vinduet", + "titleDarwin": "Spretthopp i dokk-ikonet", + "description": "Blink i vinduet når en ny melding mottas.", + "onLinux": "Noen Linux-distribusjoner støtter ikke denne funksjonen.", + "descriptionDarwin": "Dokk-ikonet spretter når en ny melding mottas." + }, + "hardwareAcceleration": { + "title": "Maskinvareakselerasjon", + "description": "Forbedrer visuell gjengivelse og ytelse. Deaktiver hvis du opplever visuelle feil eller ustabilitet.", + "hint": "Laster appen på nytt ved endring." + }, + "videoCallScreenCaptureFallback": { + "title": "Reservevalg for skjermopptak i videosamtaler", + "description": "Deaktiver Windows Graphics Capture slik at skjermdeling fungerer i økter med eksternt skrivebord.", + "hint": "Appen starter på nytt når dette alternativet endres.", + "forcedDescription": "For øyeblikket påtvunget fordi appen oppdaget en økt med eksternt skrivebord. Bryteren styrer fremtidige oppstarter når den kjøres lokalt." + }, + "internalVideoChatWindow": { + "title": "Videosamtaler i appen", + "description": "Åpne videosamtaler i appvinduet i stedet for nettleseren. Google Meet- og Jitsi-samtaler kan bare åpnes i nettleseren.", + "masDescription": "Dette alternativet er deaktivert ved installasjon fra Mac App Store. Av sikkerhetsgrunner åpnes videosamtaler alltid i nettleseren som standard." + }, + "minimizeOnClose": { + "title": "Minimer ved lukking", + "description": "Minimer og ikke avslutt appen når vinduet lukkes.", + "disabledHint": "Systemkurv-ikonet må være deaktivert." + }, + "menubar": { + "title": "Menylinje", + "description": "Vis menylinjen øverst i vinduet.", + "disabledHint": "Kan ikke deaktivere menylinjen når arbeidsområdelinjen er deaktivert. Appinnstillingene ville blitt utilgjengelige." + }, + "sidebar": { + "title": "Arbeidsområdelinje", + "description": "Vis arbeidsområdelisten med knapper for nedlastinger og innstillinger.", + "disabledHint": "Kan ikke deaktivere arbeidsområdelinjen når menylinjen er deaktivert. Appinnstillingene ville blitt utilgjengelige." + }, + "trayIcon": { + "title": "Systemkurv-ikon", + "titleDarwin": "Ekstra i menylinjen", + "description": "Vis systemkurv-ikon i systemkurven. I stedet for å avslutte skjules appen i systemkurven ved lukking når dette er aktivert.", + "descriptionDarwin": "Vis app-ikonet i menylinjen." + }, + "availableBrowsers": { + "title": "Standard nettleser", + "description": "Velg hvilken nettleser eksterne lenker skal åpnes i.", + "systemDefault": "Systemstandard", + "loading": "Laster nettlesere...", + "current": "Bruker for øyeblikket:" + }, + "telephonyServer": { + "title": "Telefoniarbeidsområde", + "description": "Velg hvilket arbeidsområde som håndterer innkommende anrop (tel:- og callto:-lenker).", + "auto": "Automatisk (spør hver gang)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Fjern tillatelser for skjermopptak", + "description": "Fjern tillatelser selv om «ikke spør igjen» tidligere ble valgt for anrop." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Tillat skjermopptak i videosamtaler", + "description": "Tillat skjermopptak i videosamtaler. Ber om tillatelse i hver videosamtale." + }, + "ntlmCredentials": { + "title": "NTLM-legitimasjon", + "description": "Tillat at NTLM-legitimasjon brukes ved tilkobling til et arbeidsområde.", + "domains": "Domener som vil bli brukt som legitimasjon. Atskilt med komma. Bruk * for å samsvare med alle servere." + }, + "videoCallWindowPersistence": { + "title": "Behold posisjonen til videosamtalevinduet", + "description": "Husk posisjonen og størrelsen på videosamtalevinduet mellom økter." + }, "transparentWindow": { "title": "Gjennomsiktig vinduseffekt", - "description": "Aktiver innfødt vibrasjon/gjennomsiktighetseffekt for vinduet. Krever omstart for å bruke." + "description": "Aktiver innfødt vibrasjon/gjennomsiktighetseffekt for vinduet.", + "hint": "Krever omstart for å bruke." }, "themeAppearance": { "title": "Tema", @@ -18,9 +97,51 @@ "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." + }, + "outlookCalendarSyncInterval": { + "title": "Synkroniseringsintervall for Outlook-kalender", + "description": "Hvor ofte det sjekkes etter kalenderoppdateringer, i minutter (1–60)." + }, + "verboseOutlookLogging": { + "title": "Detaljert logging for Outlook-kalender", + "description": "Aktiver detaljerte Exchange/NTLM-feilsøkingslogger for å feilsøke problemer med integrasjon av Outlook-kalender." + }, + "detailedEventsLogging": { + "title": "Detaljert hendelseslogging", + "description": "Logg fullstendige hendelsesdata som utveksles mellom Outlook og Rocket.Chat under kalendersynkronisering. Nyttig for å diagnostisere synkroniseringsproblemer." + }, + "debugLogging": { + "title": "Detaljert logging", + "description": "Skriver all konsollutskrift til loggfilen. Når dette er deaktivert, lagres bare feil og viktige meldinger, slik at loggene holdes mindre og mer fokuserte." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Størrelsesgrense for PDF-forhåndsvisning i krypterte rom (MB)", + "description": "Krypterte filer lastes inn i minnet for forhåndsvisninger. Filer større enn denne grensen lastes ned direkte." } } }, + "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 8af314a063..c0b836d348 100644 --- a/src/i18n/nn.i18n.json +++ b/src/i18n/nn.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "Rapporter feil til Rocket.Chat", + "description": "Rapporter problem anonymt til apputviklarane. Delt informasjon inkluderer appversjonsnummer, operativsystemtype, arbeidsområde-URL, einingsspråk og feiltype. Ikkje noko innhald eller brukarnamn vert delt.", + "masDescription": "Dette valet er deaktivert ved installasjon frå Mac App Store. Feil vert rapporterte gjennom Mac App Store sin feilrapporteringsprosess." + }, + "flashFrame": { + "title": "Blink i vindauget", + "titleDarwin": "Spretthopp i dokk-ikonet", + "description": "Blink i vindauget når ei ny melding kjem.", + "onLinux": "Nokre Linux-distribusjonar støttar ikkje denne funksjonen.", + "descriptionDarwin": "Dokk-ikonet sprett når ei ny melding kjem." + }, + "hardwareAcceleration": { + "title": "Maskinvareakselerasjon", + "description": "Betrar visuell gjengjeving og yting. Deaktiver dersom du opplever visuelle feil eller ustabilitet.", + "hint": "Lastar appen på nytt ved endring." + }, + "videoCallScreenCaptureFallback": { + "title": "Reserveval for skjermopptak i videosamtalar", + "description": "Deaktiver Windows Graphics Capture slik at skjermdeling fungerer i økter med eksternt skrivebord.", + "hint": "Appen startar på nytt når dette valet vert endra.", + "forcedDescription": "For augneblinken påtvinga fordi appen oppdaga ei økt med eksternt skrivebord. Brytaren styrer framtidige oppstartar når han køyrer lokalt." + }, + "internalVideoChatWindow": { + "title": "Videosamtalar i appen", + "description": "Opne videosamtalar i appvindauget i staden for nettlesaren. Google Meet- og Jitsi-samtalar kan berre opnast i nettlesaren.", + "masDescription": "Dette valet er deaktivert ved installasjon frå Mac App Store. Av tryggleiksgrunnar vert videosamtalar alltid opna i nettlesaren som standard." + }, + "minimizeOnClose": { + "title": "Minimer ved lukking", + "description": "Minimer og ikkje avslutt appen når vindauget vert lukka.", + "disabledHint": "Systemkurv-ikonet må vere deaktivert." + }, + "menubar": { + "title": "Menylinje", + "description": "Vis menylinja øvst i vindauget.", + "disabledHint": "Kan ikkje deaktivere menylinja når arbeidsområdelinja er deaktivert. Appinnstillingane ville blitt utilgjengelege." + }, + "sidebar": { + "title": "Arbeidsområdelinje", + "description": "Vis arbeidsområdelista med knappar for nedlastingar og innstillingar.", + "disabledHint": "Kan ikkje deaktivere arbeidsområdelinja når menylinja er deaktivert. Appinnstillingane ville blitt utilgjengelege." + }, + "trayIcon": { + "title": "Systemkurv-ikon", + "titleDarwin": "Ekstra i menylinja", + "description": "Vis systemkurv-ikon i systemkurven. I staden for å avslutte vert appen gøymd i systemkurven ved lukking når dette er aktivert.", + "descriptionDarwin": "Vis app-ikonet i menylinja." + }, + "availableBrowsers": { + "title": "Standard nettlesar", + "description": "Vel kva for nettlesar eksterne lenkjer skal opnast i.", + "systemDefault": "Systemstandard", + "loading": "Lastar nettlesarar...", + "current": "Brukar for augneblinken:" + }, + "telephonyServer": { + "title": "Telefoniarbeidsområde", + "description": "Vel kva for arbeidsområde som handterer innkomande anrop (tel:- og callto:-lenkjer).", + "auto": "Automatisk (spør kvar gong)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Fjern løyve for skjermopptak", + "description": "Fjern løyve sjølv om «ikkje spør igjen» tidlegare vart valt for anrop." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Tillat skjermopptak i videosamtalar", + "description": "Tillat skjermopptak i videosamtalar. Spør om løyve i kvar videosamtale." + }, + "ntlmCredentials": { + "title": "NTLM-legitimasjon", + "description": "Tillat at NTLM-legitimasjon vert brukt ved tilkopling til eit arbeidsområde.", + "domains": "Domene som vil bli brukte som legitimasjon. Skilde med komma. Bruk * for å samsvare med alle tenarar." + }, + "videoCallWindowPersistence": { + "title": "Behald posisjonen til videosamtalevindauget", + "description": "Hugs posisjonen og storleiken på videosamtalevindauget mellom økter." + }, "transparentWindow": { "title": "Gjennomsiktig vinduseffekt", - "description": "Aktiver innfødd vibrasjon/gjennomsiktighetseffekt for vinduet. Krever omstart for å bruke." + "description": "Aktiver innfødd vibrasjon/gjennomsiktighetseffekt for vinduet.", + "hint": "Krever omstart for å bruke." }, "themeAppearance": { "title": "Tema", @@ -18,9 +97,51 @@ "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." + }, + "outlookCalendarSyncInterval": { + "title": "Synkroniseringsintervall for Outlook-kalender", + "description": "Kor ofte det vert sjekka etter kalenderoppdateringar, i minutt (1–60)." + }, + "verboseOutlookLogging": { + "title": "Detaljert logging for Outlook-kalender", + "description": "Aktiver detaljerte Exchange/NTLM-feilsøkingsloggar for å feilsøke problem med integrasjon av Outlook-kalender." + }, + "detailedEventsLogging": { + "title": "Detaljert hendingslogging", + "description": "Logg fullstendige hendingsdata som vert utveksla mellom Outlook og Rocket.Chat under kalendersynkronisering. Nyttig for å diagnostisere synkroniseringsproblem." + }, + "debugLogging": { + "title": "Detaljert logging", + "description": "Skriv all konsollutskrift til loggfila. Når dette er deaktivert, vert berre feil og viktige meldingar lagra, slik at loggane held seg mindre og meir fokuserte." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Storleiksgrense for PDF-førehandsvising i krypterte rom (MB)", + "description": "Krypterte filer vert lasta inn i minnet for førehandsvisingar. Filer større enn denne grensa vert lasta ned direkte." } } }, + "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 b3e10c1b77..745156beef 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?", @@ -241,76 +246,101 @@ } }, "settings": { - "title": "Innstillinger", - "general": "General", + "title": "App-innstillinger", + "general": "Generelt", "certificates": "Sertifikater", "developer": "Utvikler", "sections": { + "appUi": "App-grensesnitt", + "systemUi": "Systemgrensesnitt", + "systemBehavior": "Systemoppførsel", + "calling": "Samtaler", + "other": "Annet og teknisk", "logging": "Logging" }, "options": { "report": { - "title": "Rapporter feil til utviklere", - "description": "Rapporter feil anonymt til utviklerne. Delt informasjon inkluderer appversjonsnummer, operativsystemtype, server-URL, enhetsspråk og feiltype. Ingen innhold eller brukernavn deles.", - "masDescription": "Dette alternativet er deaktivert når det er installert fra Mac App Store, feilene vil bli rapportert gjennom Mac App Store feilrapporteringsprosessen." + "title": "Rapporter feil til Rocket.Chat", + "description": "Rapporter feil anonymt til apputviklerne. Delt informasjon inkluderer appversjonsnummer, operativsystemtype, arbeidsområde-URL, enhetsspråk og feiltype. Ingen innhold eller brukernavn deles.", + "masDescription": "Dette alternativet er deaktivert når det er installert fra Mac App Store. Feil rapporteres via Mac App Store sin feilrapporteringsprosess." }, "flashFrame": { - "title": "Aktiver Flash Frame", - "titleDarwin": "Slå av/på Dock Bounce på varsel", - "description": "Blinker vinduet for å tiltrekke brukerens oppmerksomhet.", + "title": "Blink vindu", + "titleDarwin": "Sprett dock-ikon", + "description": "Blinker vinduet når en ny melding mottas.", "onLinux": "Noen Linux-distroer har ikke støtte for denne funksjonen.", - "descriptionDarwin": "Spretter appikonet i dokken for å tiltrekke brukerens oppmerksomhet." + "descriptionDarwin": "Dock-ikonet spretter når en ny melding mottas." }, "hardwareAcceleration": { "title": "Maskinvareakselerasjon", - "description": "Muliggjør maskinvareakselerasjon når tilgjengelig. Applikasjonen vil lastes inn på nytt ved endring." + "description": "Forbedrer visuell gjengivelse og ytelse. Deaktiver hvis du opplever visuelle feil eller ustabilitet.", + "hint": "Laster appen på nytt ved endring." }, "videoCallScreenCaptureFallback": { "title": "Reserveopptak for videosamtaler", - "description": "Deaktiverer Windows Graphics Capture slik at deling virker i RDP-økter. Appen starter på nytt når du endrer dette valget.", + "description": "Deaktiverer Windows Graphics Capture slik at deling virker i RDP-økter.", + "hint": "Appen starter på nytt når du endrer dette valget.", "forcedDescription": "Allerede aktivert fordi appen oppdaget en RDP-økt. Bytt til for å bestemme oppførsel ved fremtidige lokale oppstarter." }, "internalVideoChatWindow": { - "title": "Åpne videochat ved hjelp av applikasjonsvinduet", - "description": "Når innstilt, vil Videochat åpnes ved hjelp av et programvindu, ellers vil standardnettleseren brukes. Google Meet og Jitsi skjermopptak støttes ikke i Electron-applikasjoner, så denne konfigurasjonen endrer ikke anropsadferd, som åpnes i nettleseren.", - "masDescription": "Dette alternativet er deaktivert når det er installert fra Mac App Store, av sikkerhetsgrunner vil det åpne Video Chat ved å bruke nettleseren som standard." + "title": "Videosamtaler inne i appen", + "description": "Åpne videosamtaler i appvinduet i stedet for nettleseren. Google Meet og Jitsi-samtaler kan bare åpnes i nettleseren.", + "masDescription": "Dette alternativet er deaktivert når det er installert fra Mac App Store. Av sikkerhetsgrunner åpnes videosamtaler alltid i nettleseren som standard." }, "minimizeOnClose": { - "title": "Minimer på nært hold", - "description": "Når den er lukket, vil appen minimeres, ellers avsluttes applikasjonen. Systemstatusikon må deaktiveres for at dette skal tre i kraft." + "title": "Minimer ved lukking", + "description": "Minimer og ikke avslutt appen ved lukking.", + "disabledHint": "Systemstatusikonet må være deaktivert." }, "menubar": { "title": "Menylinje", "description": "Vis menylinjen øverst i vinduet.", - "disabledHint": "Kan ikke deaktivere menylinjen når sidefeltet er deaktivert. Innstillinger ville bli utilgjengelige." + "disabledHint": "Kan ikke deaktivere menylinjen når arbeidsområdelinjen er deaktivert. App-innstillinger ville bli utilgjengelige." }, "sidebar": { - "title": "Sidefelt", - "description": "Vis sidefeltet til venstre i vinduet med serverlisten, nedlastinger og innstillinger.", - "disabledHint": "Kan ikke deaktivere sidefeltet når menylinjen er deaktivert. Innstillinger ville bli utilgjengelige." + "title": "Arbeidsområdelinje", + "description": "Vis liste over arbeidsområder med knapper for nedlastinger og innstillinger.", + "disabledHint": "Kan ikke deaktivere arbeidsområdelinjen når menylinjen er deaktivert. App-innstillinger ville bli utilgjengelige." }, "trayIcon": { "title": "Systemstatusikon", - "description": "Vis ikonet på systemstatusfeltet. Hvis systemstatusikonet er aktivt, vil appen være skjult ved lukke. Ellers avsluttes programmet." + "titleDarwin": "Menylinje-ekstra", + "description": "Vis ikonet på systemstatusfeltet. Når aktivert skjules appen i statusfeltet ved lukking i stedet for å avslutte.", + "descriptionDarwin": "Vis app-ikon i menylinjen." }, "availableBrowsers": { "title": "Standard nettleser", - "description": "Velg hvilken nettleser som skal åpne eksterne lenker fra denne appen. Systemstandard bruker operativsysteminnstillingene dine.", + "description": "Velg hvilken nettleser som skal åpne eksterne lenker fra denne appen.", "systemDefault": "Systemstandard", "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." + "description": "Fjern tillatelser selv om «ikke spør igjen» ble valgt tidligere for samtaler." }, "allowScreenCaptureOnVideoCalls": { "title": "Tillat skjermopptak på videosamtaler", - "description": "Tillat skjermfangst på videosamtaler. Dette vil be om tillatelse for hver videosamtale." + "description": "Tillat skjermfangst på videosamtaler. Ber om tillatelse for hver videosamtale." }, "ntlmCredentials": { "title": "NTLM-påloggingsinformasjon", - "description": "Tillat at NTLM-påloggingsinformasjon brukes når du kobler til en server.", + "description": "Tillat at NTLM-påloggingsinformasjon brukes når du kobler til et arbeidsområde.", "domains": "Domener som vil bruke påloggingsinformasjonen. Atskilt med komma. Bruk * for å matche alle domener." }, "videoCallWindowPersistence": { @@ -319,25 +349,40 @@ }, "transparentWindow": { "title": "Gjennomsiktig vinduseffekt", - "description": "Aktiver innfødt vibrans/gjennomsiktighetseffekt for vinduet. Krever omstart for å brukes." + "description": "Aktiver innfødt vibrans/gjennomsiktighetseffekt for vinduet.", + "hint": "Krever omstart for å brukes." }, "themeAppearance": { "title": "Tema", - "description": "Velg fargtemaet for applikasjonen.", + "description": "App-fargetema.", "auto": "Følg system", "light": "Lys", "dark": "Mørk" }, "outlookCalendarSyncInterval": { "title": "Synkroniseringsintervall for Outlook-kalender", - "description": "Hvor ofte skal Outlook-kalenderhendelser synkroniseres, i minutter (1–60)." + "description": "Frekvens for oppdateringssjekk av kalenderhendelser i minutter (1–60)." }, "verboseOutlookLogging": { - "title": "Utførlig logging av Outlook-kalenderen", - "description": "Aktiver detaljerte feilsøkingslogger for Exchange/NTLM for feilsøking av problemer med integrering av Outlook-kalender." + "title": "Utførlig logging av Outlook-kalender", + "description": "Aktiver detaljerte Exchange/NTLM-feilsøkingslogger for feilsøking av problemer med Outlook-kalenderintegrasjon." }, "detailedEventsLogging": { - "title": "Detaljert hendelseslogging" + "title": "Detaljert hendelseslogging", + "description": "Logg fullstendige hendelsesdata utvekslet mellom Outlook og Rocket.Chat under kalendersynkronisering. Nyttig for å diagnostisere synkroniseringsproblemer." + }, + "telephonyServer": { + "title": "Telefoni-arbeidsområde", + "description": "Velg hvilket arbeidsområde som håndterer innkommende telefonsamtaler (tel:- og callto:-lenker).", + "auto": "Automatisk (spør hver gang)" + }, + "debugLogging": { + "title": "Detaljert logging", + "description": "Skriver all konsollutdata til loggfilen. Når deaktivert lagres kun feil og viktige meldinger, noe som holder loggene mindre og mer oversiktlige." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Størstegren for PDF-forhåndsvisning i krypterte rom (MB)", + "description": "Krypterte filer lastes inn i minnet for forhåndsvisning. Filer som er større enn denne grensen lastes ned direkte." } } }, @@ -366,7 +411,7 @@ "disableGpu": "Deaktiver GPU", "documentation": "Dokumentasjon", "downloads": "Nedlastinger", - "settings": "Innstillinger", + "settings": "App-innstillinger", "editMenu": "Redigere", "fileMenu": "Fil", "forward": "Framover", @@ -437,7 +482,7 @@ "sidebar": { "addNewServer": "Legg til ny server", "downloads": "Nedlastinger", - "settings": "Innstillinger", + "settings": "App-innstillinger", "menuTitle": "Tilpass og kontroller appen", "item": { "reload": "Last inn på nytt", diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json index 15b0fcb9b2..b07e53195b 100644 --- a/src/i18n/pl.i18n.json +++ b/src/i18n/pl.i18n.json @@ -133,30 +133,136 @@ } }, "settings": { - "title": "Ustawienia", + "title": "Ustawienia aplikacji", "general": "Ogólne", + "sections": { + "appUi": "Interfejs aplikacji", + "systemUi": "Interfejs systemowy", + "systemBehavior": "Zachowanie systemu", + "calling": "Połączenia", + "other": "Inne i techniczne", + "logging": "Rejestrowanie" + }, "options": { + "report": { + "title": "Zgłaszaj błędy do Rocket.Chat", + "description": "Anonimowo zgłaszaj problemy do deweloperów aplikacji. Udostępniane informacje obejmują numer wersji aplikacji, typ systemu operacyjnego, adres URL obszaru roboczego, język urządzenia i rodzaj błędu. Żadne treści ani nazwy użytkowników nie są udostępniane.", + "masDescription": "Ta opcja jest wyłączona w przypadku instalacji z Mac App Store. Błędy są zgłaszane za pośrednictwem procesu raportowania błędów Mac App Store." + }, + "flashFrame": { + "title": "Miganie ramki okna", + "titleDarwin": "Odbijanie ikony w docku", + "description": "Migaj oknem po otrzymaniu nowej wiadomości.", + "onLinux": "Niektóre dystrybucje Linuksa nie obsługują tej funkcji.", + "descriptionDarwin": "Ikona w docku odbija się po otrzymaniu nowej wiadomości." + }, + "hardwareAcceleration": { + "title": "Akceleracja sprzętowa", + "description": "Poprawia renderowanie grafiki i wydajność. Wyłącz, jeśli występują błędy wizualne lub niestabilność.", + "hint": "Ponownie ładuje aplikację po zmianie." + }, + "videoCallScreenCaptureFallback": { + "title": "Alternatywne przechwytywanie ekranu dla połączeń wideo", + "description": "Wyłącz funkcję Windows Graphics Capture, aby udostępnianie ekranu działało w sesjach Pulpitu zdalnego.", + "hint": "Aplikacja uruchamia się ponownie po zmianie tej opcji.", + "forcedDescription": "Obecnie wymuszone, ponieważ aplikacja wykryła sesję Pulpitu zdalnego. Przełącznik kontroluje teraz przyszłe uruchomienia przy pracy lokalnej." + }, + "internalVideoChatWindow": { + "title": "Połączenia wideo wewnątrz aplikacji", + "description": "Otwieraj połączenia wideo w oknie aplikacji zamiast w przeglądarce. Połączenia Google Meet i Jitsi można otwierać tylko w przeglądarce.", + "masDescription": "Ta opcja jest wyłączona w przypadku instalacji z Mac App Store. Ze względów bezpieczeństwa połączenia wideo są domyślnie zawsze otwierane w przeglądarce." + }, + "minimizeOnClose": { + "title": "Minimalizuj przy zamykaniu", + "description": "Minimalizuj aplikację zamiast zamykać ją przy kliknięciu przycisku zamknięcia.", + "disabledHint": "Ikona zasobnika musi być wyłączona." + }, + "menubar": { + "title": "Pasek menu", + "description": "Pokazuj pasek menu u góry okna.", + "disabledHint": "Nie można wyłączyć paska menu, gdy pasek obszarów roboczych jest wyłączony. Ustawienia aplikacji stałyby się niedostępne." + }, + "sidebar": { + "title": "Pasek obszarów roboczych", + "description": "Pokazuj listę obszarów roboczych z przyciskami pobrań i ustawień.", + "disabledHint": "Nie można wyłączyć paska obszarów roboczych, gdy pasek menu jest wyłączony. Ustawienia aplikacji stałyby się niedostępne." + }, "trayIcon": { "title": "Ikona zasobnika", - "description": "Wyświetla ikonę w zasobniku systemowym. Gdy ikona zasobnika jest aktywna, aplikacja będzie minimalizowana do zasobnika przy zamykaniu. W przeciwnym razie aplikacja zostanie zamknięta." + "titleDarwin": "Dodatkowa ikona paska menu", + "description": "Wyświetla ikonę w zasobniku systemowym. Gdy ikona zasobnika jest aktywna, aplikacja będzie minimalizowana do zasobnika przy zamykaniu.", + "descriptionDarwin": "Pokazuje ikonę aplikacji na pasku menu." }, "availableBrowsers": { "title": "Domyślna przeglądarka", - "description": "Wybierz, która przeglądarka będzie otwierać linki zewnętrzne z tej aplikacji. Ustawienie domyślne systemu używa ustawień Twojego systemu operacyjnego.", + "description": "Wybierz, która przeglądarka będzie otwierać linki zewnętrzne.", "systemDefault": "Domyślna systemu", "loading": "Ładowanie przeglądarek...", "current": "Aktualnie używana:" }, + "telephonyServer": { + "title": "Obszar roboczy telefonii", + "description": "Wybierz, który obszar roboczy obsługuje przychodzące połączenia telefoniczne (łącza tel: i callto:).", + "auto": "Automatycznie (pytaj za każdym razem)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Wyczyść uprawnienia przechwytywania ekranu", + "description": "Wyczyść uprawnienia, nawet jeśli wcześniej wybrano opcję „nie pytaj ponownie” dla połączeń." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Zezwalaj na przechwytywanie ekranu podczas połączeń wideo", + "description": "Zezwalaj na przechwytywanie ekranu podczas połączeń wideo. Prosi o uprawnienie przy każdym połączeniu wideo." + }, + "ntlmCredentials": { + "title": "Poświadczenia NTLM", + "description": "Zezwalaj na używanie poświadczeń NTLM podczas łączenia się z obszarem roboczym.", + "domains": "Domeny, dla których będą używane poświadczenia. Oddzielone przecinkami. Użyj *, aby dopasować wszystkie serwery." + }, + "videoCallWindowPersistence": { + "title": "Zachowaj pozycję okna połączenia wideo", + "description": "Pamiętaj pozycję i rozmiar okna połączenia wideo między sesjami." + }, "transparentWindow": { "title": "Efekt przezroczystego okna", - "description": "Włącz natywny efekt wibracji/przezroczystości dla okna. Wymaga ponownego uruchomienia, aby zastosować." + "description": "Włącz natywny efekt przezroczystości w oknie aplikacji.", + "hint": "Wymaga ponownego uruchomienia." }, "themeAppearance": { "title": "Motyw", - "description": "Wybierz motyw kolorystyczny aplikacji.", + "description": "Motyw kolorystyczny aplikacji.", "auto": "Zgodnie z systemem", "light": "Jasny", "dark": "Ciemny" + }, + "outlookCalendarSyncInterval": { + "title": "Interwał synchronizacji kalendarza Outlook", + "description": "Częstotliwość sprawdzania aktualizacji wydarzeń kalendarza w minutach (1–60)." + }, + "verboseOutlookLogging": { + "title": "Szczegółowe rejestrowanie kalendarza Outlook", + "description": "Włącz szczegółowe dzienniki debugowania Exchange/NTLM do rozwiązywania problemów z integracją kalendarza Outlook." + }, + "detailedEventsLogging": { + "title": "Szczegółowe rejestrowanie wydarzeń", + "description": "Rejestruj pełne dane wydarzeń wymienianych między Outlook a Rocket.Chat podczas synchronizacji kalendarza. Przydatne do diagnozowania problemów z synchronizacją." + }, + "debugLogging": { + "title": "Szczegółowe rejestrowanie", + "description": "Zapisuje wszystkie dane wyjściowe konsoli do pliku dziennika. Gdy wyłączone, zapisywane są tylko błędy i ważne komunikaty, dzięki czemu dzienniki są mniejsze i bardziej skupione." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Limit rozmiaru podglądu PDF w zaszyfrowanych pokojach (MB)", + "description": "Zaszyfrowane pliki są ładowane do pamięci na potrzeby podglądu. Pliki większe niż ten limit będą pobierane bezpośrednio." + }, + "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." } } }, @@ -183,6 +289,7 @@ "cut": "&Wytnij", "developerMode": "Tryb programisty", "documentation": "Dokumentacja", + "settings": "Ustawienia aplikacji", "editMenu": "&Edycja", "fileMenu": "&Plik", "forward": "&Do przodu", @@ -241,6 +348,7 @@ }, "sidebar": { "addNewServer": "Dodaj nowy serwer", + "settings": "Ustawienia aplikacji", "item": { "reload": "Przeładuj serwer", "remove": "Usuń serwer", diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index f5d9e5d76e..0590388e78 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -220,88 +220,138 @@ } }, "settings": { - "title": "Configurações", + "title": "Configurações do app", "general": "Geral", "certificates": "Certificados", + "sections": { + "appUi": "Interface do app", + "systemUi": "Interface do sistema", + "systemBehavior": "Comportamento do sistema", + "calling": "Chamadas", + "other": "Outros e técnico", + "logging": "Registro de logs" + }, "options": { "report": { - "title": "Relatar erros aos desenvolvedores", - "description": "Reporte os erros anonimamente aos desenvolvedores. As informações compartilhadas incluem número de versão do aplicativo, tipo de sistema operacional, URL do servidor, idioma do dispositivo e tipo de erro. Nenhum conteúdo ou nome de usuário é compartilhado.", - "masDescription": "Esta opção esta desativada quando for instalado através da Mac App Store, os erros serão reportados através do processo de relatórios de erros da Mac Apple Store." + "title": "Relatar erros ao Rocket.Chat", + "description": "Reporte os erros anonimamente ao Rocket.Chat. As informações compartilhadas incluem número de versão do aplicativo, tipo de sistema operacional, URL do workspace, idioma do dispositivo e tipo de erro. Nenhum conteúdo ou nome de usuário é compartilhado.", + "masDescription": "Esta opção está desativada quando instalado pela Mac App Store. Os erros são reportados pelo processo de relatório de erros da Mac App Store." }, "flashFrame": { "title": "Piscar janela", - "titleDarwin": "Ícone deve saltar na dock", - "description": "Pisca a janela para atrair a atenção do usuário.", + "titleDarwin": "Animar ícone no dock", + "description": "Pisca a janela quando uma nova mensagem é recebida.", "onLinux": "Algumas distribuições Linux não possuem suporte a esta funcionalidade.", - "descriptionDarwin": "Faz o ícone da aplicação saltar na dock quando recebe notificação." + "descriptionDarwin": "O ícone no dock anima quando uma nova mensagem é recebida." }, "hardwareAcceleration": { "title": "Aceleração de hardware", - "description": "Ativa aceleração de hardware quando disponível. O aplicativo será reiniciado ao ser alterado." + "description": "Melhora a renderização visual e o desempenho. Desative se houver falhas visuais ou instabilidade.", + "hint": "Reinicia o app ao alterar." }, "videoCallScreenCaptureFallback": { - "title": "Fallback de captura para videochamadas", - "description": "Desativa o Windows Graphics Capture para que o compartilhamento funcione em sessões RDP. O aplicativo reinicia ao mudar esta opção.", - "forcedDescription": "Atualmente aplicado porque o aplicativo detectou uma sessão RDP. O alternador define o comportamento das próximas execuções locais." + "title": "Alternativa de captura de tela para videochamadas", + "description": "Desativa o Windows Graphics Capture para que o compartilhamento de tela funcione em sessões de Área de Trabalho Remota.", + "hint": "O app reinicia ao alterar esta opção.", + "forcedDescription": "Atualmente aplicado porque o app detectou uma sessão de Área de Trabalho Remota. O alternador controla o comportamento das próximas execuções locais." }, "internalVideoChatWindow": { - "title": "Abrir chat em video em uma janela da aplicação", - "description": "Se ativado, o Chat de Vídeo será aberto na janela do aplicativo em vez do navegador padrão. No entanto, para Google Meet e Jitsi, a gravação de tela não é suportada em aplicativos Electron, então eles sempre serão abertos no navegador independentemente desta configuração.", - "masDescription": "Esta opção esta desativada quando for instalado através da Mac App Store, por motivos de segurança o chat em video sera aberto usando o navegador por padrão." + "title": "Videochamadas dentro do app", + "description": "Abrir videochamadas na janela do app em vez do navegador. Chamadas do Google Meet e Jitsi só podem ser abertas no navegador.", + "masDescription": "Esta opção está desativada quando instalado pela Mac App Store. Por motivos de segurança, videochamadas sempre são abertas no navegador por padrão." }, "minimizeOnClose": { "title": "Minimizar ao fechar", - "description": "Quando fechado o aplicativo será minimizado para a barra de tarefas, senão será fechado. Ícone da bandeja precisa estar desativado para que isto funcione." + "description": "Minimiza o app sem encerrar ao fechar a janela.", + "disabledHint": "O ícone da bandeja precisa estar desativado." }, "menubar": { "title": "Barra de menus", - "description": "Mostra a barra de menus no topo da aplicação", - "disabledHint": "Não é possível desativar a barra de menus quando a barra lateral está desativada. As configurações se tornariam inacessíveis." + "description": "Mostra a barra de menus no topo da janela.", + "disabledHint": "Não é possível desativar a barra de menus quando a barra de trabalho está desativada. As configurações do app ficariam inacessíveis." }, "sidebar": { - "title": "Barra lateral", - "description": "Mostra a barra na lateral da janela com a lista de servidores, downloads e configurações.", - "disabledHint": "Não é possível desativar a barra lateral quando a barra de menus está desativada. As configurações se tornariam inacessíveis." + "title": "Barra de trabalho", + "description": "Mostra a lista de workspaces com botões de downloads e configurações.", + "disabledHint": "Não é possível desativar a barra de trabalho quando a barra de menus está desativada. As configurações do app ficariam inacessíveis." }, "trayIcon": { "title": "Ícone da bandeja", - "description": "Mostra um ícone na bandeja do sistema para acessar rapidamente a aplicação. Se o ícone da bandeja estiver ativado, a aplicação será minimizada para a barra de tarefas ao ser fechada. Por outro lado se o ícone da bandeja estiver desativado, a aplicação será finalizada ao ser fechada." + "titleDarwin": "Item da barra de menus", + "description": "Mostra o ícone na bandeja do sistema. Quando ativado, o app é ocultado para a bandeja ao fechar em vez de encerrar.", + "descriptionDarwin": "Exibe o ícone do app na barra de menus." }, "availableBrowsers": { - "title": "Navegador Padrão", - "description": "Escolha qual navegador abrirá os links externos deste aplicativo. Sistema Padrão usa as configurações do seu sistema operacional.", - "systemDefault": "Sistema Padrão", + "title": "Navegador padrão", + "description": "Escolha qual navegador abrirá os links externos.", + "systemDefault": "Padrão do sistema", "loading": "Carregando navegadores...", "current": "Usando atualmente:" }, "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." + "title": "Limpar permissões de captura de tela", + "description": "Limpa as permissões mesmo que “não perguntar novamente” tenha sido selecionado anteriormente para chamadas." }, "allowScreenCaptureOnVideoCalls": { - "title": "Permitir Captura de Tela em Chamadas de Vídeo", - "description": "Permitir captura de tela em chamadas de vídeo. Isso solicitará permissão em cada chamada de vídeo." + "title": "Permitir captura de tela em videochamadas", + "description": "Permite captura de tela em videochamadas. Solicita permissão em cada videochamada." }, "ntlmCredentials": { "title": "Credenciais NTLM", - "description": "Permitir que as credenciais NTLM sejam usadas ao conectar-se a um servidor.", - "domains": "Domínios que usarão as credenciais. Separados por vírgula. Use * para corresponder a todos os domínios." + "description": "Permite que as credenciais NTLM sejam usadas ao conectar-se a um workspace.", + "domains": "Domínios que usarão as credenciais. Separados por vírgula. Use * para corresponder a todos os servidores." }, "videoCallWindowPersistence": { - "title": "Lembrar posição da janela de videochamada", - "description": "Salvar e restaurar a posição e o tamanho das janelas de videochamada entre as sessões" + "title": "Manter posição da janela de videochamada", + "description": "Lembra a posição e o tamanho da janela de videochamada entre as sessões." }, "transparentWindow": { "title": "Efeito de janela transparente", - "description": "Ativar efeito nativo de vibração/transparência para a janela. Requer reinicialização para aplicar." + "description": "Ativa a vibração nativa na janela do app.", + "hint": "Requer reinicialização do app." }, "themeAppearance": { "title": "Tema", - "description": "Escolha o tema de cores para o aplicativo.", + "description": "Tema de cores do app.", "auto": "Seguir sistema", "light": "Claro", "dark": "Escuro" + }, + "telephonyServer": { + "title": "Workspace de telefonia", + "description": "Escolha qual workspace gerencia as chamadas telefônicas recebidas (links tel: e callto:).", + "auto": "Automático (perguntar sempre)" + }, + "outlookCalendarSyncInterval": { + "title": "Intervalo de sincronização do Outlook Calendar", + "description": "Frequência de verificação de atualizações dos eventos do calendário, em minutos (1–60)." + }, + "verboseOutlookLogging": { + "title": "Log detalhado do Outlook Calendar", + "description": "Ativa logs de depuração detalhados de Exchange/NTLM para solucionar problemas de integração com o Outlook Calendar." + }, + "detailedEventsLogging": { + "title": "Log detalhado de eventos", + "description": "Registra os dados completos dos 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": "Log detalhado", + "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 objetivos." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Limite de tamanho para pré-visualização de PDF em salas criptografadas (MB)", + "description": "Arquivos criptografados são carregados na memória para pré-visualização. Arquivos maiores que este limite serão baixados diretamente." + }, + "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." } } }, @@ -330,7 +380,7 @@ "disableGpu": "Desabilitar GPU", "documentation": "Documentação", "downloads": "Downloads", - "settings": "Configurações", + "settings": "Configurações do app", "editMenu": "&Editar", "fileMenu": "&Arquivo", "forward": "&Avançar", @@ -397,7 +447,7 @@ "sidebar": { "addNewServer": "Adicionar novo servidor", "downloads": "Downloads", - "settings": "Configurações", + "settings": "Configurações do app", "menuTitle": "Personalizar e controlar app", "item": { "reload": "Recarregar", diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json index f41c685f3f..e7589d06e9 100644 --- a/src/i18n/ru.i18n.json +++ b/src/i18n/ru.i18n.json @@ -137,6 +137,11 @@ "answerCall": "отвечать на звонки", "recordMessage": "записывать сообщения" } + }, + "telephonySelectServer": { + "title": "Выбор сервера", + "message": "Какой сервер должен обработать этот звонок?", + "rememberChoice": "Запомнить этот выбор" } }, "documentViewer": { @@ -207,84 +212,138 @@ } }, "settings": { - "title": "Настройки", + "title": "Настройки приложения", "general": "Общее", "certificates": "Сертификаты", + "developer": "Разработчик", + "sections": { + "appUi": "Интерфейс приложения", + "systemUi": "Системный интерфейс", + "systemBehavior": "Системное поведение", + "calling": "Звонки", + "other": "Прочее и техническое", + "logging": "Журналирование" + }, "options": { "report": { - "title": "Сообщать об ошибках разработчикам", - "description": "Анонимно сообщайте об ошибках разработчикам. Передаваемая информация включает номер версии приложения, тип операционной системы, URL-адрес сервера, язык устройства и тип ошибки. Содержание чатов и имена пользователей не передаются.", - "masDescription": "Эта опция отключена при установке из Mac App Store, ошибки будут через механизм сбора ошибок Mac App Store." + "title": "Сообщать об ошибках в Rocket.Chat", + "description": "Анонимно сообщайте об ошибках разработчикам. Передаваемая информация включает номер версии приложения, тип операционной системы, URL-адрес рабочего пространства, язык устройства и тип ошибки. Содержание чатов и имена пользователей не передаются.", + "masDescription": "Эта опция отключена при установке из Mac App Store. Ошибки сообщаются через механизм сбора ошибок Mac App Store." }, "flashFrame": { - "title": "Включить мигание иконки", - "titleDarwin": "Мигать значком в трее при оповещении", - "description": "Данная опция управляет включением и выключением мигания иконки приложения при поступлении новых сообщений", + "title": "Мигание окна", + "titleDarwin": "Анимация значка в Dock", + "description": "Мигать окном при получении нового сообщения.", "onLinux": "Некоторые дистрибутивы Linux не поддерживают эту функцию.", - "descriptionDarwin": "Мигание значка приложения в трее для привлечения внимания пользователя." + "descriptionDarwin": "Значок в Dock подпрыгивает при получении нового сообщения." }, "hardwareAcceleration": { "title": "Аппаратное ускорение", - "description": "Включает использование аппаратного ускорения, если оно доступно. Приложение будет перезапущено при изменении этой настройки." + "description": "Улучшает визуальный рендеринг и производительность. Отключите, если наблюдаются графические артефакты или нестабильность.", + "hint": "Приложение перезагружается при изменении настройки." }, "videoCallScreenCaptureFallback": { "title": "Резервный захват экрана для видеозвонков", - "description": "Отключает Windows Graphics Capture, чтобы демонстрация экрана работала в RDP-сессиях. Приложение перезапускается при изменении настройки.", + "description": "Отключает Windows Graphics Capture, чтобы демонстрация экрана работала в RDP-сессиях.", + "hint": "Приложение перезапускается при изменении настройки.", "forcedDescription": "Сейчас включено принудительно, потому что приложение обнаружило сеанс удалённого рабочего стола. Переключатель будет управлять будущими запусками при работе локально." }, "internalVideoChatWindow": { - "title": "Открывать видеочат в окне приложения", - "description": "Если этот параметр включен, видеочат будет открываться в окне приложения вместо браузера по умолчанию. Однако для Google Meet и Jitsi запись экрана не поддерживается в приложениях Electron, поэтому они всегда будут открываться в браузере независимо от этой настройки.", - "masDescription": "Эта опция отключена при установке из Mac App Store, по соображениям безопасности окно видео чата будет открыто в браузере по умолчанию." + "title": "Видеозвонки внутри приложения", + "description": "Открывать видеозвонки в окне приложения вместо браузера. Звонки Google Meet и Jitsi могут открываться только в браузере.", + "masDescription": "Эта опция отключена при установке из Mac App Store. По соображениям безопасности видеозвонки всегда открываются в браузере." }, "minimizeOnClose": { - "title": "Свернуть в трей при закрытии", - "description": "При закрытии приложения оно будет свернуто в системный трей, иначе последует выход из приложения." + "title": "Свернуть при закрытии", + "description": "Сворачивать приложение, а не закрывать его при нажатии кнопки закрытия.", + "disabledHint": "Необходимо отключить значок в трее." }, "menubar": { - "title": "Главное меню", - "description": "Показать строку меню в верхней части окна.", - "disabledHint": "Нельзя отключить строку меню, когда боковая панель уже отключена. Настройки станут недоступными." + "title": "Строка меню", + "description": "Показывать строку меню в верхней части окна.", + "disabledHint": "Нельзя отключить строку меню, когда панель рабочих пространств отключена. Настройки приложения станут недоступными." }, "sidebar": { - "title": "Боковая панель", - "description": "Показать боковую панель в левой части окна со списком серверов, загрузками и настройками.", - "disabledHint": "Нельзя отключить боковую панель, когда строка меню уже отключена. Настройки станут недоступными." + "title": "Панель рабочих пространств", + "description": "Показывать список рабочих пространств с кнопками загрузок и настроек.", + "disabledHint": "Нельзя отключить панель рабочих пространств, когда строка меню отключена. Настройки приложения станут недоступными." }, "trayIcon": { "title": "Значок в трее", - "description": "Показывать значок в системном трее. Если значок в трее активен, приложение будет свернуто в трей при закрытии. В противном случае приложение будет завершено." + "titleDarwin": "Дополнительный значок в строке меню", + "description": "Показывать значок в системном трее. Если значок в трее активен, приложение сворачивается в трей при закрытии.", + "descriptionDarwin": "Показывать значок приложения в строке меню." }, "availableBrowsers": { "title": "Браузер по умолчанию", - "description": "Выберите, какой браузер будет открывать внешние ссылки из этого приложения. Системный по умолчанию использует настройки вашей операционной системы.", + "description": "Выберите, какой браузер будет открывать внешние ссылки.", "systemDefault": "Системный по умолчанию", "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": "Выберите серверы, которые могут захватывать экраны приложений из этого приложения." + "title": "Очистить разрешения на захват экрана", + "description": "Очистить разрешения, даже если ранее был выбран вариант «больше не спрашивать» для звонков." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Разрешить захват экрана на видеозвонках", + "description": "Разрешить захват экрана на видеозвонках. Запрашивает разрешение при каждом видеозвонке." }, "ntlmCredentials": { - "title": "Учетные данные NTLM", - "description": "Разрешить использование учетных данных NTLM при подключении к серверу.", - "domains": "Домены, которые будут использовать учетные данные. Разделены запятыми. Используйте * для соответствия всем доменам." + "title": "Учётные данные NTLM", + "description": "Разрешить использование учётных данных NTLM при подключении к рабочему пространству.", + "domains": "Домены, для которых будут использоваться учётные данные. Разделяйте запятыми. Используйте * для всех серверов." }, "videoCallWindowPersistence": { "title": "Запоминать положение окна видеозвонка", - "description": "Сохранять и восстанавливать положение и размер окон видеозвонков между сеансами" + "description": "Сохранять положение и размер окна видеозвонка между сеансами." }, "transparentWindow": { "title": "Эффект прозрачного окна", - "description": "Включить нативный эффект вибрации/прозрачности для окна. Требуется перезапуск для применения." + "description": "Включить нативный эффект вибрации для окна приложения.", + "hint": "Требуется перезапуск приложения." }, "themeAppearance": { "title": "Тема", - "description": "Выберите цветовую тему для приложения.", + "description": "Цветовая тема приложения.", "auto": "Следовать системе", "light": "Светлая", "dark": "Тёмная" + }, + "outlookCalendarSyncInterval": { + "title": "Интервал синхронизации Outlook Calendar", + "description": "Частота проверки обновлений событий календаря в минутах (1–60)." + }, + "verboseOutlookLogging": { + "title": "Подробное журналирование Outlook Calendar", + "description": "Включить детальные отладочные журналы Exchange/NTLM для диагностики проблем интеграции с Outlook Calendar." + }, + "detailedEventsLogging": { + "title": "Подробное журналирование событий", + "description": "Записывать полные данные событий, которыми обмениваются Outlook и Rocket.Chat во время синхронизации календаря. Полезно для диагностики проблем синхронизации." + }, + "debugLogging": { + "title": "Подробное журналирование", + "description": "Записывает весь вывод консоли в файл журнала. При отключении сохраняются только ошибки и важные сообщения, что делает журналы компактнее." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Ограничение размера предпросмотра PDF в зашифрованных комнатах (МБ)", + "description": "Зашифрованные файлы загружаются в память для предпросмотра. Файлы, превышающие этот лимит, будут скачиваться напрямую." } } }, @@ -313,7 +372,7 @@ "disableGpu": "Отключить GPU", "documentation": "Документация", "downloads": "Загрузки", - "settings": "Настройки", + "settings": "Настройки приложения", "editMenu": "&Правка", "fileMenu": "&Файл", "forward": "Вперед", @@ -374,7 +433,7 @@ "sidebar": { "addNewServer": "Добавить новый сервер", "downloads": "Загрузки", - "settings": "Настройки", + "settings": "Настройки приложения", "item": { "reload": "Перезагрузить вкладку сервера", "remove": "Удалить сервер", diff --git a/src/i18n/se.i18n.json b/src/i18n/se.i18n.json index 45f4a57bd7..2de34a2c5c 100644 --- a/src/i18n/se.i18n.json +++ b/src/i18n/se.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "Report errors to Rocket.Chat", + "description": "Anonymously report issues to app developers. Shared information includes app version number, operating system type, workspace URL, device language and error type. No content or usernames are shared.", + "masDescription": "This option is disabled when installed from the Mac App Store. Errors are reported through the Mac App Store error report process." + }, + "flashFrame": { + "title": "Flash frame", + "titleDarwin": "Bounce dock icon", + "description": "Flash window when a new message is received.", + "onLinux": "Some Linux distros don't have support for this feature.", + "descriptionDarwin": "Dock icon bounces when a new message is received." + }, + "hardwareAcceleration": { + "title": "Hardware acceleration", + "description": "Improves visual rendering and performance. Disable if you experience visual glitches or instability.", + "hint": "Reloads app on change." + }, + "videoCallScreenCaptureFallback": { + "title": "Video call screen capture fallback", + "description": "Disable Windows Graphics Capture so screen sharing works in Remote Desktop sessions.", + "hint": "App restarts when this option is changed.", + "forcedDescription": "Currently enforced because the app detected a Remote Desktop session. Toggle now controls future launches when running locally." + }, + "internalVideoChatWindow": { + "title": "Video calls inside app", + "description": "Open video calls inside app window instead of browser. Google Meet and Jitsi calls can only open in browser.", + "masDescription": "This option is disabled when installed from the Mac App Store. For security reasons, video calls always open in the browser by default." + }, + "minimizeOnClose": { + "title": "Minimize on close", + "description": "Minimize and do not quit app when closing.", + "disabledHint": "Tray icon must be disabled." + }, + "menubar": { + "title": "Menu bar", + "description": "Show menu bar on the top of the window.", + "disabledHint": "Cannot disable menu bar when the workspace bar is disabled. App settings would become inaccessible." + }, + "sidebar": { + "title": "Workspace bar", + "description": "Show workspace list with downloads and settings buttons.", + "disabledHint": "Cannot disable workspace bar when the menu bar is disabled. App settings would become inaccessible." + }, + "trayIcon": { + "title": "Tray icon", + "titleDarwin": "Menu bar extra", + "description": "Show tray icon on system tray. Instead of quitting, app is hidden to tray on close when enabled.", + "descriptionDarwin": "Show app icon in menu bar." + }, + "availableBrowsers": { + "title": "Default browser", + "description": "Choose which browser to open external links in.", + "systemDefault": "System default", + "loading": "Loading browsers...", + "current": "Currently using:" + }, + "telephonyServer": { + "title": "Telephony workspace", + "description": "Choose which workspace handles incoming phone calls (tel: and callto: links).", + "auto": "Auto (ask each time)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Clear screen capture permissions", + "description": "Clear permissions even if “do not ask again” was previously selected for calling." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Allow screen capture on video calls", + "description": "Allow screen capture on video calls. Asks for permission on each video call." + }, + "ntlmCredentials": { + "title": "NTLM credentials", + "description": "Allow NTLM credentials to be used when connecting to a workspace.", + "domains": "Domains that will be used as the credentials. Separated by comma. Use * for match all servers." + }, + "videoCallWindowPersistence": { + "title": "Retain video call window position", + "description": "Remember position and size of video call window between sessions." + }, "transparentWindow": { "title": "Genomskinlig fönstereffekt", - "description": "Aktivera inbyggd vibrans/genomskinlighetseffekt för fönstret. Kräver omstart för att tillämpa." + "description": "Aktivera inbyggd vibrans/genomskinlighetseffekt för fönstret.", + "hint": "Kräver omstart för att tillämpa." }, "themeAppearance": { "title": "Tema", @@ -18,9 +97,51 @@ "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." + }, + "outlookCalendarSyncInterval": { + "title": "Outlook Calendar sync interval", + "description": "Frequency of calendar events update checks in minutes (1–60)." + }, + "verboseOutlookLogging": { + "title": "Verbose Outlook Calendar logging", + "description": "Enable detailed Exchange/NTLM debug logs for troubleshooting Outlook Calendar integration issues." + }, + "detailedEventsLogging": { + "title": "Detailed events logging", + "description": "Log full event data exchanged between Outlook and Rocket.Chat during calendar sync. Useful for diagnosing sync issues." + }, + "debugLogging": { + "title": "Verbose logging", + "description": "Writes all console output to the log file. When disabled, only errors and important messages are saved, keeping logs smaller and focused." + }, + "e2ePdfPreviewSizeLimit": { + "title": "PDF preview size limit in encrypted rooms (MB)", + "description": "Encrypted files are loaded into memory for previews. Files larger than this limit will be downloaded directly." } } }, + "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 3379eab3e5..f077cd1bad 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": { @@ -234,88 +239,142 @@ } }, "settings": { - "title": "Inställningar", + "title": "Appinställningar", "general": "Allmänt", "certificates": "Certifikat", + "sections": { + "appUi": "App-gränssnitt", + "systemUi": "Systemgränssnitt", + "systemBehavior": "Systembeteende", + "calling": "Samtal", + "other": "Övrigt & tekniskt", + "logging": "Loggning" + }, "options": { "report": { - "title": "Rapportera fel till utvecklare", - "description": "Rapportera fel anonymt till utvecklarna. Delad information inkluderar appens versionsnummer, typ av operativsystem, server-URL, enhetsspråk och feltyp. Inget innehåll eller användarnamn delas.", - "masDescription": "Detta alternativ är inaktiverat när det installeras från Mac App Store, felen kommer att rapporteras via Mac App Stores felrapporteringsprocess." + "title": "Rapportera fel till Rocket.Chat", + "description": "Rapportera fel anonymt till appens utvecklare. Delad information inkluderar appens versionsnummer, operativsystemstyp, arbetsytans URL, enhetsspråk och feltyp. Inget innehåll eller användarnamn delas.", + "masDescription": "Detta alternativ är inaktiverat när det installeras från Mac App Store. Fel rapporteras via Mac App Stores felrapporteringsprocess." }, "flashFrame": { - "title": "Aktivera Flash Frame", - "titleDarwin": "Toggle Dock Bounce vid varning", - "description": "Blinkar fönstret för att dra till sig användarens uppmärksamhet.", + "title": "Blinka fönster", + "titleDarwin": "Studsa dockikon", + "description": "Blinkar fönstret när ett nytt meddelande tas emot.", "onLinux": "Vissa Linux-distributioner har inte stöd för den här funktionen.", - "descriptionDarwin": "Appikonen studsar i dockan för att dra till sig användarens uppmärksamhet." + "descriptionDarwin": "Dockikonen studsar när ett nytt meddelande tas emot." }, "hardwareAcceleration": { "title": "Hårdvaruacceleration", - "description": "Aktiverar användning av hårdvaruacceleration när sådan finns tillgänglig. Programmet laddas om vid ändring." + "description": "Förbättrar visuell rendering och prestanda. Inaktivera om du upplever grafiska fel eller instabilitet.", + "hint": "Laddar om appen vid ändring." }, "videoCallScreenCaptureFallback": { - "title": "Reservläge för videosamtal", - "description": "Stänger av Windows Graphics Capture så att delning fungerar i RDP-sessioner. Appen startas om när du ändrar valet.", - "forcedDescription": "Redan aktiverat eftersom appen upptäckte en RDP-session. Vippbrytaren anger beteendet vid kommande lokala starter." + "title": "Reservläge för skärmdumpning vid videosamtal", + "description": "Stänger av Windows Graphics Capture så att skärmdelning fungerar i fjärrskrivbordssessioner.", + "hint": "Appen startas om när du ändrar valet.", + "forcedDescription": "Redan aktiverat eftersom appen upptäckte en fjärrskrivbordssession. Vippbrytaren anger beteendet vid kommande lokala starter." }, "internalVideoChatWindow": { - "title": "Öppna videochatt i programfönstret", - "description": "Om den är aktiverad öppnas videochatten i programmets fönster i stället för i standardwebbläsaren. För Google Meet och Jitsi stöds dock inte skärminspelning i Electron-applikationer, så de kommer alltid att öppnas i webbläsaren oavsett denna inställning.", - "masDescription": "Detta alternativ är inaktiverat när det installeras från Mac App Store. Av säkerhetsskäl öppnas Video Chat alltid i webbläsaren som standard." + "title": "Videosamtal i appen", + "description": "Öppna videosamtal i appfönstret i stället för i webbläsaren. Google Meet och Jitsi-samtal kan bara öppnas i webbläsaren.", + "masDescription": "Detta alternativ är inaktiverat när det installeras från Mac App Store. Av säkerhetsskäl öppnas videosamtal alltid i webbläsaren som standard." }, "minimizeOnClose": { "title": "Minimera vid stängning", - "description": "När appen stängs kommer den att minimeras, annars kommer applikationen avslutas. Tray Icon måste inaktiveras för att detta ska gälla." + "description": "Minimera och avsluta inte appen vid stängning.", + "disabledHint": "Facket-ikonen måste vara inaktiverad." }, "menubar": { "title": "Menyfält", "description": "Visa menyfältet högst upp i fönstret.", - "disabledHint": "Kan inte inaktivera menyfältet när sidofältet är inaktiverat. Inställningar skulle bli otillgängliga." + "disabledHint": "Kan inte inaktivera menyfältet när arbetsytefältet är inaktiverat. Appinställningar skulle bli otillgängliga." }, "sidebar": { - "title": "Sidofält", - "description": "Visa sidofältet till vänster i fönstret med serverlistan, nedladdningar och inställningar.", - "disabledHint": "Kan inte inaktivera sidofältet när menyfältet är inaktiverat. Inställningar skulle bli otillgängliga." + "title": "Arbetsytefält", + "description": "Visa arbetsytelistan med knappar för nedladdningar och inställningar.", + "disabledHint": "Kan inte inaktivera arbetsytefältet när menyfältet är inaktiverat. Appinställningar skulle bli otillgängliga." }, "trayIcon": { - "title": "Ikon för brickan", - "description": "Visa ikonen i systemfältet. Om ikonen är aktiv kommer appen att döljas i facket när den stängs. Annars avslutas programmet." + "title": "Facket-ikon", + "titleDarwin": "Menyradens extra ikon", + "description": "Visa ikon i systemfältet. I stället för att avslutas döljs appen i facket vid stängning när aktiverad.", + "descriptionDarwin": "Visa appikonen i menyraden." }, "availableBrowsers": { "title": "Standardwebbläsare", - "description": "Välj vilken webbläsare som ska öppna externa länkar från den här appen. Systemstandard använder inställningarna för ditt operativsystem.", + "description": "Välj vilken webbläsare som ska öppna externa länkar.", "systemDefault": "Systemstandard", "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." + "description": "Rensa behörigheter även om “fråga inte igen” valdes tidigare vid samtal." }, "allowScreenCaptureOnVideoCalls": { - "title": "Tillåt skärmdump under videosamtal", - "description": "Tillåt skärmdump vid videosamtal. Förfrågan om tillstånd kommer vid varje videosamtal." + "title": "Tillåt skärmdumpning vid videosamtal", + "description": "Tillåt skärmdumpning vid videosamtal. Förfrågan om tillstånd ges vid varje videosamtal." }, "ntlmCredentials": { "title": "NTLM-autentiseringsuppgifter", - "description": "Tillåt att NTLM-autentiseringsuppgifter används vid anslutning till en server.", - "domains": "Domäner som kommer använda autentiseringsuppgifterna. Separerade med kommatecken. Använd * för att matcha alla domäner." + "description": "Tillåt att NTLM-autentiseringsuppgifter används vid anslutning till en arbetsyta.", + "domains": "Domäner som kommer använda autentiseringsuppgifterna. Separerade med kommatecken. Använd * för att matcha alla servrar." }, "videoCallWindowPersistence": { - "title": "Kom ihåg fönstrets position för videosamtal", - "description": "Spara och återställ position och storlek för videosamtalsfönster mellan sessioner" + "title": "Behåll videosamtalsfönstrets position", + "description": "Kom ihåg position och storlek för videosamtalsfönstret mellan sessioner." }, "transparentWindow": { "title": "Transparent fönstereffekt", - "description": "Aktivera inbyggd vibrans/transparenseffekt för fönstret. Kräver omstart för att tillämpas." + "description": "Aktivera inbyggd vibranseffekt för appfönstret.", + "hint": "Kräver omstart av appen." }, "themeAppearance": { "title": "Tema", - "description": "Välj färgtemat för applikationen.", + "description": "Appens färgtema.", "auto": "Följ system", "light": "Ljus", "dark": "Mörk" + }, + "telephonyServer": { + "title": "Telefonarbetsyta", + "description": "Välj vilken arbetsyta som hanterar inkommande telefonsamtal (tel:- och callto:-länkar).", + "auto": "Automatisk (fråga varje gång)" + }, + "outlookCalendarSyncInterval": { + "title": "Synkroniseringsintervall för Outlook-kalender", + "description": "Hur ofta kalender­händelser kontrolleras efter uppdateringar, angivet i minuter (1–60)." + }, + "verboseOutlookLogging": { + "title": "Detaljerad loggning för Outlook-kalender", + "description": "Aktivera detaljerade Exchange/NTLM-felsökningsloggar för att felsöka problem med Outlook-kalenderintegration." + }, + "detailedEventsLogging": { + "title": "Detaljerad händelseloggning", + "description": "Logga fullständig händelsedata som utbyts mellan Outlook och Rocket.Chat under kalendersynkronisering. Användbart för att diagnostisera synkroniseringsproblem." + }, + "debugLogging": { + "title": "Utförlig loggning", + "description": "Skriver all konsolutdata till loggfilen. När inaktiverat sparas endast fel och viktiga meddelanden, vilket håller loggarna mindre och mer fokuserade." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Storleksgräns för PDF-förhandsgranskning i krypterade rum (MB)", + "description": "Krypterade filer läses in i minnet för förhandsgranskning. Filer som är större än denna gräns laddas ned direkt." } } }, @@ -344,7 +403,7 @@ "disableGpu": "Avaktivera GPU", "documentation": "Dokumentation", "downloads": "Nedladdningar", - "settings": "Inställningar", + "settings": "Appinställningar", "editMenu": "&Editera", "fileMenu": "&Fil", "forward": "&Framåt", @@ -415,7 +474,7 @@ "sidebar": { "addNewServer": "Lägg till ny server", "downloads": "Nedladdningar", - "settings": "Inställningar", + "settings": "Appinställningar", "menuTitle": "Anpassa och kontrollera appen", "item": { "reload": "Ladda om", diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json index 28e1676d44..d4f7247691 100644 --- a/src/i18n/tr-TR.i18n.json +++ b/src/i18n/tr-TR.i18n.json @@ -121,30 +121,136 @@ } }, "settings": { - "title": "Ayarlar", + "title": "Uygulama ayarları", "general": "Genel", + "sections": { + "appUi": "Uygulama arayüzü", + "systemUi": "Sistem arayüzü", + "systemBehavior": "Sistem davranışı", + "calling": "Arama", + "other": "Diğer ve teknik", + "logging": "Günlük kaydı" + }, "options": { + "report": { + "title": "Hataları Rocket.Chat'e bildir", + "description": "Sorunları uygulama geliştiricilerine anonim olarak bildirin. Paylaşılan bilgiler arasında uygulama sürüm numarası, işletim sistemi türü, çalışma alanı URL'si, cihaz dili ve hata türü yer alır. İçerik veya kullanıcı adları paylaşılmaz.", + "masDescription": "Mac App Store'dan yüklendiğinde bu seçenek devre dışı kalır. Hatalar Mac App Store hata raporlama süreci aracılığıyla iletilir." + }, + "flashFrame": { + "title": "Pencere yanıp sönsün", + "titleDarwin": "Dock simgesini zıplat", + "description": "Yeni bir mesaj alındığında pencere yanıp söner.", + "onLinux": "Bazı Linux dağıtımları bu özelliği desteklememektedir.", + "descriptionDarwin": "Yeni bir mesaj alındığında Dock simgesi zıplar." + }, + "hardwareAcceleration": { + "title": "Donanım hızlandırma", + "description": "Görsel işlemeyi ve performansı artırır. Görsel hatalar veya kararsızlık yaşıyorsanız devre dışı bırakın.", + "hint": "Değişiklik uygulamayı yeniden yükler." + }, + "videoCallScreenCaptureFallback": { + "title": "Video arama ekran yakalama geri dönüşü", + "description": "Uzak Masaüstü oturumlarında ekran paylaşımının çalışması için Windows Grafik Yakalamayı devre dışı bırakın.", + "hint": "Bu seçenek değiştirildiğinde uygulama yeniden başlar.", + "forcedDescription": "Uygulama bir Uzak Masaüstü oturumu algıladığından şu anda zorunlu kılınmaktadır. Geçiş artık yerel olarak çalışırken gelecekteki başlatmaları kontrol eder." + }, + "internalVideoChatWindow": { + "title": "Video aramalar uygulama içinde", + "description": "Video aramalarını tarayıcı yerine uygulama penceresinde açın. Google Meet ve Jitsi aramaları yalnızca tarayıcıda açılabilir.", + "masDescription": "Mac App Store'dan yüklendiğinde bu seçenek devre dışı kalır. Güvenlik nedeniyle video aramalar varsayılan olarak her zaman tarayıcıda açılır." + }, + "minimizeOnClose": { + "title": "Kapatırken küçült", + "description": "Kapatırken uygulamadan çıkmak yerine küçültün.", + "disabledHint": "Tepsi simgesi devre dışı bırakılmalıdır." + }, + "menubar": { + "title": "Menü çubuğu", + "description": "Pencerenin üstünde menü çubuğunu göster.", + "disabledHint": "Çalışma alanı çubuğu devre dışıyken menü çubuğu devre dışı bırakılamaz. Uygulama ayarlarına erişilemez hale gelirdi." + }, + "sidebar": { + "title": "Çalışma alanı çubuğu", + "description": "İndirmeler ve ayarlar düğmeleriyle birlikte çalışma alanı listesini göster.", + "disabledHint": "Menü çubuğu devre dışıyken çalışma alanı çubuğu devre dışı bırakılamaz. Uygulama ayarlarına erişilemez hale gelirdi." + }, "trayIcon": { - "title": "Görev çubuğu ikonu", - "description": "Sistem tepsisinde bir simge gösterir. Tepsi simgesi etkinleştirildiğinde, uygulama kapatıldığında tepsiye gizlenir. Aksi takdirde uygulama kapatılır." + "title": "Tepsi simgesi", + "titleDarwin": "Menü çubuğu ek öğesi", + "description": "Sistem tepsisinde bir simge gösterir. Tepsi simgesi etkinleştirildiğinde, uygulama kapatıldığında tepsiye gizlenir. Aksi takdirde uygulama kapatılır.", + "descriptionDarwin": "Uygulama simgesini menü çubuğunda göster." }, "availableBrowsers": { - "title": "Varsayılan Tarayıcı", - "description": "Bu uygulamadan harici bağlantıları hangi tarayıcının açacağını seçin. Sistem Varsayılanı, işletim sisteminizin ayarlarını kullanır.", - "systemDefault": "Sistem Varsayılanı", + "title": "Varsayılan tarayıcı", + "description": "Bu uygulamadan harici bağlantıları hangi tarayıcının açacağını seçin.", + "systemDefault": "Sistem varsayılanı", "loading": "Tarayıcılar yükleniyor...", "current": "Şu anda kullanılan:" }, + "telephonyServer": { + "title": "Telefoni çalışma alanı", + "description": "Gelen telefon aramalarını (tel: ve callto: bağlantıları) hangi çalışma alanının yöneteceğini seçin.", + "auto": "Otomatik (her seferinde sor)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Ekran yakalama izinlerini temizle", + "description": "Arama için daha önce “bir daha sorma” seçilmiş olsa bile izinleri temizle." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Video aramalarda ekran yakalamaya izin ver", + "description": "Video aramalarda ekran yakalamaya izin ver. Her video aramada izin ister." + }, + "ntlmCredentials": { + "title": "NTLM kimlik bilgileri", + "description": "Bir çalışma alanına bağlanırken NTLM kimlik bilgilerinin kullanılmasına izin ver.", + "domains": "Kimlik bilgileri için kullanılacak alan adları. Virgülle ayrılır. Tüm sunucularla eşleştirmek için * kullanın." + }, + "videoCallWindowPersistence": { + "title": "Video arama penceresi konumunu koru", + "description": "Oturumlar arasında video arama penceresinin konum ve boyutunu hatırla." + }, "transparentWindow": { "title": "Şeffaf pencere efekti", - "description": "Pencere için yerel titreşim/şeffaflık efektini etkinleştir. Uygulamak için yeniden başlatma gerektirir." + "description": "Uygulama penceresinde yerel titreşim/şeffaflık efektini etkinleştir.", + "hint": "Uygulama yeniden başlatması gerektirir." }, "themeAppearance": { "title": "Tema", - "description": "Uygulama için renk temasını seçin.", + "description": "Uygulama renk teması.", "auto": "Sistemi takip et", "light": "Açık", "dark": "Koyu" + }, + "outlookCalendarSyncInterval": { + "title": "Outlook Takvimi eşitleme aralığı", + "description": "Takvim etkinlikleri güncelleme kontrollerinin dakika cinsinden sıklığı (1–60)." + }, + "verboseOutlookLogging": { + "title": "Ayrıntılı Outlook Takvimi günlük kaydı", + "description": "Outlook Takvimi entegrasyon sorunlarını gidermek için ayrıntılı Exchange/NTLM hata ayıklama günlüklerini etkinleştir." + }, + "detailedEventsLogging": { + "title": "Ayrıntılı olay günlük kaydı", + "description": "Takvim eşitlemesi sırasında Outlook ile Rocket.Chat arasında değiş tokuş edilen tam olay verilerini kaydet. Eşitleme sorunlarının teşhisinde kullanışlıdır." + }, + "debugLogging": { + "title": "Ayrıntılı günlük kaydı", + "description": "Tüm konsol çıktısını günlük dosyasına yazar. Devre dışı bırakıldığında yalnızca hatalar ve önemli mesajlar kaydedilir; bu da günlükleri daha küçük ve odaklı tutar." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Şifrelenmiş odalarda PDF önizleme boyutu sınırı (MB)", + "description": "Şifrelenmiş dosyalar önizlemeler için belleğe yüklenir. Bu sınırdan büyük dosyalar doğrudan indirilir." + }, + "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." } } }, @@ -182,6 +288,7 @@ "quit": "&Çıkış yap {{- appName}}", "redo": "&Yinele", "reload": "&Yeniden yükle", + "settings": "Uygulama ayarları", "reportIssue": "Hata bildir", "resetAppData": "Uygulama verisini sıfırla", "resetZoom": "Görünümü sıfırla", @@ -225,6 +332,7 @@ }, "sidebar": { "addNewServer": "Yeni sunucu ekle", + "settings": "Uygulama ayarları", "item": { "reload": "Sunucuyu yeniden yükle", "remove": "Sunucuyu sil", diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json index 3da507a500..9ede037e3f 100644 --- a/src/i18n/uk-UA.i18n.json +++ b/src/i18n/uk-UA.i18n.json @@ -103,30 +103,141 @@ } }, "settings": { - "title": "Налаштування", + "title": "Налаштування застосунку", "general": "Загальні", + "sections": { + "appUi": "Інтерфейс застосунку", + "systemUi": "Системний інтерфейс", + "systemBehavior": "Поведінка системи", + "calling": "Дзвінки", + "other": "Інше та технічне", + "logging": "Журналювання" + }, "options": { "trayIcon": { - "title": "Значок в треї", - "description": "Показує значок в системному треї. Якщо значок в треї увімкнено, додаток буде згорнуто в трей під час закриття. Інакше додаток буде повністю закрито." + "title": "Значок у треї", + "titleDarwin": "Додаток у рядку меню", + "description": "Показувати значок у системному треї. Якщо увімкнено, при закритті застосунок згортається в трей замість виходу.", + "descriptionDarwin": "Показувати значок застосунку в рядку меню." }, "availableBrowsers": { "title": "Браузер за замовчуванням", - "description": "Виберіть, який браузер відкриватиме зовнішні посилання з цього додатка. Системний за замовчуванням використовує налаштування вашої операційної системи.", + "description": "Виберіть, який браузер відкриватиме зовнішні посилання.", "systemDefault": "Системний за замовчуванням", "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": "Увімкнути нативний ефект вібрації/прозорості для вікна. Потрібен перезапуск для застосування." + "description": "Увімкнути нативний ефект вібрації для вікна застосунку.", + "hint": "Потрібен перезапуск застосунку." }, "themeAppearance": { "title": "Тема", - "description": "Виберіть колірну тему для програми.", - "auto": "Слідувати системі", + "description": "Колірна тема застосунку.", + "auto": "Як у системі", "light": "Світла", "dark": "Темна" + }, + "report": { + "title": "Повідомляти про помилки Rocket.Chat", + "description": "Анонімно повідомляти про проблеми розробникам застосунку. До переданих даних входять номер версії застосунку, тип операційної системи, URL робочого простору, мова пристрою та тип помилки. Жодного вмісту чи імен користувачів не передається.", + "masDescription": "Цей параметр вимкнено при встановленні з Mac App Store. Про помилки повідомляється через процес звітування про помилки Mac App Store." + }, + "flashFrame": { + "title": "Підсвічування вікна", + "titleDarwin": "Підстрибування значка в доку", + "description": "Підсвічувати вікно при отриманні нового повідомлення.", + "onLinux": "Деякі дистрибутиви Linux не підтримують цю функцію.", + "descriptionDarwin": "Значок у доку підстрибує при отриманні нового повідомлення." + }, + "hardwareAcceleration": { + "title": "Апаратне прискорення", + "description": "Покращує візуальне відтворення та продуктивність. Вимкніть, якщо ви помічаєте візуальні збої або нестабільність.", + "hint": "Змінення перезавантажує застосунок." + }, + "videoCallScreenCaptureFallback": { + "title": "Резервний режим захоплення екрана для відеодзвінків", + "description": "Вимикає Windows Graphics Capture, щоб демонстрація екрана працювала в сеансах Remote Desktop.", + "hint": "Застосунок перезапускається при зміненні цього параметра.", + "forcedDescription": "Наразі примусово ввімкнено, оскільки застосунок виявив сеанс Remote Desktop. Перемикач тепер керує майбутніми запусками при локальній роботі." + }, + "internalVideoChatWindow": { + "title": "Відеодзвінки всередині застосунку", + "description": "Відкривати відеодзвінки у вікні застосунку, а не в браузері. Дзвінки Google Meet та Jitsi можуть відкриватися лише в браузері.", + "masDescription": "Цей параметр вимкнено при встановленні з Mac App Store. З міркувань безпеки відеодзвінки за замовчуванням завжди відкриваються в браузері." + }, + "minimizeOnClose": { + "title": "Згортати при закритті", + "description": "Згортати, а не завершувати застосунок при закритті.", + "disabledHint": "Значок у треї має бути вимкнено." + }, + "menubar": { + "title": "Рядок меню", + "description": "Показувати рядок меню у верхній частині вікна.", + "disabledHint": "Неможливо вимкнути рядок меню, коли вимкнено панель робочих просторів. Налаштування застосунку стануть недоступними." + }, + "sidebar": { + "title": "Панель робочих просторів", + "description": "Показувати список робочих просторів із кнопками завантажень і налаштувань.", + "disabledHint": "Неможливо вимкнути панель робочих просторів, коли вимкнено рядок меню. Налаштування застосунку стануть недоступними." + }, + "telephonyServer": { + "title": "Робочий простір телефонії", + "description": "Виберіть, який робочий простір обробляє вхідні телефонні дзвінки (посилання tel: і callto:).", + "auto": "Авто (запитувати щоразу)" + }, + "clearPermittedScreenCaptureServers": { + "title": "Очистити дозволи на захоплення екрана", + "description": "Очистити дозволи, навіть якщо раніше для дзвінків було вибрано “більше не запитувати”." + }, + "allowScreenCaptureOnVideoCalls": { + "title": "Дозволити захоплення екрана під час відеодзвінків", + "description": "Дозволити захоплення екрана під час відеодзвінків. Запитує дозвіл при кожному відеодзвінку." + }, + "ntlmCredentials": { + "title": "Облікові дані NTLM", + "description": "Дозволити використання облікових даних NTLM при під'єднанні до робочого простору.", + "domains": "Домени, які використовуватимуться як облікові дані. Розділені комою. Використовуйте * для відповідності всім серверам." + }, + "videoCallWindowPersistence": { + "title": "Зберігати положення вікна відеодзвінка", + "description": "Запам'ятовувати положення та розмір вікна відеодзвінка між сеансами." + }, + "outlookCalendarSyncInterval": { + "title": "Інтервал синхронізації календаря Outlook", + "description": "Частота перевірок оновлень подій календаря у хвилинах (1–60)." + }, + "verboseOutlookLogging": { + "title": "Докладне журналювання календаря Outlook", + "description": "Увімкнути детальні журнали налагодження Exchange/NTLM для усунення проблем з інтеграцією календаря Outlook." + }, + "detailedEventsLogging": { + "title": "Детальне журналювання подій", + "description": "Журналювати повні дані подій, якими обмінюються Outlook і Rocket.Chat під час синхронізації календаря. Корисно для діагностики проблем синхронізації." + }, + "debugLogging": { + "title": "Докладне журналювання", + "description": "Записує весь вивід консолі до файлу журналу. Якщо вимкнено, зберігаються лише помилки та важливі повідомлення, завдяки чому журнали залишаються меншими та зосередженими." + }, + "e2ePdfPreviewSizeLimit": { + "title": "Обмеження розміру попереднього перегляду PDF у зашифрованих кімнатах (МБ)", + "description": "Зашифровані файли завантажуються в пам'ять для попереднього перегляду. Файли, більші за це обмеження, будуть завантажені напряму." } } }, @@ -152,6 +263,7 @@ "cut": "В&ирізати", "developerMode": "Режим розробника", "documentation": "Документація", + "settings": "Налаштування застосунку", "editMenu": "&Редагувати", "fileMenu": "&Файл", "forward": "Вперед", @@ -210,6 +322,7 @@ }, "sidebar": { "addNewServer": "Додати новий сервер", + "settings": "Налаштування застосунку", "item": { "reload": "Перезавантажити сервер", "remove": "Видалити сервер", diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json index 98883ea682..9700984cff 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": { @@ -154,32 +159,141 @@ } }, "settings": { - "title": "设置", + "title": "应用设置", + "general": "常规", + "sections": { + "appUi": "应用界面", + "systemUi": "系统界面", + "systemBehavior": "系统行为", + "calling": "通话", + "other": "其他与技术", + "logging": "日志" + }, "options": { "report": { - "title": "向开发者报告错误", - "description": "匿名向开发者报告错误。会发送包括程序版本号、操作系统类型、服务器地址、设备语言和错误类型。不会发送聊天内容和用户名等信息。" + "title": "向 Rocket.Chat 报告错误", + "description": "匿名向开发者报告错误。会发送包括程序版本号、操作系统类型、工作区地址、设备语言和错误类型。不会发送聊天内容和用户名等信息。", + "masDescription": "从 Mac App Store 安装时此选项被禁用。错误将通过 Mac App Store 错误报告流程提交。" }, "flashFrame": { - "title": "允许闪屏", - "description": "使用闪屏来吸引用户的注意。", - "onLinux": "在一些Linux系统上这个功能不可用。" + "title": "闪烁窗口", + "titleDarwin": "弹跳 Dock 图标", + "description": "收到新消息时闪烁窗口。", + "onLinux": "在一些Linux系统上这个功能不可用。", + "descriptionDarwin": "收到新消息时 Dock 图标弹跳提示。" + }, + "hardwareAcceleration": { + "title": "硬件加速", + "description": "改善视觉渲染和性能。如出现画面异常或不稳定,请禁用此选项。", + "hint": "更改后将重载应用。" + }, + "videoCallScreenCaptureFallback": { + "title": "视频通话屏幕捕获备用方案", + "description": "禁用 Windows 图形捕获,使屏幕共享在远程桌面会话中正常工作。", + "hint": "更改此选项后应用将重启。", + "forcedDescription": "当前已强制启用,因为应用检测到远程桌面会话。切换开关控制在本地运行时未来启动的行为。" }, "internalVideoChatWindow": { - "title": "在程序窗口中打开视频通话", - "description": "如果启用,视频通话将在应用程序窗口中打开,而不是在默认浏览器中打开。但是,对于Google MeetJitsi,Electron应用程序不支持屏幕录制,所以无论此设置如何,它们始终会在浏览器中打开。", - "masDescription": "从Mac App Store安装时此选项被禁用。出于安全原因,视频通话将始终默认在浏览器中打开。" + "title": "在应用内打开视频通话", + "description": "在应用窗口内打开视频通话,而非浏览器。Google Meet 和 Jitsi 通话只能在浏览器中打开。", + "masDescription": "从 Mac App Store 安装时此选项被禁用。出于安全原因,视频通话将始终默认在浏览器中打开。" + }, + "minimizeOnClose": { + "title": "关闭时最小化", + "description": "关闭窗口时最小化而不退出应用。", + "disabledHint": "必须先禁用托盘图标。" + }, + "menubar": { + "title": "菜单栏", + "description": "在窗口顶部显示菜单栏。", + "disabledHint": "禁用工作区栏后无法禁用菜单栏。否则应用设置将无法访问。" + }, + "sidebar": { + "title": "工作区栏", + "description": "显示包含下载和设置按钮的工作区列表。", + "disabledHint": "禁用菜单栏后无法禁用工作区栏。否则应用设置将无法访问。" + }, + "trayIcon": { + "title": "托盘图标", + "titleDarwin": "菜单栏附加图标", + "description": "在系统托盘中显示托盘图标。启用后关闭窗口时应用将隐藏到托盘而非退出。", + "descriptionDarwin": "在菜单栏中显示应用图标。" + }, + "availableBrowsers": { + "title": "默认浏览器", + "description": "选择打开外部链接所用的浏览器。", + "systemDefault": "系统默认", + "loading": "正在加载浏览器...", + "current": "当前使用:" + }, + "telephonyServer": { + "title": "电话工作区", + "description": "选择处理来电(tel: 和 callto: 链接)的工作区。", + "auto": "自动(每次询问)" + }, + "clearPermittedScreenCaptureServers": { + "title": "清除屏幕捕获权限", + "description": "清除之前已选择「不再询问」的通话屏幕捕获权限。" + }, + "allowScreenCaptureOnVideoCalls": { + "title": "允许视频通话中的屏幕捕获", + "description": "允许在视频通话中进行屏幕捕获。每次视频通话时将请求权限。" + }, + "ntlmCredentials": { + "title": "NTLM 凭据", + "description": "允许在连接到工作区时使用 NTLM 凭据。", + "domains": "将使用该凭据的域名,以逗号分隔。使用 * 匹配所有服务器。" + }, + "videoCallWindowPersistence": { + "title": "保留视频通话窗口位置", + "description": "在会话间记住视频通话窗口的位置和大小。" }, "transparentWindow": { "title": "透明窗口效果", - "description": "启用窗口的原生模糊/透明效果。需要重启才能应用。" + "description": "启用窗口的原生毛玻璃效果。", + "hint": "需要重启应用才能应用。" }, "themeAppearance": { "title": "主题", - "description": "选择应用程序的颜色主题。", + "description": "应用颜色主题。", "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 或您的操作系统保留。" + }, + "outlookCalendarSyncInterval": { + "title": "Outlook 日历同步间隔", + "description": "日历事件更新检查的频率,单位为分钟(1–60)。" + }, + "verboseOutlookLogging": { + "title": "详细 Outlook 日历日志", + "description": "启用详细的 Exchange/NTLM 调试日志,用于排查 Outlook 日历集成问题。" + }, + "detailedEventsLogging": { + "title": "详细事件日志", + "description": "记录日历同步期间 Outlook 与 Rocket.Chat 之间交换的完整事件数据。有助于诊断同步问题。" + }, + "debugLogging": { + "title": "详细日志记录", + "description": "将所有控制台输出写入日志文件。禁用后仅保存错误和重要消息,保持日志简洁。" + }, + "e2ePdfPreviewSizeLimit": { + "title": "加密房间中 PDF 预览大小限制(MB)", + "description": "加密文件将加载到内存中以生成预览。超过此限制的文件将直接下载。" } } }, @@ -208,7 +322,7 @@ "disableGpu": "禁用 GPU", "documentation": "文件", "downloads": "下载", - "settings": "设置", + "settings": "应用设置", "editMenu": "编辑 (&E)", "fileMenu": "文件 (&F)", "forward": "下一步 (&F)", @@ -259,7 +373,7 @@ "sidebar": { "addNewServer": "新增服务器", "downloads": "下载", - "settings": "设置", + "settings": "应用设置", "item": { "reload": "重新载入服务器", "remove": "移除服务器", diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json index 3697080bef..7b84b221ce 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": { @@ -100,30 +105,136 @@ } }, "settings": { - "title": "設定", + "title": "應用程式設定", "general": "一般", + "sections": { + "appUi": "應用程式介面", + "systemUi": "系統介面", + "systemBehavior": "系統行為", + "calling": "通話", + "other": "其他與技術", + "logging": "記錄" + }, "options": { + "report": { + "title": "向 Rocket.Chat 回報錯誤", + "description": "匿名向應用程式開發者回報問題。共享資訊包括應用程式版本號、作業系統類型、工作區 URL、裝置語言及錯誤類型。不會共享任何內容或使用者名稱。", + "masDescription": "從 Mac App Store 安裝時,此選項將被停用。錯誤將透過 Mac App Store 的錯誤回報流程進行回報。" + }, + "flashFrame": { + "title": "閃爍視窗框", + "titleDarwin": "彈跳 Dock 圖示", + "description": "收到新訊息時閃爍視窗。", + "onLinux": "某些 Linux 發行版不支援此功能。", + "descriptionDarwin": "收到新訊息時 Dock 圖示會彈跳。" + }, + "hardwareAcceleration": { + "title": "硬體加速", + "description": "改善視覺渲染和效能。若遇到視覺異常或不穩定的情況,請停用此選項。", + "hint": "變更後將重新載入應用程式。" + }, + "videoCallScreenCaptureFallback": { + "title": "視訊通話螢幕擷取備援", + "description": "停用 Windows 圖形擷取,讓螢幕共享在遠端桌面工作階段中正常運作。", + "hint": "變更此選項時應用程式將重新啟動。", + "forcedDescription": "目前已強制啟用,因為應用程式偵測到遠端桌面工作階段。切換開關現在控制在本機執行時未來啟動的行為。" + }, + "internalVideoChatWindow": { + "title": "在應用程式內進行視訊通話", + "description": "在應用程式視窗內開啟視訊通話,而非在瀏覽器中開啟。Google Meet 和 Jitsi 通話只能在瀏覽器中開啟。", + "masDescription": "從 Mac App Store 安裝時,此選項將被停用。基於安全考量,視訊通話預設一律在瀏覽器中開啟。" + }, + "minimizeOnClose": { + "title": "關閉時最小化", + "description": "關閉時最小化應用程式而非結束。", + "disabledHint": "必須停用系統匣圖示。" + }, + "menubar": { + "title": "選單列", + "description": "在視窗頂部顯示選單列。", + "disabledHint": "停用工作區列時無法停用選單列。應用程式設定將無法存取。" + }, + "sidebar": { + "title": "工作區列", + "description": "顯示工作區清單,以及下載和設定按鈕。", + "disabledHint": "停用選單列時無法停用工作區列。應用程式設定將無法存取。" + }, "trayIcon": { "title": "系統匣圖示", - "description": "顯示系統匣圖示。啟用系統匣圖示時,關閉視窗會將應用程式最小化到系統匣。否則,應用程式將會關閉。" + "titleDarwin": "選單列附加項目", + "description": "在系統匣顯示圖示。啟用時,關閉視窗會將應用程式隱藏至系統匣而非結束。", + "descriptionDarwin": "在選單列顯示應用程式圖示。" }, "availableBrowsers": { "title": "預設瀏覽器", - "description": "選擇哪個瀏覽器將開啟此應用程式的外部連結。系統預設使用您操作系統的設定。", + "description": "選擇哪個瀏覽器將開啟此應用程式的外部連結。", "systemDefault": "系統預設", "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": "即使之前在通話中選擇了「不再詢問」,也清除相關權限。" + }, + "allowScreenCaptureOnVideoCalls": { + "title": "允許在視訊通話中擷取螢幕", + "description": "允許在視訊通話中擷取螢幕。每次視訊通話都會要求權限。" + }, + "ntlmCredentials": { + "title": "NTLM 憑證", + "description": "允許在連線至工作區時使用 NTLM 憑證。", + "domains": "將使用憑證的網域。以逗號分隔。使用 * 符合所有工作區。" + }, + "videoCallWindowPersistence": { + "title": "保留視訊通話視窗位置", + "description": "記住工作階段間視訊通話視窗的位置和大小。" + }, "transparentWindow": { "title": "透明視窗效果", - "description": "啟用視窗的原生模糊/透明效果。需要重新啟動才能套用。" + "description": "啟用應用程式視窗的原生活力效果。", + "hint": "需要重新啟動應用程式。" }, "themeAppearance": { "title": "主題", - "description": "選擇應用程式的顏色主題。", + "description": "應用程式顏色主題。", "auto": "跟隨系統", "light": "淺色", "dark": "深色" + }, + "outlookCalendarSyncInterval": { + "title": "Outlook 行事曆同步間隔", + "description": "行事曆活動更新檢查的頻率(分鐘,1–60)。" + }, + "verboseOutlookLogging": { + "title": "詳細 Outlook 行事曆記錄", + "description": "啟用詳細的 Exchange/NTLM 除錯記錄,用於排除 Outlook 行事曆整合問題。" + }, + "detailedEventsLogging": { + "title": "詳細事件記錄", + "description": "記錄行事曆同步期間 Outlook 與 Rocket.Chat 之間交換的完整事件資料。有助於診斷同步問題。" + }, + "debugLogging": { + "title": "詳細記錄", + "description": "將所有主控台輸出寫入記錄檔。停用時,僅儲存錯誤和重要訊息,讓記錄更精簡且集中。" + }, + "e2ePdfPreviewSizeLimit": { + "title": "加密房間中的 PDF 預覽大小限制(MB)", + "description": "加密檔案會載入記憶體以進行預覽。超過此限制的檔案將直接下載。" } } }, @@ -143,6 +254,7 @@ "menus": { "about": "關於 {{- appName}}", "addNewServer": "新增伺服器", + "settings": "應用程式設定", "back": "上一步 (&B)", "clearTrustedCertificates": "清除已信任的憑證", "close": "關閉", @@ -184,6 +296,7 @@ }, "sidebar": { "addNewServer": "新增伺服器", + "settings": "應用程式設定", "item": { "reload": "重新載入伺服器", "remove": "移除伺服器", diff --git a/src/i18n/zh.i18n.json b/src/i18n/zh.i18n.json index c5dee48e08..3d8407f105 100644 --- a/src/i18n/zh.i18n.json +++ b/src/i18n/zh.i18n.json @@ -8,9 +8,88 @@ }, "settings": { "options": { + "report": { + "title": "向 Rocket.Chat 报告错误", + "description": "匿名向开发者报告错误。会发送包括程序版本号、操作系统类型、工作区地址、设备语言和错误类型。不会发送聊天内容和用户名等信息。", + "masDescription": "从 Mac App Store 安装时此选项被禁用。错误将通过 Mac App Store 错误报告流程提交。" + }, + "flashFrame": { + "title": "闪烁窗口", + "titleDarwin": "弹跳 Dock 图标", + "description": "收到新消息时闪烁窗口。", + "onLinux": "在一些Linux系统上这个功能不可用。", + "descriptionDarwin": "收到新消息时 Dock 图标弹跳提示。" + }, + "hardwareAcceleration": { + "title": "硬件加速", + "description": "改善视觉渲染和性能。如出现画面异常或不稳定,请禁用此选项。", + "hint": "更改后将重载应用。" + }, + "videoCallScreenCaptureFallback": { + "title": "视频通话屏幕捕获备用方案", + "description": "禁用 Windows 图形捕获,使屏幕共享在远程桌面会话中正常工作。", + "hint": "更改此选项后应用将重启。", + "forcedDescription": "当前已强制启用,因为应用检测到远程桌面会话。切换开关控制在本地运行时未来启动的行为。" + }, + "internalVideoChatWindow": { + "title": "在应用内打开视频通话", + "description": "在应用窗口内打开视频通话,而非浏览器。Google Meet 和 Jitsi 通话只能在浏览器中打开。", + "masDescription": "从 Mac App Store 安装时此选项被禁用。出于安全原因,视频通话将始终默认在浏览器中打开。" + }, + "minimizeOnClose": { + "title": "关闭时最小化", + "description": "关闭窗口时最小化而不退出应用。", + "disabledHint": "必须先禁用托盘图标。" + }, + "menubar": { + "title": "菜单栏", + "description": "在窗口顶部显示菜单栏。", + "disabledHint": "禁用工作区栏后无法禁用菜单栏。否则应用设置将无法访问。" + }, + "sidebar": { + "title": "工作区栏", + "description": "显示包含下载和设置按钮的工作区列表。", + "disabledHint": "禁用菜单栏后无法禁用工作区栏。否则应用设置将无法访问。" + }, + "trayIcon": { + "title": "托盘图标", + "titleDarwin": "菜单栏附加图标", + "description": "在系统托盘中显示托盘图标。启用后关闭窗口时应用将隐藏到托盘而非退出。", + "descriptionDarwin": "在菜单栏中显示应用图标。" + }, + "availableBrowsers": { + "title": "默认浏览器", + "description": "选择打开外部链接所用的浏览器。", + "systemDefault": "系统默认", + "loading": "正在加载浏览器...", + "current": "当前使用:" + }, + "telephonyServer": { + "title": "电话工作区", + "description": "选择处理来电(tel: 和 callto: 链接)的工作区。", + "auto": "自动(每次询问)" + }, + "clearPermittedScreenCaptureServers": { + "title": "清除屏幕捕获权限", + "description": "清除之前已选择「不再询问」的通话屏幕捕获权限。" + }, + "allowScreenCaptureOnVideoCalls": { + "title": "允许视频通话中的屏幕捕获", + "description": "允许在视频通话中进行屏幕捕获。每次视频通话时将请求权限。" + }, + "ntlmCredentials": { + "title": "NTLM 凭据", + "description": "允许在连接到工作区时使用 NTLM 凭据。", + "domains": "将使用该凭据的域名,以逗号分隔。使用 * 匹配所有服务器。" + }, + "videoCallWindowPersistence": { + "title": "保留视频通话窗口位置", + "description": "在会话间记住视频通话窗口的位置和大小。" + }, "transparentWindow": { "title": "透明窗口效果", - "description": "启用窗口的原生模糊/透明效果。需要重启才能应用。" + "description": "启用窗口的原生模糊/透明效果。", + "hint": "需要重启才能应用。" }, "themeAppearance": { "title": "主题", @@ -18,9 +97,51 @@ "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 或您的操作系统保留。" + }, + "outlookCalendarSyncInterval": { + "title": "Outlook 日历同步间隔", + "description": "日历事件更新检查的频率,单位为分钟(1–60)。" + }, + "verboseOutlookLogging": { + "title": "详细 Outlook 日历日志", + "description": "启用详细的 Exchange/NTLM 调试日志,用于排查 Outlook 日历集成问题。" + }, + "detailedEventsLogging": { + "title": "详细事件日志", + "description": "记录日历同步期间 Outlook 与 Rocket.Chat 之间交换的完整事件数据。有助于诊断同步问题。" + }, + "debugLogging": { + "title": "详细日志记录", + "description": "将所有控制台输出写入日志文件。禁用后仅保存错误和重要消息,保持日志简洁。" + }, + "e2ePdfPreviewSizeLimit": { + "title": "加密房间中 PDF 预览大小限制(MB)", + "description": "加密文件将加载到内存中以生成预览。超过此限制的文件将直接下载。" } } }, + "dialog": { + "telephonySelectServer": { + "title": "选择服务器", + "message": "哪个服务器应该处理此通话?", + "rememberChoice": "记住此选择" + } + }, "serverInfo": { "title": "服务器信息", "urlLabel": "URL:", diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index ab46a3371a..0d5cd66c59 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 = { @@ -32,6 +33,7 @@ type ChannelToArgsMap = { } ) => void; 'video-call-window/open-url': (url: string) => void; + 'video-call-window/open-in-main-window': (path: string) => void; 'video-call-window/web-contents-id': (webContentsId: number) => void; 'video-call-window/open-screen-picker': () => { success: boolean }; 'video-call-window/screen-sharing-source-responded': (source: string) => void; @@ -45,6 +47,7 @@ type ChannelToArgsMap = { success: boolean; url: string | null; autoOpenDevtools: boolean; + partition?: string; }; 'video-call-window/url-received': () => { success: boolean }; 'video-call-window/webview-created': () => { success: boolean }; @@ -137,6 +140,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 f5c6e32b90..32bc35e906 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -6,6 +6,7 @@ import { JitsiMeetElectron } from './jitsi/preload'; import { listenToNotificationsRequests } from './notifications/preload'; import { listenToScreenSharingRequests } from './screenSharing/preload'; import { RocketChatDesktop } from './servers/preload/api'; +import { listenToNavigateToRouteRequests } from './servers/preload/navigateToRoute'; import { setServerUrl } from './servers/preload/urls'; import { createRendererReduxStore, listen } from './store'; import { listenToTelephonyRequests } from './telephony/preload'; @@ -66,6 +67,7 @@ const start = async (): Promise => { await invoke('server-view/ready'); listenToTelephonyRequests(); + listenToNavigateToRouteRequests(); console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady'); RocketChatDesktop.onReady(() => { diff --git a/src/screenSharing/serverViewScreenSharing.ts b/src/screenSharing/serverViewScreenSharing.ts index edad48fab3..236cfb0d30 100644 --- a/src/screenSharing/serverViewScreenSharing.ts +++ b/src/screenSharing/serverViewScreenSharing.ts @@ -55,7 +55,12 @@ const initializeProvider = (): Promise => { providerReady = true; console.log(`Server view screen sharing: using ${provider.type} provider`); - })(); + })().catch((error) => { + initPromise = null; + providerReady = false; + provider = null; + throw error; + }); return initPromise; }; @@ -111,6 +116,43 @@ export const setupServerViewDisplayMedia = ( } }; +/** + * Routes a display-media request to the server-view screen picker (root window). + * Used by the video call window's unified handler when it shares the server's + * session and must dispatch a main-app request back to the server-view picker. + */ +export const handleServerViewDisplayMediaRequest = ( + callback: DisplayMediaCallback +): void => { + const dispatch = (): void => { + if (!provider) { + callback({ video: false } as any); + return; + } + try { + provider.handleDisplayMediaRequest(callback); + } catch (error) { + console.error('Server view screen sharing: error in handler:', error); + callback({ video: false } as any); + } + }; + + if (providerReady) { + dispatch(); + return; + } + + initializeProvider() + .then(dispatch) + .catch((error) => { + console.error( + 'Server view screen sharing: error initializing provider:', + error + ); + callback({ video: false } as any); + }); +}; + export const startServerViewScreenSharingHandler = (): void => { handle('screen-picker/screen-recording-is-permission-granted', async () => checkScreenRecordingPermission() diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index 51eef7e5ca..bb77ee43c2 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -29,6 +29,7 @@ import { getInternalVideoChatWindowEnabled, openInternalVideoChatWindow, } from './internalVideoChatWindow'; +import { onNavigateToRoute } from './navigateToRoute'; import { openInBrowser } from './openInBrowser'; import { reloadServer } from './reloadServer'; import { @@ -60,6 +61,7 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { callback: (payload: { phoneNumber: string; rawUri: string }) => void ) => void; supportedDocumentViewerFormats: () => string[]; + onNavigateToRoute: (callback: (path: string) => void) => void; setUserRoles: (roles: string[]) => void; }; @@ -111,4 +113,5 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { reloadServer, getE2ePdfPreviewSizeLimit, onTelephonyCallRequested, + onNavigateToRoute, }; diff --git a/src/servers/preload/navigateToRoute.spec.ts b/src/servers/preload/navigateToRoute.spec.ts new file mode 100644 index 0000000000..5f5bc36cbd --- /dev/null +++ b/src/servers/preload/navigateToRoute.spec.ts @@ -0,0 +1,65 @@ +type IpcListener = (event: unknown, path: string) => void; + +const ipcListeners = new Map(); +const on = jest.fn((channel: string, listener: IpcListener) => { + ipcListeners.set(channel, listener); +}); + +jest.mock('electron', () => ({ + ipcRenderer: { + on: (channel: string, listener: IpcListener) => on(channel, listener), + }, +})); + +const emit = (path: string) => { + const listener = ipcListeners.get('navigate-to-route'); + if (!listener) throw new Error('navigate-to-route listener not registered'); + listener({}, path); +}; + +describe('servers/preload/navigateToRoute', () => { + let onNavigateToRoute: (cb: (path: string) => void) => void; + let listenToNavigateToRouteRequests: () => void; + + beforeEach(async () => { + jest.resetModules(); + ipcListeners.clear(); + on.mockClear(); + const mod = await import('./navigateToRoute'); + onNavigateToRoute = mod.onNavigateToRoute; + listenToNavigateToRouteRequests = mod.listenToNavigateToRouteRequests; + }); + + it('registers the ipcRenderer listener only once', () => { + listenToNavigateToRouteRequests(); + listenToNavigateToRouteRequests(); + expect(on).toHaveBeenCalledTimes(1); + expect(on).toHaveBeenCalledWith('navigate-to-route', expect.any(Function)); + }); + + it('delivers the path to a callback registered before the event', () => { + listenToNavigateToRouteRequests(); + const cb = jest.fn(); + onNavigateToRoute(cb); + + emit('/channel/general'); + + expect(cb).toHaveBeenCalledWith('/channel/general'); + }); + + it('buffers a path that arrives before the callback registers, then flushes once', () => { + listenToNavigateToRouteRequests(); + + emit('/admin/rooms'); + + const cb = jest.fn(); + onNavigateToRoute(cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('/admin/rooms'); + + // The buffered path is consumed: a later registration gets nothing extra. + const cb2 = jest.fn(); + onNavigateToRoute(cb2); + expect(cb2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/servers/preload/navigateToRoute.ts b/src/servers/preload/navigateToRoute.ts new file mode 100644 index 0000000000..2c8bb4785c --- /dev/null +++ b/src/servers/preload/navigateToRoute.ts @@ -0,0 +1,36 @@ +import { ipcRenderer } from 'electron'; + +let navigateCallback: ((path: string) => void) | null = null; +let pendingPath: string | null = null; + +// Registered by the web client to receive in-app route changes requested by the +// desktop shell (e.g. from the standalone video call window). `path` is a +// server-relative route, e.g. "/channel/general". +export const onNavigateToRoute = (callback: (path: string) => void): void => { + navigateCallback = callback; + if (pendingPath) { + callback(pendingPath); + pendingPath = null; + } +}; + +let listening = false; + +// Relays the main-process 'navigate-to-route' event (delivered to this preload's +// ipcRenderer, since the server webview is contextIsolated) to the web client's +// callback. Buffers the latest path if a request arrives before the web client +// has registered its handler. +export const listenToNavigateToRouteRequests = (): void => { + if (listening) { + return; + } + listening = true; + + ipcRenderer.on('navigate-to-route', (_event, path: string) => { + if (navigateCallback) { + navigateCallback(path); + } else { + pendingPath = path; + } + }); +}; diff --git a/src/servers/supportedVersions/main.ts b/src/servers/supportedVersions/main.ts index 413cf9a473..43b2dc6ed8 100644 --- a/src/servers/supportedVersions/main.ts +++ b/src/servers/supportedVersions/main.ts @@ -247,12 +247,12 @@ const isVersionExceptionForServer = ( } const trimmedExceptionVersion = exceptionVersion.trim(); - if (!trimmedExceptionVersion.startsWith('sha-')) { + if (!trimmedExceptionVersion.toLowerCase().startsWith('sha-')) { return false; } const normalizedExceptionVersion = trimmedExceptionVersion - .replace(/^sha-/, '') + .replace(/^sha-/i, '') .toLowerCase(); if (!normalizedExceptionVersion) { return false; @@ -265,7 +265,7 @@ const isVersionExceptionForServer = ( const normalizedGitCommitHash = gitCommitHash .trim() - .replace(/^sha-/, '') + .replace(/^sha-/i, '') .toLowerCase(); return normalizedGitCommitHash.startsWith(normalizedExceptionVersion); 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 9c34895e4b..dbf8f26b4f 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -18,7 +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 { telephonyPreferredServer } from '../telephony/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'; @@ -38,6 +42,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'; @@ -122,6 +127,9 @@ export const rootReducer = combineReducers({ 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..7b3b5faada --- /dev/null +++ b/src/telephony/__tests__/dialpad.spec.ts @@ -0,0 +1,205 @@ +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(); + const isDestroyed = jest.fn(() => false); + getWebContentsByServerUrlMock.mockReturnValue({ send, isDestroyed } 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 index 739448af08..5e0d5677b2 100644 --- a/src/telephony/actions.ts +++ b/src/telephony/actions.ts @@ -1,5 +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..d6674d9479 --- /dev/null +++ b/src/telephony/dialpad.ts @@ -0,0 +1,139 @@ +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; + } + + if (webContents.isDestroyed()) { + 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..4523970135 --- /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().mockResolvedValue(undefined), + }, + }; +}); + +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: '+18005550199', + rawUri: '+1 (800) 555-0199', + }); + }); + + it('strips surrounding words and formatting debris from pasted text', () => { + expect( + createTelephonyLinkFromClipboardText('Call +1 (800) 555-0199 x123') + ).toEqual({ + phoneNumber: '+18005550199123', + 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..8521e620af --- /dev/null +++ b/src/telephony/main.ts @@ -0,0 +1,437 @@ +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 => { + // Strip everything that is not a dialable phone character so surrounding + // words and formatting debris (e.g. "Call (800) 555-0199 now") never reach + // the dial pad. '+' is only meaningful as a leading international prefix. + const sanitized = text.replace(/[^\d+*#]/g, '').replace(/(?!^)\+/g, ''); + const digitCount = (sanitized.match(/\d/g) ?? []).length; + + if (digitCount < 3) { + return null; + } + + return sanitized; +}; + +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') { + 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') + ) { + const child = spawn('gnome-control-center', ['default-apps'], { + detached: true, + stdio: 'ignore', + }); + child.on('error', (error: NodeJS.ErrnoException) => { + logger.warn('Failed to open Linux default apps settings'); + logger.warn(error); + }); + child.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') { + const fallback = spawn('kcmshell6', ['componentchooser'], { + detached: true, + stdio: 'ignore', + }); + fallback.on('error', (fallbackError: NodeJS.ErrnoException) => { + logger.warn('Failed to open Linux default apps settings'); + logger.warn(fallbackError); + }); + fallback.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 index 35de60ee26..39b0e899ef 100644 --- a/src/telephony/preload.ts +++ b/src/telephony/preload.ts @@ -2,16 +2,31 @@ import { ipcRenderer } from 'electron'; type TelephonyPayload = { phoneNumber: string; rawUri: string }; +// A buffered deeplink expires after this window. If the target workspace never +// registers a callback (e.g. it lacks VoIP), the payload is silently dropped +// instead of lingering and surfacing on a much-later unrelated remount. +const PENDING_PAYLOAD_TTL_MS = 120_000; + let telephonyCallback: ((payload: TelephonyPayload) => void) | null = null; let pendingPayload: TelephonyPayload | null = null; +let pendingTimer: ReturnType | null = null; + +const clearPendingPayload = (): void => { + pendingPayload = null; + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingTimer = null; + } +}; export const onTelephonyCallRequested = ( callback: (payload: TelephonyPayload) => void ): void => { telephonyCallback = callback; if (pendingPayload) { - callback(pendingPayload); - pendingPayload = null; + const payload = pendingPayload; + clearPendingPayload(); + callback(payload); } }; @@ -29,7 +44,9 @@ export const listenToTelephonyRequests = (): void => { if (telephonyCallback) { telephonyCallback(payload); } else { + clearPendingPayload(); pendingPayload = payload; + pendingTimer = setTimeout(clearPendingPayload, PENDING_PAYLOAD_TTL_MS); } } ); diff --git a/src/telephony/reducers.ts b/src/telephony/reducers.ts index b17c793716..b1b606b595 100644 --- a/src/telephony/reducers.ts +++ b/src/telephony/reducers.ts @@ -1,12 +1,58 @@ import type { Reducer } from 'redux'; +import { APP_SETTINGS_LOADED } from '../app/actions'; import type { ActionOf } from '../store/actions'; -import { TELEPHONY_PREFERRED_SERVER_SET } from './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< - typeof TELEPHONY_PREFERRED_SERVER_SET +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 @@ -15,6 +61,44 @@ export const telephonyPreferredServer: Reducer< 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..8066c17676 --- /dev/null +++ b/src/telephony/renderer/preload.spec.ts @@ -0,0 +1,213 @@ +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(); + }); + + it('silently drops a buffered payload once the 120s TTL elapses', () => { + jest.useFakeTimers(); + try { + listenToTelephonyRequests(); + + const payload: TelephonyPayload = { + phoneNumber: '2222', + rawUri: 'tel:2222', + }; + fireIpcEvent(payload); + + // TTL elapses before any callback registers (e.g. non-VoIP workspace). + jest.advanceTimersByTime(120_000); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + + expect(cb).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('delivers a buffered payload registered just before the TTL elapses', () => { + jest.useFakeTimers(); + try { + listenToTelephonyRequests(); + + const payload: TelephonyPayload = { + phoneNumber: '3333', + rawUri: 'tel:3333', + }; + fireIpcEvent(payload); + + jest.advanceTimersByTime(119_999); + + const cb = jest.fn(); + onTelephonyCallRequested(cb); + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(payload); + + // The expiry timer was cleared on flush; later ticks must not re-fire. + jest.advanceTimersByTime(120_000); + expect(cb).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); +}); 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 a5a1b6f244..a144d7afbc 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -97,6 +97,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 = @@ -159,6 +161,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; @@ -257,6 +267,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; @@ -316,4 +327,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/DownloadsManagerView/index.tsx b/src/ui/components/DownloadsManagerView/index.tsx index 9559552e11..1c36420784 100644 --- a/src/ui/components/DownloadsManagerView/index.tsx +++ b/src/ui/components/DownloadsManagerView/index.tsx @@ -53,10 +53,12 @@ const DownloadsManagerView = () => { const { t } = useTranslation(); - const serverFilterOptions = useSelector( - ({ downloads }) => [ + const downloadsState = useSelector(({ downloads }: RootState) => downloads); + + const serverFilterOptions = useMemo<[string, string][]>( + () => [ ['*', t('downloads.filters.all')], - ...Object.values(downloads) + ...Object.values(downloadsState) .filter(({ serverUrl, serverTitle }) => serverUrl && serverTitle) .map<[string, string]>(({ serverUrl, serverTitle }) => [ serverUrl, @@ -66,7 +68,8 @@ const DownloadsManagerView = () => { (value, index, array) => array.findIndex((valueTwo) => valueTwo[0] === value[0]) === index ), - ] + ], + [downloadsState, t] ); const [serverFilter, setServerFilter] = useLocalStorage< @@ -157,7 +160,7 @@ const DownloadsManagerView = () => { (mimeTypeFilter !== '' && mimeTypeFilter !== '*') || (statusFilter !== '' && statusFilter !== DownloadStatus.ALL); - const downloads = useSelector(({ downloads }: RootState) => { + const downloads = useMemo(() => { type Predicate = (download: Download) => boolean; const searchPredicate: Predicate = searchFilter ? ({ fileName }) => fileName.indexOf(searchFilter) > -1 @@ -174,13 +177,19 @@ const DownloadsManagerView = () => { statusFilter !== '' && statusFilter !== DownloadStatus.ALL ? ({ status }) => status === statusFilter : () => true; - return Object.values(downloads) + return Object.values(downloadsState) .filter(searchPredicate) .filter(serverPredicate) .filter(mimeTypePredicate) .filter(statusPredicate) .sort((a, b) => b.itemId - a.itemId); - }); + }, [ + downloadsState, + searchFilter, + serverFilter, + mimeTypeFilter, + statusFilter, + ]); // Reset to the first page whenever the current offset falls outside the // (filtered) result set — e.g. after narrowing a filter or removing items — @@ -219,7 +228,12 @@ const DownloadsManagerView = () => { alignItems='center' > {!isSideBarEnabled && ( - + )} {t('downloads.title')} diff --git a/src/ui/components/ServersView/DocumentViewer.tsx b/src/ui/components/ServersView/DocumentViewer.tsx index e838b5922c..366e9fbeab 100644 --- a/src/ui/components/ServersView/DocumentViewer.tsx +++ b/src/ui/components/ServersView/DocumentViewer.tsx @@ -32,7 +32,7 @@ const DocumentViewer = ({ @@ -234,7 +234,7 @@ const MarkdownContent = ({ ref={containerRef} className='markdown-body' style={{ maxWidth: 980, margin: '0 auto', padding: '48px 40px 64px' }} - color='font-default' + color='default' dangerouslySetInnerHTML={{ __html: htmlContent }} /> diff --git a/src/ui/components/ServersView/PdfContent.tsx b/src/ui/components/ServersView/PdfContent.tsx index 8500f930b6..c9f30b7b0f 100644 --- a/src/ui/components/ServersView/PdfContent.tsx +++ b/src/ui/components/ServersView/PdfContent.tsx @@ -78,7 +78,7 @@ const PdfContent = ({ url, partition }: { url: string; partition: string }) => { height='100%' width='100%' position='absolute' - color='font-default' + color='default' > diff --git a/src/ui/components/SettingsView/GeneralTab.tsx b/src/ui/components/SettingsView/GeneralTab.tsx index 41470432d5..00797a8f67 100644 --- a/src/ui/components/SettingsView/GeneralTab.tsx +++ b/src/ui/components/SettingsView/GeneralTab.tsx @@ -1,47 +1,51 @@ import { Box, FieldGroup } from '@rocket.chat/fuselage'; import { AvailableBrowsers } from './features/AvailableBrowsers'; -import { ClearPermittedScreenCaptureServers } from './features/ClearPermittedScreenCaptureServers'; import { E2ePdfPreviewSizeLimit } from './features/E2ePdfPreviewSizeLimit'; 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 { TelephonyServer } from './features/TelephonyServer'; import { ThemeAppearance } from './features/ThemeAppearance'; import { TransparentWindow } from './features/TransparentWindow'; import { TrayIcon } from './features/TrayIcon'; -import { VideoCallWindowPersistence } from './features/VideoCallWindowPersistence'; -export const GeneralTab = () => ( - - - - - - - {process.platform === 'win32' && } - - - {process.platform === 'darwin' && } - - {process.platform === 'win32' && } - - {process.platform !== 'darwin' && } - {process.platform === 'win32' && } - - - - - - {!process.mas && } - +export const GeneralTab = () => { + const isDarwin = process.platform === 'darwin'; + const isWin32 = process.platform === 'win32'; + + return ( + + + + + + + + + {isDarwin && } + + {isWin32 && } + {!isDarwin && } + + + + + + + + + + + + + {isWin32 && } + + - -); + ); +}; diff --git a/src/ui/components/SettingsView/SettingsView.tsx b/src/ui/components/SettingsView/SettingsView.tsx index c2591508c5..890cced05d 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( @@ -66,7 +67,12 @@ export const SettingsView = () => { color='default' > {!isSideBarEnabled && ( - + )} {t('settings.title')} @@ -84,6 +90,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..63c8d19298 --- /dev/null +++ b/src/ui/components/SettingsView/VoiceVideoTab.tsx @@ -0,0 +1,49 @@ +import { + Accordion, + AccordionItem, + 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 9268c3e4eb..98bf55dfe4 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) => { htmlFor={browserSelectId} className={props.className} label={t('settings.options.availableBrowsers.title')} - hint={t('settings.options.availableBrowsers.description')} + description={t('settings.options.availableBrowsers.description')} > , +})); + +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', + configurable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + 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 index 1d4ac5b1ec..8753bfd18c 100644 --- a/src/ui/components/SettingsView/features/TelephonyServer.tsx +++ b/src/ui/components/SettingsView/features/TelephonyServer.tsx @@ -10,6 +10,14 @@ import type { RootState } from '../../../../store/rootReducer'; import { TELEPHONY_PREFERRED_SERVER_SET } from '../../../../telephony/actions'; import { SettingField } from './SettingField'; +const safeHostname = (url: string): string => { + try { + return new URL(url).hostname; + } catch { + return url; + } +}; + type TelephonyServerProps = { className?: string; }; @@ -19,6 +27,9 @@ export const TelephonyServer = (props: TelephonyServerProps) => { const telephonyPreferredServer = useSelector( ({ telephonyPreferredServer }: RootState) => telephonyPreferredServer ); + const isTelephonyEnabled = useSelector( + ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled + ); const dispatch = useDispatch>(); const { t } = useTranslation(); const telephonyServerSelectId = useId(); @@ -39,7 +50,7 @@ export const TelephonyServer = (props: TelephonyServerProps) => { ['auto', t('settings.options.telephonyServer.auto')], ...servers.map((s): [string, string] => [ s.url, - s.title ?? new URL(s.url).hostname, + s.title ?? safeHostname(s.url), ]), ], [servers, t] @@ -54,10 +65,11 @@ export const TelephonyServer = (props: TelephonyServerProps) => { className={props.className} htmlFor={telephonyServerSelectId} label={t('settings.options.telephonyServer.title')} - hint={t('settings.options.telephonyServer.description')} + description={t('settings.options.telephonyServer.description')} > ) => void; + disabled?: boolean; + /** escape hatch for extras (Callout, InputBox) rendered after the hint */ + children?: ReactNode; +} & Pick< + ComponentProps, + 'className' | 'marginBlock' | 'marginBlockStart' +>; + +/** + * Shared layout for a toggle-style settings row. Pairs a label with a + * ToggleSwitch on one row, then stacks the description and optional hint as + * left-aligned block elements below. Centralizes the canonical Fuselage + * label / description / hint vertical rhythm so every toggle row stays + * consistent. + */ +export const ToggleField = ({ + id, + label, + description, + hint, + checked, + onChange, + disabled, + children, + className, + marginBlock, + marginBlockStart, +}: ToggleFieldProps) => ( + + + {label} + + + {description} + {hint && {hint}} + {children} + +); diff --git a/src/ui/components/SettingsView/features/TransparentWindow.tsx b/src/ui/components/SettingsView/features/TransparentWindow.tsx index 13d4af9947..31362dc830 100644 --- a/src/ui/components/SettingsView/features/TransparentWindow.tsx +++ b/src/ui/components/SettingsView/features/TransparentWindow.tsx @@ -1,10 +1,3 @@ -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'; @@ -14,6 +7,7 @@ import type { Dispatch } from 'redux'; import type { RootAction } from '../../../../store/actions'; import type { RootState } from '../../../../store/rootReducer'; import { SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED } from '../../../actions'; +import { ToggleField } from './ToggleField'; type TransparentWindowProps = { className?: string; @@ -39,22 +33,14 @@ export const TransparentWindow = (props: TransparentWindowProps) => { const id = useId(); return ( - - - - {t('settings.options.transparentWindow.title')} - - - - - - {t('settings.options.transparentWindow.description')} - - - + ); }; diff --git a/src/ui/components/SettingsView/features/TrayIcon.tsx b/src/ui/components/SettingsView/features/TrayIcon.tsx index 24352ae0a7..c31a1e4a62 100644 --- a/src/ui/components/SettingsView/features/TrayIcon.tsx +++ b/src/ui/components/SettingsView/features/TrayIcon.tsx @@ -1,10 +1,3 @@ -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'; @@ -14,6 +7,7 @@ import type { Dispatch } from 'redux'; import type { RootAction } from '../../../../store/actions'; import type { RootState } from '../../../../store/rootReducer'; import { SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED } from '../../../actions'; +import { ToggleField } from './ToggleField'; type TrayIconProps = { className?: string; @@ -39,20 +33,21 @@ export const TrayIcon = (props: TrayIconProps) => { const isTrayIconEnabledId = useId(); return ( - - - - {t('settings.options.trayIcon.title')} - - - - - {t('settings.options.trayIcon.description')} - - + ); }; diff --git a/src/ui/components/SettingsView/features/VideoCallWindowPersistence.tsx b/src/ui/components/SettingsView/features/VideoCallWindowPersistence.tsx index dd62eab864..ffb3175cc0 100644 --- a/src/ui/components/SettingsView/features/VideoCallWindowPersistence.tsx +++ b/src/ui/components/SettingsView/features/VideoCallWindowPersistence.tsx @@ -1,10 +1,3 @@ -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'; @@ -14,6 +7,7 @@ import type { Dispatch } from 'redux'; import type { RootAction } from '../../../../store/actions'; import type { RootState } from '../../../../store/rootReducer'; import { SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED } from '../../../actions'; +import { ToggleField } from './ToggleField'; type VideoCallWindowPersistenceProps = { className?: string; @@ -42,22 +36,13 @@ export const VideoCallWindowPersistence = ( const id = useId(); return ( - - - - {t('settings.options.videoCallWindowPersistence.title')} - - - - - - {t('settings.options.videoCallWindowPersistence.description')} - - - + ); }; diff --git a/src/ui/components/SettingsView/features/__tests__/TelephonyDiagnostics.spec.tsx b/src/ui/components/SettingsView/features/__tests__/TelephonyDiagnostics.spec.tsx new file mode 100644 index 0000000000..a42c904a97 --- /dev/null +++ b/src/ui/components/SettingsView/features/__tests__/TelephonyDiagnostics.spec.tsx @@ -0,0 +1,301 @@ +import '@testing-library/jest-dom'; +import { + act, + render, + screen, + waitFor, + fireEvent, +} from '@testing-library/react'; + +import type { TelephonyDiagnostics } from '../../../../../telephony/diagnostics'; +import { TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED } from '../../../../actions'; +import { TelephonyDiagnostics as TelephonyDiagnosticsComponent } from '../TelephonyDiagnostics'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { defaultValue?: string }) => + opts?.defaultValue ?? key, + }), +})); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, +})); + +const mockInvoke = jest.fn(); +jest.mock('../../../../../ipc/renderer', () => ({ + invoke: (...args: any[]) => mockInvoke(...args), +})); + +// Fuselage Throbber uses SVG/canvas — simplified mock +jest.mock('@rocket.chat/fuselage', () => { + const actual = jest.requireActual('@rocket.chat/fuselage') as Record< + string, + unknown + >; + return { + ...actual, + Throbber: () =>
, + }; +}); + +const makeDiagnostics = ( + overrides?: Partial +): TelephonyDiagnostics => ({ + platform: 'darwin', + generatedAt: new Date().toISOString(), + checks: [ + { + id: 'isDefault.tel', + label: 'tel:// is set to Rocket.Chat', + status: 'pass', + }, + { + id: 'isDefault.callto', + label: 'callto:// is set to Rocket.Chat', + status: 'fail', + details: 'Not registered', + }, + { + id: 'windows.registeredApp', + label: 'Windows: Rocket.Chat is in RegisteredApplications', + status: 'unknown', + details: 'registry locked', + }, + ], + ...overrides, +}); + +describe('TelephonyDiagnostics', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Suppress act() warnings from async state updates + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore?.(); + }); + + it('shows a loading state initially, then renders checks after the promise resolves', async () => { + let resolvePromise!: (d: TelephonyDiagnostics) => void; + const pending = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockInvoke.mockReturnValue(pending); + + render(); + + expect(screen.getByTestId('throbber')).toBeInTheDocument(); + + await act(async () => { + resolvePromise(makeDiagnostics()); + await pending; + }); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + expect( + screen.getByText('tel:// is set to Rocket.Chat') + ).toBeInTheDocument(); + }); + + it('Refresh button re-invokes the IPC and re-renders with updated data', async () => { + const first = makeDiagnostics(); + const second = makeDiagnostics({ + checks: [ + { + id: 'isDefault.tel', + label: 'tel:// is set to Rocket.Chat', + status: 'pass', + }, + ], + }); + + mockInvoke.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + expect(mockInvoke).toHaveBeenCalledTimes(1); + + const refreshButton = screen.getByText('telephony.diagnostics.refresh'); + await act(async () => { + fireEvent.click(refreshButton); + }); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + expect(mockInvoke).toHaveBeenCalledWith('telephony/get-diagnostics'); + }); + + it('Copy button calls navigator.clipboard.writeText with JSON containing platform and check ids', async () => { + const diagnostics = makeDiagnostics(); + mockInvoke.mockResolvedValue(diagnostics); + + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + const copyButton = screen.getByText('telephony.diagnostics.copy'); + await act(async () => { + fireEvent.click(copyButton); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + const payload = JSON.parse( + writeText.mock.calls[0][0] + ) as TelephonyDiagnostics; + expect(payload.platform).toBe('darwin'); + expect(payload.checks.map((c) => c.id)).toContain('isDefault.tel'); + }); + + it('pass status renders pill with data-status="pass"', async () => { + mockInvoke.mockResolvedValue( + makeDiagnostics({ + checks: [ + { + id: 'isDefault.tel', + label: 'tel:// is set to Rocket.Chat', + status: 'pass', + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + const pill = screen.getByTestId('telephony-diagnostic-status'); + expect(pill.getAttribute('data-status')).toBe('pass'); + expect( + screen.queryByTestId('telephony-diagnostic-open-settings') + ).not.toBeInTheDocument(); + }); + + it('fail status renders pill with data-status="fail"', async () => { + mockInvoke.mockResolvedValue( + makeDiagnostics({ + checks: [ + { + id: 'isDefault.callto', + label: 'callto:// is set to Rocket.Chat', + status: 'fail', + details: 'Not registered', + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + const pill = screen.getByTestId('telephony-diagnostic-status'); + expect(pill.getAttribute('data-status')).toBe('fail'); + expect( + screen.queryByTestId('telephony-diagnostic-open-settings') + ).not.toBeInTheDocument(); + }); + + it('shows an open settings action for actionable failures', async () => { + mockInvoke.mockResolvedValue( + makeDiagnostics({ + checks: [ + { + id: 'isDefault.callto', + label: 'callto:// is set to Rocket.Chat', + status: 'fail', + details: 'Not registered', + action: 'openDefaultAppsSettings', + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + expect( + screen.getByTestId('telephony-diagnostic-open-settings') + ).toHaveTextContent('telephony.diagnostics.openSettingsAction'); + }); + + it('open settings action dispatches the default apps action', async () => { + mockInvoke.mockResolvedValue( + makeDiagnostics({ + checks: [ + { + id: 'isDefault.callto', + label: 'callto:// is set to Rocket.Chat', + status: 'fail', + details: 'Not registered', + action: 'openDefaultAppsSettings', + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('telephony-diagnostic-open-settings')); + }); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, + }); + }); + + it('unknown status renders pill with data-status="unknown"', async () => { + mockInvoke.mockResolvedValue( + makeDiagnostics({ + checks: [ + { + id: 'isDefault.sip', + label: 'sip:// is set to Rocket.Chat', + status: 'unknown', + details: 'registry locked', + }, + ], + }) + ); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('throbber')).not.toBeInTheDocument(); + }); + + const pill = screen.getByTestId('telephony-diagnostic-status'); + expect(pill.getAttribute('data-status')).toBe('unknown'); + }); +}); diff --git a/src/ui/components/Shell/index.tsx b/src/ui/components/Shell/index.tsx index b64c8fdf1b..7e942f9366 100644 --- a/src/ui/components/Shell/index.tsx +++ b/src/ui/components/Shell/index.tsx @@ -17,6 +17,8 @@ import { ServersView } from '../ServersView'; import { SettingsView } from '../SettingsView'; import { SideBar } from '../SideBar'; import { SupportedVersionDialog } from '../SupportedVersionDialog'; +import { TelephonyDefaultHandlerPromptModal } from '../TelephonyDefaultHandlerPromptModal'; +import { TelephonyServerSelectModal } from '../TelephonyServerSelectModal'; import { TopBar } from '../TopBar'; import { UpdateDialog } from '../UpdateDialog'; import TooltipProvider from '../utils/TooltipProvider'; @@ -102,6 +104,8 @@ export const Shell = () => { + + ); }; diff --git a/src/ui/components/TelephonyDefaultHandlerPromptModal/index.spec.tsx b/src/ui/components/TelephonyDefaultHandlerPromptModal/index.spec.tsx new file mode 100644 index 0000000000..97ea4708aa --- /dev/null +++ b/src/ui/components/TelephonyDefaultHandlerPromptModal/index.spec.tsx @@ -0,0 +1,338 @@ +import '@testing-library/jest-dom'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../store/rootReducer'; +import type { TelephonyDiagnostics } from '../../../telephony/diagnostics'; +import { + TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, +} from '../../actions'; +import { TelephonyDefaultHandlerPromptModal } from './index'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const mockInvoke = jest.fn(); +jest.mock('../../../ipc/renderer', () => ({ + invoke: (...args: any[]) => mockInvoke(...args), +})); + +// Dialog uses showModal() which is not available in jsdom. +// Mock to a simple conditional wrapper focused on content/dispatch logic. +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children?: ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const openState = (): PartialState => ({ + dialogs: { + telephonyDefaultHandlerPrompt: { isOpen: true }, + telephonyServerSelect: null, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +const closedState = (): PartialState => ({ + dialogs: { + telephonyDefaultHandlerPrompt: { isOpen: false }, + telephonyServerSelect: null, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +const makeDiagnostics = ( + checks: TelephonyDiagnostics['checks'] +): TelephonyDiagnostics => ({ + platform: process.platform, + generatedAt: new Date().toISOString(), + checks, +}); + +const flushDiagnostics = async (): Promise => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const setPlatform = (platform: NodeJS.Platform): (() => void) => { + const original = process.platform; + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + return () => + Object.defineProperty(process, 'platform', { + value: original, + configurable: true, + }); +}; + +describe('TelephonyDefaultHandlerPromptModal', () => { + let restorePlatform: () => void; + + afterEach(() => { + restorePlatform?.(); + jest.clearAllMocks(); + }); + + it('renders nothing when isOpen=false', () => { + restorePlatform = setPlatform('linux'); + mockInvoke.mockResolvedValue(makeDiagnostics([])); + const store = makeStore(closedState()); + const { container } = render( + + + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('renders only the generic copy and dismiss button when Windows diagnostics pass', async () => { + restorePlatform = setPlatform('win32'); + mockInvoke.mockResolvedValue( + makeDiagnostics([ + { + id: 'isDefault.tel', + label: 'tel:// is set to Rocket.Chat', + status: 'pass', + }, + ]) + ); + const store = makeStore(openState()); + render( + + + + ); + + await flushDiagnostics(); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('telephony/get-diagnostics'); + }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.title') + ).toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.body') + ).toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.bodyWindows') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.openSettingsWindows') + ).not.toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.dismiss') + ).toBeInTheDocument(); + }); + + it('renders only the generic copy and dismiss button when Linux diagnostics pass', async () => { + restorePlatform = setPlatform('linux'); + mockInvoke.mockResolvedValue( + makeDiagnostics([ + { + id: 'linux.xdg.tel', + label: 'Linux: tel is set to Rocket.Chat', + status: 'pass', + }, + ]) + ); + const store = makeStore(openState()); + render( + + + + ); + + await flushDiagnostics(); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('telephony/get-diagnostics'); + }); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.title') + ).toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.body') + ).toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.bodyLinux') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.openSettingsLinux') + ).not.toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.dismiss') + ).toBeInTheDocument(); + }); + + it('renders platform guidance and open settings button for actionable failures', async () => { + restorePlatform = setPlatform('linux'); + mockInvoke.mockResolvedValue( + makeDiagnostics([ + { + id: 'linux.xdg.tel', + label: 'Linux: tel is set to Rocket.Chat', + status: 'fail', + details: 'facetime.desktop', + action: 'openDefaultAppsSettings', + }, + ]) + ); + const store = makeStore(openState()); + render( + + + + ); + + await flushDiagnostics(); + + await waitFor(() => { + expect( + screen.getByText('telephony.defaultHandlerPrompt.openSettingsLinux') + ).toBeInTheDocument(); + }); + + expect( + screen.getByText('telephony.defaultHandlerPrompt.bodyLinux') + ).toBeInTheDocument(); + }); + + it('hides body2 and openSettings button on darwin', async () => { + restorePlatform = setPlatform('darwin'); + mockInvoke.mockResolvedValue( + makeDiagnostics([ + { + id: 'isDefault.tel', + label: 'tel:// is set to Rocket.Chat', + status: 'fail', + action: 'openDefaultAppsSettings', + }, + ]) + ); + const store = makeStore(openState()); + render( + + + + ); + + await flushDiagnostics(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.bodyLinux') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('telephony.defaultHandlerPrompt.openSettingsLinux') + ).not.toBeInTheDocument(); + expect( + screen.getByText('telephony.defaultHandlerPrompt.dismiss') + ).toBeInTheDocument(); + }); + + it('dismiss button dispatches TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE only', async () => { + restorePlatform = setPlatform('linux'); + mockInvoke.mockResolvedValue(makeDiagnostics([])); + const store = makeStore(openState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + await flushDiagnostics(); + + fireEvent.click(screen.getByText('telephony.defaultHandlerPrompt.dismiss')); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE, + }); + }); + + it('open settings button dispatches OPEN_SETTINGS_CLICKED then CLOSE in order', async () => { + restorePlatform = setPlatform('linux'); + mockInvoke.mockResolvedValue( + makeDiagnostics([ + { + id: 'linux.xdg.tel', + label: 'Linux: tel is set to Rocket.Chat', + status: 'fail', + details: 'facetime.desktop', + action: 'openDefaultAppsSettings', + }, + ]) + ); + const store = makeStore(openState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + await flushDiagnostics(); + + await waitFor(() => { + expect( + screen.getByText('telephony.defaultHandlerPrompt.openSettingsLinux') + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('telephony.defaultHandlerPrompt.openSettingsLinux') + ); + + expect(dispatchSpy).toHaveBeenCalledTimes(2); + expect(dispatchSpy).toHaveBeenNthCalledWith(1, { + type: TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, + }); + expect(dispatchSpy).toHaveBeenNthCalledWith(2, { + type: TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE, + }); + }); +}); diff --git a/src/ui/components/TelephonyDefaultHandlerPromptModal/index.tsx b/src/ui/components/TelephonyDefaultHandlerPromptModal/index.tsx new file mode 100644 index 0000000000..36b8bec2e4 --- /dev/null +++ b/src/ui/components/TelephonyDefaultHandlerPromptModal/index.tsx @@ -0,0 +1,121 @@ +import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { Dispatch } from 'redux'; + +import { invoke } from '../../../ipc/renderer'; +import type { RootAction } from '../../../store/actions'; +import type { RootState } from '../../../store/rootReducer'; +import type { TelephonyDiagnostics } from '../../../telephony/diagnostics'; +import { + TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, +} from '../../actions'; +import { Dialog } from '../Dialog'; + +export const TelephonyDefaultHandlerPromptModal = () => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const [diagnostics, setDiagnostics] = useState( + null + ); + const [loadingDiagnostics, setLoadingDiagnostics] = useState(false); + + const isVisible = useSelector( + ({ dialogs }: RootState) => + dialogs?.telephonyDefaultHandlerPrompt?.isOpen ?? false + ); + + useEffect(() => { + if (!isVisible) { + setDiagnostics(null); + setLoadingDiagnostics(false); + return undefined; + } + + let isCanceled = false; + setLoadingDiagnostics(true); + void invoke('telephony/get-diagnostics') + .then((result) => { + if (!isCanceled) { + setDiagnostics(result); + } + }) + .catch(() => { + if (!isCanceled) { + setDiagnostics(null); + } + }) + .finally(() => { + if (!isCanceled) { + setLoadingDiagnostics(false); + } + }); + + return () => { + isCanceled = true; + }; + }, [isVisible]); + + const hasActionableFailure = useMemo( + () => + diagnostics?.checks.some( + (check) => + check.status !== 'pass' && check.action === 'openDefaultAppsSettings' + ) ?? false, + [diagnostics] + ); + + const showOpenSettingsButton = + !loadingDiagnostics && + hasActionableFailure && + (process.platform === 'win32' || process.platform === 'linux'); + + const handleClose = () => { + dispatch({ type: TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE }); + }; + + const handleOpenSettings = () => { + dispatch({ type: TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED }); + dispatch({ type: TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE }); + }; + + return ( + + + + {t('telephony.defaultHandlerPrompt.title')} + + + {t('telephony.defaultHandlerPrompt.body')} + + {showOpenSettingsButton && process.platform === 'win32' && ( + + {t('telephony.defaultHandlerPrompt.bodyWindows')} + + )} + {showOpenSettingsButton && process.platform === 'linux' && ( + + {t('telephony.defaultHandlerPrompt.bodyLinux')} + + )} + + {showOpenSettingsButton && process.platform === 'win32' && ( + + )} + {showOpenSettingsButton && process.platform === 'linux' && ( + + )} + + + + + ); +}; diff --git a/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx b/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx new file mode 100644 index 0000000000..831be16783 --- /dev/null +++ b/src/ui/components/TelephonyServerSelectModal/ServerItem.tsx @@ -0,0 +1,96 @@ +import { Avatar, Box } from '@rocket.chat/fuselage'; +import type { MouseEvent } from 'react'; +import { useMemo, useState } from 'react'; + +type ServerItemProps = { + url: string; + title?: string; + favicon?: string | null; + onClick: () => void; +}; + +export const ServerItem = ({ + url, + title, + favicon, + onClick, +}: ServerItemProps) => { + const [isHovered, setIsHovered] = useState(false); + const displayTitle = title ?? new URL(url).hostname; + const { hostname } = new URL(url); + + const initials = useMemo( + () => + (title ?? hostname) + ?.replace(url, hostname) + ?.split(/[^A-Za-z0-9]+/g) + ?.slice(0, 2) + ?.map((text) => text.slice(0, 1).toUpperCase()) + ?.join(''), + [title, url, hostname] + ); + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + onClick(); + }; + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {favicon ? ( + + ) : ( + + {initials} + + )} + + + + {displayTitle} + + + {hostname} + + + + ); +}; diff --git a/src/ui/components/TelephonyServerSelectModal/index.spec.tsx b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx new file mode 100644 index 0000000000..6ad7ff570a --- /dev/null +++ b/src/ui/components/TelephonyServerSelectModal/index.spec.tsx @@ -0,0 +1,251 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import type { RootState } from '../../../store/rootReducer'; +import { TELEPHONY_SERVER_SELECT_CLOSE } from '../../actions'; +import { TelephonyServerSelectModal } from './index'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Dialog uses .showModal() which is not available in all test environments. +// Mock to a simple conditional wrapper so tests stay focused on modal content/dispatch logic. +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children?: ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +type PartialState = Pick; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const twoServers = [ + { url: 'https://chat.alpha.com', title: 'Alpha Chat' }, + { url: 'https://chat.beta.com', title: 'Beta Chat' }, +]; + +const openDialogState = ( + servers: PartialState['servers'] = twoServers +): PartialState => ({ + servers, + dialogs: { + telephonyServerSelect: { + isOpen: true, + phoneNumber: '123', + rawUri: 'tel:123', + }, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +const closedDialogState = ( + servers: PartialState['servers'] = twoServers +): PartialState => ({ + servers, + dialogs: { + telephonyServerSelect: { + isOpen: false, + phoneNumber: '', + rawUri: '', + }, + serverInfoModal: { isOpen: false, serverData: null }, + } as unknown as RootState['dialogs'], +}); + +describe('TelephonyServerSelectModal', () => { + it('renders nothing when dialog is closed', () => { + const store = makeStore(closedDialogState()); + const { container } = render( + + + + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + + it('renders server items when dialog is open with 2 servers', () => { + const store = makeStore(openDialogState()); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Alpha Chat')).toBeInTheDocument(); + expect(screen.getByText('Beta Chat')).toBeInTheDocument(); + }); + + it('clicking a server dispatches TELEPHONY_SERVER_SELECT_CLOSE with serverUrl and rememberChoice false', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + }); + + it('clicking a server with rememberChoice checked dispatches rememberChoice true', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + // Toggle the checkbox via the label (label uses onClick to toggle state) + const label = screen.getByText( + 'dialog.telephonySelectServer.rememberChoice' + ); + fireEvent.click(label); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: true, + }, + }); + }); + + it('dialog onClose dispatches TELEPHONY_SERVER_SELECT_CLOSE with null payload', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('dialog-close')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: null, + }); + }); + + it('rememberChoice resets to false after close', () => { + const store = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const { rerender } = render( + + + + ); + + // Toggle rememberChoice on + const label = screen.getByText( + 'dialog.telephonySelectServer.rememberChoice' + ); + fireEvent.click(label); + + // Close the dialog + fireEvent.click(screen.getByTestId('dialog-close')); + + // Reopen: rebuild store with open state (simulates a new open event) + const store2 = makeStore(openDialogState()); + const dispatchSpy2 = jest.spyOn(store2, 'dispatch'); + + rerender( + + + + ); + + // After reopening, click a server — rememberChoice should be false (was reset by close) + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy2).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + + // Suppress unused var warning — dispatchSpy was used to trigger close above + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('rememberChoice resets when the dialog is closed by state update', () => { + const store = makeStore(openDialogState()); + + const { rerender } = render( + + + + ); + + fireEvent.click( + screen.getByText('dialog.telephonySelectServer.rememberChoice') + ); + + rerender( + + + + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + const reopenedStore = makeStore(openDialogState()); + const dispatchSpy = jest.spyOn(reopenedStore, 'dispatch'); + + rerender( + + + + ); + + fireEvent.click(screen.getByText('Alpha Chat')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { + serverUrl: 'https://chat.alpha.com', + rememberChoice: false, + }, + }); + }); +}); diff --git a/src/ui/components/TelephonyServerSelectModal/index.tsx b/src/ui/components/TelephonyServerSelectModal/index.tsx new file mode 100644 index 0000000000..77a17bbce2 --- /dev/null +++ b/src/ui/components/TelephonyServerSelectModal/index.tsx @@ -0,0 +1,90 @@ +import { Box, CheckBox } from '@rocket.chat/fuselage'; +import { 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 { TELEPHONY_SERVER_SELECT_CLOSE } from '../../actions'; +import { Dialog } from '../Dialog'; +import { ServerItem } from './ServerItem'; + +export const TelephonyServerSelectModal = () => { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + + const isVisible = useSelector( + ({ dialogs }: RootState) => dialogs?.telephonyServerSelect?.isOpen ?? false + ); + + const servers = useSelector(({ servers }: RootState) => servers); + + const [rememberChoice, setRememberChoice] = useState(false); + + // Covers state-driven closes (external dispatch flips isOpen without going + // through handleClose / handleServerClick). Handler-driven resets below stay + // in place so they also work in tests whose stub reducers ignore actions. + useEffect(() => { + if (!isVisible) { + setRememberChoice(false); + } + }, [isVisible]); + + const handleClose = () => { + dispatch({ type: TELEPHONY_SERVER_SELECT_CLOSE, payload: null }); + setRememberChoice(false); + }; + + const handleServerClick = (serverUrl: string) => { + dispatch({ + type: TELEPHONY_SERVER_SELECT_CLOSE, + payload: { serverUrl, rememberChoice }, + }); + setRememberChoice(false); + }; + + return ( + + + {t('dialog.telephonySelectServer.title')} + + + {t('dialog.telephonySelectServer.message')} + + + + {servers.map((server) => ( + handleServerClick(server.url)} + /> + ))} + + + + setRememberChoice(!rememberChoice)} + /> + + {t('dialog.telephonySelectServer.rememberChoice')} + + + + ); +}; diff --git a/src/ui/components/TopBar/index.tsx b/src/ui/components/TopBar/index.tsx index bc2f282f51..40168edbf6 100644 --- a/src/ui/components/TopBar/index.tsx +++ b/src/ui/components/TopBar/index.tsx @@ -25,7 +25,7 @@ export const TopBar = () => { flexDirection='row' justifyContent='center' alignItems='center' - color='font-default' + color='default' bg={sidebarBg} width='100%' > diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index c2d71eaab2..82f1f491a2 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -605,7 +605,11 @@ const createHelpMenu = createSelector( label: t('menus.toggleDevTools'), accelerator: 'CommandOrControl+Shift+D', click: async () => { - const browserWindow = await getRootWindow(); + // Target the focused window (e.g. the video call window) so DevTools + // open where the user is looking; fall back to the main window when + // nothing is focused. + const browserWindow = + BrowserWindow.getFocusedWindow() ?? (await getRootWindow()); if (!browserWindow.isVisible()) { browserWindow.showInactive(); diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index fa1225cb16..adabe831e1 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -9,7 +9,6 @@ import type { MediaAccessPermissionRequest, MenuItemConstructorOptions, OpenExternalPermissionRequest, - Session, UploadFile, UploadRawData, WebContents, @@ -122,6 +121,70 @@ export const getServerUrlByWebContentsId = ( )?.[0]; }; +export const setupServerViewPermissionHandler = ( + guestWebContents: WebContents, + rootWindow: BrowserWindow +): void => { + guestWebContents.session.setPermissionRequestHandler( + async (_webContents, permission, callback, details) => { + if (process.env.NODE_ENV === 'development') { + console.log('Permission request', permission, details); + } + switch (permission) { + case 'media': { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + rootWindow, + 'recordMessage', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in server view:', + error + ); + callback(false); + } + return; + } + + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + callback(true); + return; + + case 'openExternal': { + const { externalURL } = details as OpenExternalPermissionRequest; + if (!externalURL) { + callback(false); + return; + } + + try { + const allowed = await isProtocolAllowed(externalURL); + callback(allowed); + } catch (error) { + console.error( + 'Failed to validate external protocol request:', + error + ); + callback(false); + } + return; + } + + default: + callback(false); + } + } + ); +}; + const initializeServerWebContentsAfterReady = ( _serverUrl: string, guestWebContents: WebContents, @@ -402,48 +465,6 @@ export const attachGuestWebContentsEvents = async (): Promise => { ); }; - const handlePermissionRequest: Parameters< - Session['setPermissionRequestHandler'] - >[0] = async (_webContents, permission, callback, details) => { - console.log('Permission request', permission, details); - switch (permission) { - case 'media': { - const { mediaTypes = [] } = details as MediaAccessPermissionRequest; - await handleMediaPermissionRequest( - mediaTypes as ReadonlyArray<'audio' | 'video'>, - rootWindow, - 'recordMessage', - callback - ); - return; - } - - case 'geolocation': - case 'notifications': - case 'midiSysex': - case 'pointerLock': - case 'fullscreen': - callback(true); - return; - - case 'openExternal': { - if (!(details as OpenExternalPermissionRequest).externalURL) { - callback(false); - return; - } - - const allowed = await isProtocolAllowed( - (details as OpenExternalPermissionRequest).externalURL as string - ); - callback(allowed); - return; - } - - default: - callback(false); - } - }; - listen(WEBVIEW_READY, (action) => { const guestWebContents = webContents.fromId( action.payload.webContentsId @@ -454,9 +475,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { rootWindow ); - guestWebContents.session.setPermissionRequestHandler( - handlePermissionRequest - ); + setupServerViewPermissionHandler(guestWebContents, rootWindow); setupServerViewDisplayMedia(guestWebContents); @@ -507,7 +526,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { listen(SIDE_BAR_SERVER_COPY_URL, async (action) => { const guestWebContents = getWebContentsByServerUrl(action.payload); - const currentUrl = await guestWebContents?.getURL(); + const currentUrl = guestWebContents?.getURL(); clipboard.writeText(currentUrl || ''); }); @@ -577,7 +596,7 @@ export const attachGuestWebContentsEvents = async (): Promise => { label: t('sidebar.item.copyCurrentUrl'), click: async () => { const guestWebContents = getWebContentsByServerUrl(serverUrl); - const currentUrl = await guestWebContents?.getURL(); + const currentUrl = guestWebContents?.getURL(); clipboard.writeText(currentUrl || ''); }, }, diff --git a/src/ui/reducers/dialogs.ts b/src/ui/reducers/dialogs.ts index c51f54539c..a5469b92c0 100644 --- a/src/ui/reducers/dialogs.ts +++ b/src/ui/reducers/dialogs.ts @@ -1,7 +1,15 @@ import type { Reducer } from 'redux'; import type { ActionOf } from '../../store/actions'; -import { CLOSE_SERVER_INFO_MODAL, OPEN_SERVER_INFO_MODAL } from '../actions'; +import { + CLOSE_SERVER_INFO_MODAL, + OPEN_SERVER_INFO_MODAL, + TELEPHONY_SERVER_SELECT_OPEN, + TELEPHONY_SERVER_SELECT_CLOSE, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN, + TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE, + TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED, +} from '../actions'; type ServerInfoModalState = { isOpen: boolean; @@ -16,13 +24,30 @@ type ServerInfoModalState = { } | null; }; +type TelephonyServerSelectState = { + isOpen: boolean; + phoneNumber: string; + rawUri: string; +} | null; + +type TelephonyDefaultHandlerPromptState = { + isOpen: boolean; +} | null; + type DialogsState = { serverInfoModal: ServerInfoModalState; + telephonyServerSelect: TelephonyServerSelectState; + telephonyDefaultHandlerPrompt: TelephonyDefaultHandlerPromptState; }; type DialogsAction = | ActionOf - | ActionOf; + | ActionOf + | ActionOf + | ActionOf + | ActionOf + | ActionOf + | ActionOf; const initialServerInfoModalState: ServerInfoModalState = { isOpen: false, @@ -48,9 +73,50 @@ const serverInfoModal: Reducer = ( } }; +const telephonyServerSelect: Reducer< + TelephonyServerSelectState, + DialogsAction +> = (state = null, action) => { + switch (action.type) { + case TELEPHONY_SERVER_SELECT_OPEN: + return { + isOpen: true, + phoneNumber: action.payload.phoneNumber, + rawUri: action.payload.rawUri, + }; + + case TELEPHONY_SERVER_SELECT_CLOSE: + return null; + + default: + return state; + } +}; + +const telephonyDefaultHandlerPrompt: Reducer< + TelephonyDefaultHandlerPromptState, + DialogsAction +> = (state = null, action) => { + switch (action.type) { + case TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN: + return { + isOpen: true, + }; + + case TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE: + case TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED: + return null; + + default: + return state; + } +}; + export const dialogs: Reducer = ( state = { serverInfoModal: initialServerInfoModalState, + telephonyServerSelect: null, + telephonyDefaultHandlerPrompt: null, }, action ) => { @@ -62,6 +128,27 @@ export const dialogs: Reducer = ( serverInfoModal: serverInfoModal(state.serverInfoModal, action), }; + case TELEPHONY_SERVER_SELECT_OPEN: + case TELEPHONY_SERVER_SELECT_CLOSE: + return { + ...state, + telephonyServerSelect: telephonyServerSelect( + state.telephonyServerSelect, + action + ), + }; + + case TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN: + case TELEPHONY_DEFAULT_HANDLER_PROMPT_CLOSE: + case TELEPHONY_DEFAULT_HANDLER_PROMPT_OPEN_SETTINGS_CLICKED: + return { + ...state, + telephonyDefaultHandlerPrompt: telephonyDefaultHandlerPrompt( + state.telephonyDefaultHandlerPrompt, + action + ), + }; + default: return state; } diff --git a/src/ui/reducers/isTelephonyEnabled.ts b/src/ui/reducers/isTelephonyEnabled.ts new file mode 100644 index 0000000000..5b39f99811 --- /dev/null +++ b/src/ui/reducers/isTelephonyEnabled.ts @@ -0,0 +1,27 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED } from '../actions'; + +type IsTelephonyEnabledAction = + | ActionOf + | ActionOf; + +export const isTelephonyEnabled: Reducer = ( + state = false, + action +) => { + switch (action.type) { + case SETTINGS_SET_IS_TELEPHONY_ENABLED_CHANGED: + return action.payload; + + case APP_SETTINGS_LOADED: { + const { isTelephonyEnabled = state } = action.payload; + return isTelephonyEnabled; + } + + default: + return state; + } +}; diff --git a/src/videoCallWindow/ipc.ts b/src/videoCallWindow/ipc.ts index 17932dbafb..fa50d0bdfb 100644 --- a/src/videoCallWindow/ipc.ts +++ b/src/videoCallWindow/ipc.ts @@ -23,13 +23,26 @@ import type { ScreenPickerProvider, } from '../screenSharing/screenPicker/types'; import { checkScreenRecordingPermission } from '../screenSharing/screenRecordingPermission'; +import { + handleServerViewDisplayMediaRequest, + setupServerViewDisplayMedia, +} from '../screenSharing/serverViewScreenSharing'; import { select, dispatchLocal } from '../store'; import { VIDEO_CALL_WINDOW_STATE_CHANGED } from '../ui/actions'; import { debounce } from '../ui/main/debounce'; import { handleMediaPermissionRequest } from '../ui/main/mediaPermissions'; import { isInsideSomeScreen, getRootWindow } from '../ui/main/rootWindow'; +import { + getServerUrlByWebContentsId, + getWebContentsByServerUrl, + setupServerViewPermissionHandler, +} from '../ui/main/serverView'; import { openExternal } from '../utils/browserLauncher'; +// Alias to reach the WebContents static methods (e.g. fromFrame) from inside +// functions that shadow `webContents` with a parameter of the same name. +const electronWebContents = webContents; + const DESTRUCTION_CHECK_INTERVAL = 50; const DEVTOOLS_TIMEOUT = 2000; const WEBVIEW_CHECK_INTERVAL = 100; @@ -37,6 +50,24 @@ const WEBVIEW_CHECK_INTERVAL = 100; let videoCallWindow: BrowserWindow | null = null; let isVideoCallWindowDestroying = false; let pendingVideoCallUrl: string | null = null; +// READ ONLY by the renderer handshake (the BrowserWindow `loadFile` query +// payload, and the `video-call-window/request-url` IPC response). It carries +// the originating server's partition (`persist:`) so the call shares +// the main webview's cookies + localStorage. Do NOT read it for any lifecycle +// decision — those go through `activeCall` instead. +let pendingVideoCallPartition: string | null = null; + +const FALLBACK_PARTITION = 'persist:jitsi-session'; +type ActiveCall = { + url: string; // the conference URL this window was opened for + partition: string; // 'persist:' OR the fallback — always truthy + isSharedSession: boolean; // true only when a real server URL resolved + serverWebContentsId: number | null; +}; +let activeCall: ActiveCall | null = null; +// Serializes open-window requests so two near-simultaneous opens can't both pass +// the destruction/existing-window guards and race into `new BrowserWindow`. +let openWindowQueue: Promise = Promise.resolve(); let videoCallCredentials: { userId: string; authToken: string; @@ -87,7 +118,42 @@ const fetchVideoCallWindowState = async (browserWindow: BrowserWindow) => { }; }; +// Restore the plain server-view display-media handler on the originating +// server's session after a call window's unified handler took it over. Safe to +// call from every teardown path: it re-resolves the live server webContents and +// is idempotent (last-writer-wins, app-singleton provider). It must NOT null +// `activeCall` — each teardown path re-resolves the live server webContents. +const restoreServerViewHandler = async ( + call: ActiveCall | null +): Promise => { + if (!call?.isSharedSession) return; // isolated/fallback sessions: nothing to restore + const serverUrl = call.partition.replace(/^persist:/, ''); + const serverWc = getWebContentsByServerUrl(serverUrl); + if (serverWc && !serverWc.isDestroyed()) { + setupServerViewDisplayMedia(serverWc); + } + + // The shared-session teardown reset the permission handler to deny-all, + // which also kills permission prompts on the live main webview. Restore it. + // getRootWindow() can reject during teardown/before-quit (root window not + // initialized or already destroyed); only the permission-handler restore is + // skipped on failure — the display-media restore above always runs. + try { + const rootWindow = await getRootWindow(); + // Re-check after the await: getRootWindow yields the event loop. + if (serverWc && !serverWc.isDestroyed() && rootWindow) { + setupServerViewPermissionHandler(serverWc, rootWindow); + } + } catch (error) { + console.warn( + 'Video call window: could not restore server-view permission handler', + error + ); + } +}; + const cleanupVideoCallWindow = () => { + const capturedCall = activeCall; if ( videoCallWindow && !videoCallWindow.isDestroyed() && @@ -121,7 +187,11 @@ const cleanupVideoCallWindow = () => { console.log( 'Stopping webview JavaScript execution before window cleanup' ); - webviewContents.session.setPermissionRequestHandler(() => false); + // Don't reset the permission handler when sharing the server's + // session — it would disable permissions on the live main webview. + if (!capturedCall?.isSharedSession) { + webviewContents.session.setPermissionRequestHandler(() => false); + } webviewContents.loadURL('about:blank').catch(() => {}); } } catch (error) { @@ -130,6 +200,10 @@ const cleanupVideoCallWindow = () => { ); } + // Restore the server-view display-media handler that this call's unified + // handler took over (no-op on isolated/fallback sessions). + void restoreServerViewHandler(capturedCall); + // Clean up screen sharing listener before removing window listeners videoCallScreenSharingTracker.cleanup(); @@ -167,6 +241,9 @@ const cleanupVideoCallWindow = () => { videoCallWindow = null; isVideoCallWindowDestroying = false; videoCallWindowDestructionCount++; + // Only clear `activeCall` if it still belongs to this teardown — a stale + // prior-window teardown firing later must not wipe a freshly-set newer call. + if (activeCall === capturedCall) activeCall = null; console.log('Video call window cleanup completed'); logVideoCallWindowStats(); @@ -190,6 +267,33 @@ const createInternalPickerHandler = }); }; +// Schemes a conference page legitimately opens as an in-app popup (device +// pickers, transient PDF/export blobs). Everything else that isn't external +// http(s) is denied so a compromised conference frame can't spawn an Electron +// window pointed at `javascript:`, `data:`, `file:`, etc. +const ALLOWED_POPUP_SCHEMES = ['about:', 'blob:']; + +// Window-open policy shared by the video call window's host page and its +// conference webview: route external http(s) links (target="_blank" / +// window.open) to the system browser and deny the Electron popup, allow the +// in-app popup schemes above, and deny everything else. Mirrors the main app +// window's intent while keeping the popup surface closed by default. +const handleVideoCallWindowOpen = ({ + url, +}: { + url: string; +}): { action: 'deny' } | { action: 'allow' } => { + if (url.startsWith('http://') || url.startsWith('https://')) { + openExternal(url); + return { action: 'deny' }; + } + const lower = url.toLowerCase(); + if (ALLOWED_POPUP_SCHEMES.some((scheme) => lower.startsWith(scheme))) { + return { action: 'allow' }; + } + return { action: 'deny' }; +}; + const setupWebviewHandlers = (webContents: WebContents) => { // Track attached webviews that need handler setup const pendingWebviews: WebContents[] = []; @@ -200,13 +304,34 @@ const setupWebviewHandlers = (webContents: WebContents) => { const setupDisplayMediaHandler = (webviewWebContents: WebContents): void => { if (!provider) return; const currentProvider = provider; // Capture for closure + // When the call shares the server's session, this single per-session handler + // also serves the main server webview, so it must route by origin. Snapshot + // the active call at attach time; the routing decision reads it at request + // time via `call?.isSharedSession`. + const call = activeCall; try { // useSystemPicker is an experimental macOS 15+ option; not available on other platforms. // We set it to false unconditionally and use the callback handler on all platforms to // enable custom source selection (including PipeWire on Wayland via XDG portal). webviewWebContents.session.setDisplayMediaRequestHandler( - (_request, cb) => { + (request, cb) => { try { + // On a shared session, route by originating frame: in-call requests + // use the call window's picker; anything else (the main server + // webview) falls back to the server-view picker in the root window. + if (call?.isSharedSession) { + const originWebContents = request.frame + ? electronWebContents.fromFrame(request.frame) + : null; + const fromCallWindow = + !!originWebContents && + originWebContents.hostWebContents?.id === + videoCallWindow?.webContents.id; + if (!fromCallWindow) { + handleServerViewDisplayMediaRequest(cb); + return; + } + } currentProvider.handleDisplayMediaRequest(cb); } catch (error) { console.error('Error in screen picker handler:', error); @@ -225,6 +350,83 @@ const setupWebviewHandlers = (webContents: WebContents) => { _event: Event, webviewWebContents: WebContents ): void => { + // Route external links opened from the conference (target="_blank" / + // window.open) to the system browser instead of spawning a new Electron + // window, mirroring the main app window. + webviewWebContents.setWindowOpenHandler(handleVideoCallWindowOpen); + + // Send external-protocol target="_self" navigations (mailto:, tel:, custom + // schemes) to the browser too; http(s) self-navigations stay in the webview + // so the conference's own flows (auth redirects, etc.) keep working. + webviewWebContents.on('will-navigate', (event: Event, navUrl: string) => { + try { + const { protocol } = new URL(navUrl); + if ( + !['http:', 'https:', 'file:', 'data:', 'about:', 'blob:'].includes( + protocol + ) + ) { + event.preventDefault(); + isProtocolAllowed(navUrl).then((allowed) => { + if (allowed) { + openExternal(navUrl); + } + }); + } + } catch { + // Ignore unparseable URLs. + } + }); + + // Media (mic/cam) permission requests from the conference originate in the + // webview's session, NOT the host window's, so the handler must live on the + // webview partition. On a SHARED session that partition already carries the + // server view's permission handler (installed for the main webview) — leave + // it untouched so we don't clobber it. Only the isolated FALLBACK partition + // (`persist:jitsi-session`) has no handler of its own; install one there so + // the call still routes through the app's `handleMediaPermissionRequest` + // flow instead of relying on Electron's silent default-grant. + const call = activeCall; + if (!call?.isSharedSession) { + webviewWebContents.session.setPermissionRequestHandler( + async (_webContents, permission, callback, details) => { + if (permission === 'media') { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + videoCallWindow, + 'initiateCall', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in video call webview:', + error + ); + callback(false); + } + return; + } + + switch (permission) { + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + callback(true); + return; + case 'openExternal': + callback(true); + return; + default: + callback(false); + } + } + ); + } + if (screenPickerReady && provider) { setupDisplayMediaHandler(webviewWebContents); } else { @@ -261,336 +463,368 @@ const setupWebviewHandlers = (webContents: WebContents) => { }); }; -export const startVideoCallWindowHandler = (): void => { - // Sync IPC handler for provider name - used by jitsiBridge preload - // to skip initialization for non-Jitsi providers without async delay - ipcMain.on('video-call-window/get-provider-sync', (event) => { - event.returnValue = videoCallProviderName; - }); - - handle('video-call-window/screen-recording-is-permission-granted', async () => - checkScreenRecordingPermission() - ); - - handle('video-call-window/open-url', async (_webContents, url) => { - await openExternal(url); - }); +// eslint-disable-next-line complexity +const openVideoCallWindow = async ( + _wc: WebContents, + url: string, + options?: { + providerName?: string; + credentials?: { userId: string; authToken: string }; + } +): Promise => { + console.log('Video call window: Open-window handler called with URL:', url); - handle('video-call-window/open-screen-picker', async (callerWebContents) => { - if (!videoCallWindow || videoCallWindow.isDestroyed()) { - console.warn( - 'Video call window: Cannot open screen picker - window not available' - ); - return { success: false }; + // If a window for the same conference is already open, just focus it instead + // of tearing it down and recreating it. (`activeCall` still holds the current + // call here — it's only reassigned for the new call further below.) + if ( + videoCallWindow && + !videoCallWindow.isDestroyed() && + !isVideoCallWindowDestroying && + activeCall?.url === url + ) { + console.log( + 'Video call window: same conference already open, focusing existing window' + ); + if (videoCallWindow.isMinimized()) { + videoCallWindow.restore(); } + videoCallWindow.show(); + videoCallWindow.focus(); + return; + } - // Clean up any stale listener before registering a new one, to ensure only - // one ipcMain listener is active at a time (same pattern as createInternalPickerHandler). - videoCallScreenSharingTracker.cleanup(); - - videoCallWindow.webContents.send('video-call-window/open-screen-picker'); + // Store provider name and credentials + videoCallProviderName = options?.providerName ?? null; + videoCallCredentials = null; + if (options?.providerName === 'pexip' && options?.credentials) { + try { + const serverOrigin = new URL(_wc.getURL()).origin; + videoCallCredentials = { + userId: options.credentials.userId, + authToken: options.credentials.authToken, + serverUrl: serverOrigin, + }; + } catch { + // _wc.getURL() may not be a valid URL in edge cases + videoCallCredentials = null; + } + } - // Forward the picker response back to the calling webContents (e.g. the Jitsi webview - // preload that called ipcRenderer.invoke here). The screenSharePicker renderer sends - // the result via ipcRenderer.send → ipcMain; we relay it to the caller so that - // jitsiBridge's ipcRenderer.on listener fires correctly. - ipcMain.once( - 'video-call-window/screen-sharing-source-responded', - (_event, sourceId: string | null) => { - if (!callerWebContents.isDestroyed()) { - callerWebContents.send( - 'video-call-window/screen-sharing-source-responded', - sourceId - ); + if (isVideoCallWindowDestroying) { + console.log('Waiting for video call window destruction to complete...'); + await new Promise((resolve) => { + const checkDestructionComplete = () => { + if (!isVideoCallWindowDestroying) { + resolve(); + } else { + setTimeout(checkDestructionComplete, DESTRUCTION_CHECK_INTERVAL); } - } - ); - - return { success: true }; - }); - - // eslint-disable-next-line complexity - handle('video-call-window/open-window', async (_wc, url, options) => { - console.log('Video call window: Open-window handler called with URL:', url); + }; + checkDestructionComplete(); + }); + } - // Store provider name and credentials - videoCallProviderName = options?.providerName ?? null; - videoCallCredentials = null; - if (options?.providerName === 'pexip' && options?.credentials) { - try { - const serverOrigin = new URL(_wc.getURL()).origin; - videoCallCredentials = { - userId: options.credentials.userId, - authToken: options.credentials.authToken, - serverUrl: serverOrigin, - }; - } catch { - // _wc.getURL() may not be a valid URL in edge cases - videoCallCredentials = null; - } - } + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + console.log('Closing existing video call window to create fresh one'); + videoCallWindow.close(); + videoCallWindow = null; if (isVideoCallWindowDestroying) { - console.log('Waiting for video call window destruction to complete...'); await new Promise((resolve) => { - const checkDestructionComplete = () => { + const checkClosed = () => { if (!isVideoCallWindowDestroying) { resolve(); } else { - setTimeout(checkDestructionComplete, DESTRUCTION_CHECK_INTERVAL); + setTimeout(checkClosed, DESTRUCTION_CHECK_INTERVAL); } }; - checkDestructionComplete(); + checkClosed(); }); } + } - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - console.log('Closing existing video call window to create fresh one'); - videoCallWindow.close(); - videoCallWindow = null; - - if (isVideoCallWindowDestroying) { - await new Promise((resolve) => { - const checkClosed = () => { - if (!isVideoCallWindowDestroying) { - resolve(); - } else { - setTimeout(checkClosed, DESTRUCTION_CHECK_INTERVAL); - } - }; - checkClosed(); - }); - } - } + const validUrl = new URL(url); + const allowedProtocols = ['http:', 'https:']; + console.log( + 'Video call window: URL validation - hostname:', + validUrl.hostname, + 'protocol:', + validUrl.protocol + ); - const validUrl = new URL(url); - const allowedProtocols = ['http:', 'https:']; - console.log( - 'Video call window: URL validation - hostname:', - validUrl.hostname, - 'protocol:', - validUrl.protocol + // Validate the protocol up front (fail closed). This must run BEFORE the + // g.co external-open special-case so a disallowed protocol (e.g. ftp://) + // can never reach openExternal. + if (!allowedProtocols.includes(validUrl.protocol)) { + throw new Error( + `Invalid video call URL protocol: ${validUrl.protocol}. Only http: and https: are allowed.` ); + } - if (validUrl.hostname.match(/(\.)?g\.co$/)) { + // Exact host match (or a true subdomain of g.co) — avoids overmatching + // hostnames like `evilg.co` that a `(\.)?g\.co$` regex would accept. + if (validUrl.hostname === 'g.co' || validUrl.hostname.endsWith('.g.co')) { + console.log( + 'Video call window: Google URL detected, opening externally instead of internal window' + ); + openExternal(validUrl.toString()); + return; + } + // The protocol is already validated above (fail-closed throw) and the g.co + // external-open case has returned, so by here a window WILL be created. + // Resolve the partition and set `activeCall` now — doing it earlier would + // leave stale state behind for opens that bail out before `new BrowserWindow`, + // which a later teardown could misread. + // + // Always load the call webview in the originating server's partition so it + // shares the main webview's session (cookies + localStorage). When the + // server can't be resolved, fall back to an isolated jitsi-session partition. + { + const serverUrl = getServerUrlByWebContentsId(_wc.id); + const partition = serverUrl ? `persist:${serverUrl}` : FALLBACK_PARTITION; + activeCall = { + url, + partition, + isSharedSession: Boolean(serverUrl), + serverWebContentsId: serverUrl ? _wc.id : null, + }; + pendingVideoCallPartition = partition; // handshake global only + if (activeCall.isSharedSession) { console.log( - 'Video call window: Google URL detected, opening externally instead of internal window' + 'Video call window: sharing server session via partition', + partition + ); + } else { + console.warn( + 'Video call window: could not resolve originating server; opening with isolated fallback partition', + partition ); - openExternal(validUrl.toString()); - return; } - if (allowedProtocols.includes(validUrl.protocol)) { - const mainWindow = await getRootWindow(); - const winBounds = await mainWindow.getNormalBounds(); - const centeredWindowPosition = { - x: winBounds.x + winBounds.width / 2, - y: winBounds.y + winBounds.height / 2, - }; + const mainWindow = await getRootWindow(); + const winBounds = mainWindow.getNormalBounds(); - const actualScreen = screen.getDisplayNearestPoint({ - x: centeredWindowPosition.x, - y: centeredWindowPosition.y, - }); + const centeredWindowPosition = { + x: winBounds.x + winBounds.width / 2, + y: winBounds.y + winBounds.height / 2, + }; - const state = select((state) => ({ - videoCallWindowState: state.videoCallWindowState, - isVideoCallWindowPersistenceEnabled: - state.isVideoCallWindowPersistenceEnabled, - isAutoOpenEnabled: state.isVideoCallDevtoolsAutoOpenEnabled, - })); + const actualScreen = screen.getDisplayNearestPoint({ + x: centeredWindowPosition.x, + y: centeredWindowPosition.y, + }); - let { x, y, width, height } = state.videoCallWindowState.bounds; + const state = select((state) => ({ + videoCallWindowState: state.videoCallWindowState, + isVideoCallWindowPersistenceEnabled: + state.isVideoCallWindowPersistenceEnabled, + isAutoOpenEnabled: state.isVideoCallDevtoolsAutoOpenEnabled, + })); + + let { x, y, width, height } = state.videoCallWindowState.bounds; + + if ( + !state.isVideoCallWindowPersistenceEnabled || + !x || + !y || + width === 0 || + height === 0 || + !isInsideSomeScreen({ x, y, width, height }) + ) { + width = Math.round(actualScreen.workAreaSize.width * 0.8); + height = Math.round(actualScreen.workAreaSize.height * 0.8); + x = Math.round( + (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + ); + y = Math.round( + (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y + ); + } - if ( - !state.isVideoCallWindowPersistenceEnabled || - !x || - !y || - width === 0 || - height === 0 || - !isInsideSomeScreen({ x, y, width, height }) - ) { - width = Math.round(actualScreen.workAreaSize.width * 0.8); - height = Math.round(actualScreen.workAreaSize.height * 0.8); - x = Math.round( - (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x - ); - y = Math.round( - (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y - ); - } + console.log('Creating new video call window'); + videoCallWindowCreationCount++; - console.log('Creating new video call window'); - videoCallWindowCreationCount++; + logVideoCallWindowStats(); - logVideoCallWindowStats(); + const additionalArgs: string[] = []; - const additionalArgs: string[] = []; + if (process.platform === 'win32') { + const sessionName = process.env.SESSIONNAME; + const isRdpSession = + typeof sessionName === 'string' && sessionName !== 'Console'; + const { readSetting } = await import('../store/readSetting'); + const isScreenCaptureFallbackEnabled = readSetting( + 'isVideoCallScreenCaptureFallbackEnabled' + ); - if (process.platform === 'win32') { - const sessionName = process.env.SESSIONNAME; - const isRdpSession = - typeof sessionName === 'string' && sessionName !== 'Console'; - const { readSetting } = await import('../store/readSetting'); - const isScreenCaptureFallbackEnabled = readSetting( - 'isVideoCallScreenCaptureFallbackEnabled' + if (isScreenCaptureFallbackEnabled || isRdpSession) { + additionalArgs.push( + '--disable-features=WebRtcAllowWgcDesktopCapturer,WebRtcAllowWgcScreenCapturer' + ); + console.log( + 'Video call window: Explicitly passing WGC disable flags to webview via additionalArguments', + { isRdpSession, isScreenCaptureFallbackEnabled } ); - - if (isScreenCaptureFallbackEnabled || isRdpSession) { - additionalArgs.push( - '--disable-features=WebRtcAllowWgcDesktopCapturer,WebRtcAllowWgcScreenCapturer' - ); - console.log( - 'Video call window: Explicitly passing WGC disable flags to webview via additionalArguments', - { isRdpSession, isScreenCaptureFallbackEnabled } - ); - } } + } - videoCallWindow = new BrowserWindow({ - width, - height, - x, - y, - webPreferences: { - nodeIntegration: true, - nodeIntegrationInSubFrames: true, - contextIsolation: false, - webviewTag: true, - experimentalFeatures: false, - offscreen: false, - disableHtmlFullscreenWindowResize: true, - backgroundThrottling: true, - v8CacheOptions: 'bypassHeatCheck', - spellcheck: false, - ...(additionalArgs.length > 0 && { - additionalArguments: additionalArgs, - }), - }, - show: false, - frame: true, - transparent: false, - skipTaskbar: false, - }); + videoCallWindow = new BrowserWindow({ + width, + height, + x, + y, + webPreferences: { + nodeIntegration: true, + nodeIntegrationInSubFrames: true, + contextIsolation: false, + webviewTag: true, + experimentalFeatures: false, + offscreen: false, + disableHtmlFullscreenWindowResize: true, + backgroundThrottling: true, + v8CacheOptions: 'bypassHeatCheck', + spellcheck: false, + ...(additionalArgs.length > 0 && { + additionalArguments: additionalArgs, + }), + }, + show: false, + frame: true, + transparent: false, + skipTaskbar: false, + }); - videoCallWindow.webContents.on( - 'will-navigate', - (event: Event, url: string) => { - if (url.toLowerCase().startsWith('smb://')) { - event.preventDefault(); - } + // Capture per-window so a later call opening (which resets the module + // state) can't change which server's handlers this window restores. + const capturedCall = activeCall; + + videoCallWindow.webContents.on( + 'will-navigate', + (event: Event, url: string) => { + if (url.toLowerCase().startsWith('smb://')) { + event.preventDefault(); } - ); - videoCallWindow.webContents.setWindowOpenHandler( - ({ url }: { url: string }) => { - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } - return { action: 'allow' }; + } + ); + videoCallWindow.webContents.setWindowOpenHandler( + ({ url }: { url: string }) => { + if (url.toLowerCase().startsWith('smb://')) { + return { action: 'deny' }; } - ); - - if (state.isVideoCallWindowPersistenceEnabled) { - const fetchAndDispatchWindowState = debounce(async () => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - dispatchLocal({ - type: VIDEO_CALL_WINDOW_STATE_CHANGED, - payload: await fetchVideoCallWindowState(videoCallWindow), - }); - } - }, 1000); - - videoCallWindow.addListener('show', fetchAndDispatchWindowState); - videoCallWindow.addListener('hide', fetchAndDispatchWindowState); - videoCallWindow.addListener('focus', fetchAndDispatchWindowState); - videoCallWindow.addListener('blur', fetchAndDispatchWindowState); - videoCallWindow.addListener('maximize', fetchAndDispatchWindowState); - videoCallWindow.addListener('unmaximize', fetchAndDispatchWindowState); - videoCallWindow.addListener('minimize', fetchAndDispatchWindowState); - videoCallWindow.addListener('restore', fetchAndDispatchWindowState); - videoCallWindow.addListener('resize', fetchAndDispatchWindowState); - videoCallWindow.addListener('move', fetchAndDispatchWindowState); + return { action: 'allow' }; } + ); - videoCallWindow.on('closed', () => { - console.log('Video call window closed - destroying completely'); + if (state.isVideoCallWindowPersistenceEnabled) { + const fetchAndDispatchWindowState = debounce(async () => { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + dispatchLocal({ + type: VIDEO_CALL_WINDOW_STATE_CHANGED, + payload: await fetchVideoCallWindowState(videoCallWindow), + }); + } + }, 1000); + + videoCallWindow.addListener('show', fetchAndDispatchWindowState); + videoCallWindow.addListener('hide', fetchAndDispatchWindowState); + videoCallWindow.addListener('focus', fetchAndDispatchWindowState); + videoCallWindow.addListener('blur', fetchAndDispatchWindowState); + videoCallWindow.addListener('maximize', fetchAndDispatchWindowState); + videoCallWindow.addListener('unmaximize', fetchAndDispatchWindowState); + videoCallWindow.addListener('minimize', fetchAndDispatchWindowState); + videoCallWindow.addListener('restore', fetchAndDispatchWindowState); + videoCallWindow.addListener('resize', fetchAndDispatchWindowState); + videoCallWindow.addListener('move', fetchAndDispatchWindowState); + } - // Clean up screen sharing listener - videoCallScreenSharingTracker.cleanup(); + videoCallWindow.on('closed', () => { + console.log('Video call window closed - destroying completely'); - // Clear credentials and provider on close - videoCallCredentials = null; - videoCallProviderName = null; + // Clean up screen sharing listener + videoCallScreenSharingTracker.cleanup(); - // Use setTimeout to ensure cleanup happens after any potential app lifecycle events - // This prevents crashes during first launch when timing is critical - setTimeout(() => { - try { - videoCallWindow = null; - isVideoCallWindowDestroying = false; - videoCallWindowDestructionCount++; + // This call's unified handler took over the shared session's + // display-media handler. Restore the plain server-view handler so + // main-app screen sharing keeps working (no-op on isolated sessions). + void restoreServerViewHandler(capturedCall); - logVideoCallWindowStats(); - } catch (error) { - console.error( - 'Error during video call window closed event handling:', - error - ); - } - }, 50); // Small delay to let app state stabilize - }); + // Clear credentials and provider on close + videoCallCredentials = null; + videoCallProviderName = null; - videoCallWindow.on('close', (_event) => { - if (!isVideoCallWindowDestroying) { - isVideoCallWindowDestroying = true; - console.log( - 'Video call window close initiated - preventing JS execution' + // Use setTimeout to ensure cleanup happens after any potential app lifecycle events + // This prevents crashes during first launch when timing is critical + setTimeout(() => { + try { + videoCallWindow = null; + isVideoCallWindowDestroying = false; + videoCallWindowDestructionCount++; + // Only clear `activeCall` if it still belongs to this window — a + // stale prior-window teardown must not wipe a freshly-set newer call. + if (activeCall === capturedCall) activeCall = null; + + logVideoCallWindowStats(); + } catch (error) { + console.error( + 'Error during video call window closed event handling:', + error ); - - // Clean up screen sharing listener - videoCallScreenSharingTracker.cleanup(); - - try { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.webContents.session.setPermissionRequestHandler( - () => false - ); - videoCallWindow.webContents - .executeJavaScript('void 0') - .catch(() => {}); - } - } catch (error) { - console.log('Error during close preparation:', error); - } } - }); + }, 50); // Small delay to let app state stabilize + }); - videoCallWindow.webContents.on( - 'did-fail-load', - (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - console.error('Video call window failed to load:', { - errorCode, - errorDescription, - validatedURL, - isMainFrame, - }); + videoCallWindow.on('close', (_event) => { + if (!isVideoCallWindowDestroying) { + isVideoCallWindowDestroying = true; + console.log( + 'Video call window close initiated - preventing JS execution' + ); - if (isMainFrame) { - console.error( - 'Main frame failed to load, this may indicate issues on low-power devices' + // Clean up screen sharing listener + videoCallScreenSharingTracker.cleanup(); + + try { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + videoCallWindow.webContents.session.setPermissionRequestHandler( + () => false ); + videoCallWindow.webContents + .executeJavaScript('void 0') + .catch(() => {}); } + } catch (error) { + console.log('Error during close preparation:', error); } - ); + } + }); + + videoCallWindow.webContents.on( + 'did-fail-load', + (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + console.error('Video call window failed to load:', { + errorCode, + errorDescription, + validatedURL, + isMainFrame, + }); - videoCallWindow.webContents.on('dom-ready', () => { - if (process.env.NODE_ENV === 'development') { - console.log('Video call window DOM ready'); + if (isMainFrame) { + console.error( + 'Main frame failed to load, this may indicate issues on low-power devices' + ); } + } + ); + + videoCallWindow.webContents.on('dom-ready', () => { + if (process.env.NODE_ENV === 'development') { + console.log('Video call window DOM ready'); + } - videoCallWindow?.webContents - .executeJavaScript( - ` + videoCallWindow?.webContents + .executeJavaScript( + ` if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'development') { console.log('Video call window: JavaScript execution test successful'); } @@ -609,203 +843,340 @@ export const startVideoCallWindowHandler = (): void => { } }, 5000); ` - ) - .catch((error) => { - console.error( - 'Video call window: JavaScript execution test failed:', - error - ); - }); - }); - - videoCallWindow.webContents.on( - 'console-message', - (_event, level, message, line, sourceId) => { - const logPrefix = 'Video call window console:'; - switch (level) { - case 0: - console.log( - `${logPrefix} [INFO]`, - message, - `(${sourceId}:${line})` - ); - break; - case 1: - console.warn( - `${logPrefix} [WARN]`, - message, - `(${sourceId}:${line})` - ); - break; - case 2: - console.error( - `${logPrefix} [ERROR]`, - message, - `(${sourceId}:${line})` - ); - break; - default: - console.log( - `${logPrefix} [${level}]`, - message, - `(${sourceId}:${line})` - ); - } - } - ); - - const htmlPath = path.join( - app.getAppPath(), - 'app/video-call-window.html' - ); - console.log('Video call window: Loading HTML file from:', htmlPath); - - videoCallWindow - .loadFile(htmlPath, { - query: { - url, - autoOpenDevtools: String(state.isAutoOpenEnabled), - }, - }) + ) .catch((error) => { - console.error('Video call window: Failed to load HTML file:', error); console.error( - 'This may indicate build issues or file system problems on low-power devices' + 'Video call window: JavaScript execution test failed:', + error ); }); + }); - videoCallWindow.once('ready-to-show', () => { - if (videoCallWindow && !videoCallWindow.isDestroyed()) { - videoCallWindow.setTitle(packageJsonInformation.productName); - - console.log( - 'Video call window: Window ready, waiting for renderer to signal ready state' - ); - console.log( - 'Video call window: Current pending URL:', - pendingVideoCallUrl - ); - videoCallWindow.show(); + videoCallWindow.webContents.on( + 'console-message', + (_event, level, message, line, sourceId) => { + const logPrefix = 'Video call window console:'; + switch (level) { + case 0: + console.log( + `${logPrefix} [INFO]`, + message, + `(${sourceId}:${line})` + ); + break; + case 1: + console.warn( + `${logPrefix} [WARN]`, + message, + `(${sourceId}:${line})` + ); + break; + case 2: + console.error( + `${logPrefix} [ERROR]`, + message, + `(${sourceId}:${line})` + ); + break; + default: + console.log( + `${logPrefix} [${level}]`, + message, + `(${sourceId}:${line})` + ); } + } + ); + + const htmlPath = path.join(app.getAppPath(), 'app/video-call-window.html'); + console.log('Video call window: Loading HTML file from:', htmlPath); + + videoCallWindow + .loadFile(htmlPath, { + query: { + url, + autoOpenDevtools: String(state.isAutoOpenEnabled), + ...(pendingVideoCallPartition && { + partition: pendingVideoCallPartition, + }), + }, + }) + .catch((error) => { + console.error('Video call window: Failed to load HTML file:', error); + console.error( + 'This may indicate build issues or file system problems on low-power devices' + ); }); - const { webContents } = videoCallWindow; + videoCallWindow.once('ready-to-show', () => { + if (videoCallWindow && !videoCallWindow.isDestroyed()) { + videoCallWindow.setTitle(packageJsonInformation.productName); - // Setup webview handlers (listener registered synchronously, module loads async) - setupWebviewHandlers(webContents); + console.log( + 'Video call window: Window ready, waiting for renderer to signal ready state' + ); + console.log( + 'Video call window: Current pending URL:', + pendingVideoCallUrl + ); + videoCallWindow.show(); + } + }); - // Set the pending URL after window is created to prevent race condition with cleanup - setPendingVideoCallUrl(url, 'open-window-after-creation'); - console.log( - 'Video call window: Set pending URL after window creation:', - url - ); + const { webContents } = videoCallWindow; - webContents.setWindowOpenHandler(({ url }: { url: string }) => { - console.log('Video call window - new window requested:', url); + // Setup webview handlers (listener registered synchronously, module loads async) + setupWebviewHandlers(webContents); - if (url.toLowerCase().startsWith('smb://')) { - return { action: 'deny' }; - } + // If the call window's host process crashes, the graceful 'closed' restore + // never fires — restore the server-view handler here too (idempotent). + webContents.on('render-process-gone', () => { + void restoreServerViewHandler(capturedCall); + }); - if (url.startsWith('http://') || url.startsWith('https://')) { - openExternal(url); - return { action: 'deny' }; - } + // Set the pending URL after window is created to prevent race condition with cleanup + setPendingVideoCallUrl(url, 'open-window-after-creation'); + console.log( + 'Video call window: Set pending URL after window creation:', + url + ); - return { action: 'allow' }; - }); + webContents.setWindowOpenHandler((details: { url: string }) => { + console.log('Video call window - new window requested:', details.url); + return handleVideoCallWindowOpen(details); + }); + + webContents.on('will-navigate', (event: any, url: string) => { + console.log('Video call window will-navigate:', url); - webContents.on('will-navigate', (event: any, url: string) => { - console.log('Video call window will-navigate:', url); + // Check for close pages and handle them specially to prevent crashes + if (url.includes('/close.html') || url.includes('/close2.html')) { + console.log( + 'Video call window: Navigation to close page detected, will handle gracefully' + ); + // Don't prevent navigation, but note it for safer handling + } - // Check for close pages and handle them specially to prevent crashes - if (url.includes('/close.html') || url.includes('/close2.html')) { + try { + const parsedUrl = new URL(url); + + if ( + !['http:', 'https:', 'file:', 'data:', 'about:'].includes( + parsedUrl.protocol + ) + ) { console.log( - 'Video call window: Navigation to close page detected, will handle gracefully' + 'External protocol detected in video call window:', + parsedUrl.protocol ); - // Don't prevent navigation, but note it for safer handling - } + event.preventDefault(); - try { - const parsedUrl = new URL(url); + isProtocolAllowed(url).then((allowed) => { + if (allowed) { + openExternal(url); + } + }); + } + } catch (e) { + console.warn('Failed to parse URL in video call window:', url, e); + } + }); - if ( - !['http:', 'https:', 'file:', 'data:', 'about:'].includes( - parsedUrl.protocol - ) - ) { - console.log( - 'External protocol detected in video call window:', - parsedUrl.protocol - ); - event.preventDefault(); + webContents.session.setPermissionRequestHandler( + async ( + _webContents: any, + permission: any, + callback: any, + details: any + ) => { + console.log( + 'Video call window permission request', + permission, + details + ); + switch (permission) { + case 'media': { + const { mediaTypes = [] } = details as MediaAccessPermissionRequest; + try { + await handleMediaPermissionRequest( + mediaTypes as ReadonlyArray<'audio' | 'video'>, + videoCallWindow, + 'initiateCall', + callback + ); + } catch (error) { + console.error( + 'Error handling media permission request in video call window:', + error + ); + callback(false); + } + return; + } - isProtocolAllowed(url).then((allowed) => { - if (allowed) { - openExternal(url); - } - }); + case 'geolocation': + case 'notifications': + case 'midiSysex': + case 'pointerLock': + case 'fullscreen': + case 'screen-wake-lock': + case 'system-wake-lock': + callback(true); + return; + + case 'openExternal': { + callback(true); + return; } - } catch (e) { - console.warn('Failed to parse URL in video call window:', url, e); + + default: + callback(false); } - }); + } + ); + } +}; - webContents.session.setPermissionRequestHandler( - async ( - _webContents: any, - permission: any, - callback: any, - details: any - ) => { - console.log( - 'Video call window permission request', - permission, - details - ); - switch (permission) { - case 'media': { - const { mediaTypes = [] } = - details as MediaAccessPermissionRequest; - try { - await handleMediaPermissionRequest( - mediaTypes as ReadonlyArray<'audio' | 'video'>, - videoCallWindow, - 'initiateCall', - callback - ); - } catch (error) { - console.error( - 'Error handling media permission request in video call window:', - error - ); - callback(false); - } - return; - } +export const startVideoCallWindowHandler = (): void => { + // Sync IPC handler for provider name - used by jitsiBridge preload + // to skip initialization for non-Jitsi providers without async delay + ipcMain.on('video-call-window/get-provider-sync', (event) => { + event.returnValue = videoCallProviderName; + }); - case 'geolocation': - case 'notifications': - case 'midiSysex': - case 'pointerLock': - case 'fullscreen': - case 'screen-wake-lock': - case 'system-wake-lock': - callback(true); - return; + // Close the video call window on request from its own renderer. The + // renderer's window.close() can't close a window the main process created, so + // it asks via this fire-and-forget channel. We resolve the window from the + // sender (the webview guest's host window), so a renderer can only close its + // own window. + ipcMain.on('video-call-window/close', (event) => { + const { sender } = event; + const win = + BrowserWindow.fromWebContents(sender) ?? + (sender.hostWebContents + ? BrowserWindow.fromWebContents(sender.hostWebContents) + : null); + if (win && !win.isDestroyed()) { + win.close(); + } + }); - case 'openExternal': { - callback(true); - return; - } + handle('video-call-window/screen-recording-is-permission-granted', async () => + checkScreenRecordingPermission() + ); - default: - callback(false); - } + handle('video-call-window/open-url', async (_webContents, url) => { + await openExternal(url); + }); + + // Bring the main app window to the front and ask the active server's web + // client to navigate to an in-app route. Used by the standalone video-chat + // window, which has no window.opener and therefore can't reach the main + // window via the web app's window.open trick. + handle( + 'video-call-window/open-in-main-window', + async (callerWebContents, path) => { + // Defense in depth (the preload validates too): only accept in-app + // relative routes — reject absolute/protocol-relative/scheme URLs. + if ( + typeof path !== 'string' || + !path.startsWith('/') || + path.startsWith('//') || + path.startsWith('/\\') + ) { + console.warn( + 'Video call window: open-in-main-window rejected non-relative path:', + path + ); + return; + } + + // Resolve the target server webview in priority order: + // 1. the caller's own server (the conference webview, when resolvable); + // 2. the server the active call actually belongs to — authoritative, and + // avoids navigating a *different* server in a multi-workspace setup; + // 3. the server currently active in the main window (last-resort guess). + let serverUrl = getServerUrlByWebContentsId(callerWebContents.id); + if (!serverUrl && activeCall?.serverWebContentsId != null) { + serverUrl = getServerUrlByWebContentsId(activeCall.serverWebContentsId); + } + if (!serverUrl) { + const currentView = select((state) => state.currentView); + if (typeof currentView === 'object' && currentView.url) { + serverUrl = currentView.url; + console.warn( + 'Video call window: open-in-main-window could not resolve the call’s origin server; falling back to the active view', + serverUrl + ); + } + } + + const serverWebContents = serverUrl + ? getWebContentsByServerUrl(serverUrl) + : undefined; + if (!serverWebContents || serverWebContents.isDestroyed()) { + console.warn( + 'Video call window: open-in-main-window could not find a target server webview for', + serverUrl + ); + return; + } + + // Bring the main window to the foreground. + const rootWindow = await getRootWindow(); + if (rootWindow && !rootWindow.isDestroyed()) { + if (rootWindow.isMinimized()) { + rootWindow.restore(); } + rootWindow.show(); + rootWindow.focus(); + } + + // Client-side route change. NOT a loadURL — that would hard-reload the + // SPA. The web client listens for this event and calls its router. + serverWebContents.send('navigate-to-route', path); + } + ); + + handle('video-call-window/open-screen-picker', async (callerWebContents) => { + if (!videoCallWindow || videoCallWindow.isDestroyed()) { + console.warn( + 'Video call window: Cannot open screen picker - window not available' ); + return { success: false }; } + + // Clean up any stale listener before registering a new one, to ensure only + // one ipcMain listener is active at a time (same pattern as createInternalPickerHandler). + videoCallScreenSharingTracker.cleanup(); + + videoCallWindow.webContents.send('video-call-window/open-screen-picker'); + + // Forward the picker response back to the calling webContents (e.g. the Jitsi webview + // preload that called ipcRenderer.invoke here). The screenSharePicker renderer sends + // the result via ipcRenderer.send → ipcMain; we relay it to the caller so that + // jitsiBridge's ipcRenderer.on listener fires correctly. + ipcMain.once( + 'video-call-window/screen-sharing-source-responded', + (_event, sourceId: string | null) => { + if (!callerWebContents.isDestroyed()) { + callerWebContents.send( + 'video-call-window/screen-sharing-source-responded', + sourceId + ); + } + } + ); + + return { success: true }; + }); + + handle('video-call-window/open-window', (_wc, url, options) => { + const run = openWindowQueue.then(() => + openVideoCallWindow(_wc, url, options) + ); + openWindowQueue = run.catch(() => {}); // keep chain alive on failure + return run; }); handle('video-call-window/close-requested', async () => { @@ -992,6 +1363,7 @@ handle('video-call-window/request-url', async () => { success: true, url: pendingVideoCallUrl, autoOpenDevtools: state.isAutoOpenEnabled, + partition: pendingVideoCallPartition ?? undefined, }; }); diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..b1baca8261 --- /dev/null +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -0,0 +1,994 @@ +/** + * Regression tests for the PR #3359 hardening of `src/videoCallWindow/ipc.ts`. + * + * Location note: the brief asked for `src/videoCallWindow/ipc.main.spec.ts`, but + * jest.config.js routes MAIN-process tests via + * '/src/*\/main/**\/*.(spec|test)...' and + * '/src/**\/main.(spec|test)...' + * The second pattern requires the literal filename `main.spec.ts`; a flat + * `videoCallWindow/ipc.main.spec.ts` matches NEITHER project and is silently + * never run (verified with `jest --listTests`). The established convention for + * this module is the sibling `src/videoCallWindow/main/ipc.spec.ts`, which + * matches `src/*\/main/**`. This file lives in that same `main/` dir so it is + * actually discovered and run by the main-process project. + * + * Strategy A (chosen): exercise the real module wiring. We mock `'../../ipc/main'` + * so every `handle(channel, cb)` registration captures `cb` into a map; the + * open-window behavior is then driven by invoking the captured + * `'video-call-window/open-window'` callback directly. All sibling imports of + * `ipc.ts` (electron, serverView, serverViewScreenSharing, rootWindow, store, + * etc.) are mocked. This validates the genuine flow: + * open-window callback -> openWindowQueue chain -> openVideoCallWindow -> + * activeCall assignment -> BrowserWindow creation -> 'closed'/'render-process-gone' + * listeners -> restoreServerViewHandler. + * + * `restoreServerViewHandler`, `activeCall` and `openVideoCallWindow` are + * module-internal and NOT exported. We observe them indirectly: + * - `activeCall.partition` / `isSharedSession` -> via the `loadFile` query + * `partition` arg AND via whether `restoreServerViewHandler` (fired through + * the BrowserWindow `'closed'` listener) calls `setupServerViewDisplayMedia`. + * - `restoreServerViewHandler` -> by capturing the BrowserWindow `'closed'` + * listener and the webContents `'render-process-gone'` listener and firing + * them, then asserting `setupServerViewDisplayMedia` calls. + * - serialization -> by gating the awaited `getRootWindow()` on a controllable + * deferred and asserting the 2nd BrowserWindow is not constructed until the + * 1st open body resolves. + * - null-out guard -> by driving two opens then firing the FIRST window's + * delayed `'closed'` teardown and asserting the fresh `activeCall` survives + * (observed via a subsequent restore still targeting the 2nd server). + * + * No production code was changed; Strategy B (test-only export) was not needed. + */ +import type { WebContents } from 'electron'; + +// --------------------------------------------------------------------------- +// `handle` capture: the SUT calls handle() both at module top-level and inside +// startVideoCallWindowHandler(). We record every registration into a map keyed +// by channel so tests can invoke the open-window callback directly. +// --------------------------------------------------------------------------- +const handleRegistry = new Map any>(); + +jest.mock('../../ipc/main', () => ({ + handle: jest.fn((channel: string, cb: (...args: any[]) => any) => { + handleRegistry.set(channel, cb); + return () => handleRegistry.delete(channel); + }), +})); + +// --- serverView: controls server-URL resolution and live server webContents --- +const getServerUrlByWebContentsId = jest.fn(); +const getWebContentsByServerUrl = jest.fn(); +const setupServerViewPermissionHandler = jest.fn((..._a: any[]) => undefined); +jest.mock('../../ui/main/serverView', () => ({ + getServerUrlByWebContentsId: (...a: any[]) => + getServerUrlByWebContentsId(...a), + getWebContentsByServerUrl: (...a: any[]) => getWebContentsByServerUrl(...a), + setupServerViewPermissionHandler: (...a: any[]) => + setupServerViewPermissionHandler(...a), +})); + +// --- the handler restore + routing surface under test --- +const setupServerViewDisplayMedia = jest.fn((..._a: any[]) => undefined); +const handleServerViewDisplayMediaRequest = jest.fn( + (..._a: any[]) => undefined +); +jest.mock('../../screenSharing/serverViewScreenSharing', () => ({ + setupServerViewDisplayMedia: (...a: any[]) => + setupServerViewDisplayMedia(...a), + handleServerViewDisplayMediaRequest: (...a: any[]) => + handleServerViewDisplayMediaRequest(...a), +})); + +// --- getRootWindow is the first awaited point inside openVideoCallWindow --- +// A controllable deferred lets us assert serialization ordering deterministically. +let rootWindowDeferred: { + promise: Promise; + resolve: (v: any) => void; +} | null = null; +const makeDeferred = () => { + let resolve!: (v: any) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; +const fakeRootWindow = { + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })), + isDestroyed: jest.fn(() => false), + isMinimized: jest.fn(() => false), + restore: jest.fn(), + show: jest.fn(), + focus: jest.fn(), +}; +const getRootWindow = jest.fn((..._a: any[]) => { + // Default: resolve immediately. Tests can swap in a deferred to gate it. + if (rootWindowDeferred) return rootWindowDeferred.promise; + return Promise.resolve(fakeRootWindow); +}); +const isInsideSomeScreen = jest.fn((..._a: any[]) => true); +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...a: any[]) => getRootWindow(...a), + isInsideSomeScreen: (...a: any[]) => isInsideSomeScreen(...a), +})); + +// --- store: select() returns a state shape sufficient for the open path --- +const select = jest.fn((..._a: any[]) => ({ + videoCallWindowState: { bounds: { x: 0, y: 0, width: 0, height: 0 } }, + isVideoCallWindowPersistenceEnabled: false, + isAutoOpenEnabled: false, +})); +const dispatchLocal = jest.fn((..._a: any[]) => undefined); +jest.mock('../../store', () => ({ + select: (...a: any[]) => select(...a), + dispatchLocal: (...a: any[]) => dispatchLocal(...a), +})); + +// --- remaining leaf imports of ipc.ts: keep them inert --- +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); +jest.mock('../../i18n/common', () => ({ fallbackLng: 'en' })); +jest.mock('../../navigation/main', () => ({ + isProtocolAllowed: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('../../screenSharing/desktopCapturerCache', () => ({ + clearDesktopCapturerCache: jest.fn(), + getDesktopCapturerCacheStatus: jest.fn(() => ({ + cached: false, + pending: false, + })), + prewarmDesktopCapturerCache: jest.fn(), +})); +jest.mock('../../screenSharing/screenRecordingPermission', () => ({ + checkScreenRecordingPermission: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('../../ui/main/debounce', () => ({ + debounce: (cb: any) => cb, +})); +jest.mock('../../ui/main/mediaPermissions', () => ({ + handleMediaPermissionRequest: jest.fn(() => Promise.resolve()), +})); +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); +// The screen picker module is dynamically imported by setupWebviewHandlers; a +// lightweight stub keeps that async branch from throwing. +jest.mock('../../screenSharing/screenPicker', () => ({ + createScreenPicker: jest.fn(() => ({ + handleDisplayMediaRequest: jest.fn(), + })), + InternalPickerProvider: class {}, +})); +// ScreenSharingRequestTracker only needs to be constructible + .cleanup(). +jest.mock('../../screenSharing/ScreenSharingRequestTracker', () => ({ + ScreenSharingRequestTracker: class { + cleanup = jest.fn(); + + createRequest = jest.fn(); + }, +})); + +// --------------------------------------------------------------------------- +// electron mock. BrowserWindow records constructions and exposes captured +// event listeners so tests can fire 'closed' / 'render-process-gone'. +// --------------------------------------------------------------------------- +type FakeWC = { + id: number; + isDestroyed: jest.Mock; + on: jest.Mock; + once: jest.Mock; + setWindowOpenHandler: jest.Mock; + removeAllListeners: jest.Mock; + session: { setPermissionRequestHandler: jest.Mock }; + executeJavaScript: jest.Mock; + listeners: Record void>>; +}; + +type FakeBW = { + webContents: FakeWC; + loadFile: jest.Mock; + listeners: Record void>>; + loadFileQuery: any; +}; + +const createdWindows: FakeBW[] = []; +let wcIdSeq = 1000; + +const makeFakeWebContents = (): FakeWC => { + const listeners: Record void>> = {}; + const register = (event: string, fn: (...a: any[]) => void) => { + (listeners[event] ??= []).push(fn); + }; + return { + id: wcIdSeq++, + isDestroyed: jest.fn(() => false), + on: jest.fn((event: string, fn: any) => register(event, fn)), + once: jest.fn((event: string, fn: any) => register(event, fn)), + setWindowOpenHandler: jest.fn(), + removeAllListeners: jest.fn(), + session: { setPermissionRequestHandler: jest.fn() }, + executeJavaScript: jest.fn(() => Promise.resolve()), + listeners, + }; +}; + +class FakeBrowserWindow { + webContents = makeFakeWebContents(); + + loadFile = jest.fn((_path: string, opts?: any) => { + (this as unknown as FakeBW).loadFileQuery = opts?.query; + return Promise.resolve(); + }); + + listeners: Record void>> = {}; + + loadFileQuery: any = undefined; + + private register(event: string, fn: (...a: any[]) => void) { + (this.listeners[event] ??= []).push(fn); + } + + on = jest.fn((event: string, fn: any) => this.register(event, fn)); + + once = jest.fn((event: string, fn: any) => this.register(event, fn)); + + addListener = jest.fn((event: string, fn: any) => this.register(event, fn)); + + removeAllListeners = jest.fn(); + + close = jest.fn(); + + isDestroyed = jest.fn(() => false); + + setTitle = jest.fn(); + + show = jest.fn(); + + focus = jest.fn(); + + isMinimized = jest.fn(() => false); + + restore = jest.fn(); + + isFocused = jest.fn(() => true); + + isVisible = jest.fn(() => true); + + getNormalBounds = jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })); + + constructor() { + createdWindows.push(this as unknown as FakeBW); + } +} + +const screen = { + getDisplayNearestPoint: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), +}; + +jest.mock('electron', () => ({ + app: { getAppPath: jest.fn(() => '/app') }, + BrowserWindow: Object.assign( + jest.fn().mockImplementation(() => new FakeBrowserWindow()), + { fromWebContents: jest.fn(() => null) } + ), + ipcMain: { + on: jest.fn(), + once: jest.fn(), + handle: jest.fn(), + removeHandler: jest.fn(), + removeListener: jest.fn(), + }, + screen, + webContents: { + getAllWebContents: jest.fn(() => []), + fromFrame: jest.fn(() => null), + }, +})); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +const flushPromises = (): Promise => + new Promise((resolve) => setImmediate(resolve)); + +const makeCallerWc = (id: number): WebContents => + ({ + id, + getURL: jest.fn(() => 'https://server.example/'), + isDestroyed: jest.fn(() => false), + }) as unknown as WebContents; + +// Load the SUT fresh (resets module-internal `activeCall`, `openWindowQueue`, +// `videoCallWindow`) and register all handlers. +const loadModule = async () => { + const mod = await import('../ipc'); + mod.startVideoCallWindowHandler(); + const openWindow = handleRegistry.get('video-call-window/open-window'); + if (!openWindow) throw new Error('open-window handler not registered'); + return { mod, openWindow }; +}; + +// Drive a single open and wait for the queued chain + async body to settle. +const open = async ( + openWindow: (...a: any[]) => any, + callerWc: WebContents, + url = 'https://meet.example/room' +) => { + const p = openWindow(callerWc, url, undefined); + await flushPromises(); + await flushPromises(); + await p; + await flushPromises(); +}; + +// Fire a captured event listener set (window or webContents). +const fire = ( + listeners: Record void>>, + event: string, + ...args: any[] +) => { + (listeners[event] ?? []).forEach((fn) => fn(...args)); +}; + +describe('videoCallWindow/ipc — PR #3359 hardening', () => { + let realSetTimeout: typeof setTimeout; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + handleRegistry.clear(); + createdWindows.length = 0; + rootWindowDeferred = null; + wcIdSeq = 1000; + realSetTimeout = global.setTimeout; + // Re-apply default impls after clearAllMocks wiped them. + select.mockImplementation(() => ({ + videoCallWindowState: { bounds: { x: 0, y: 0, width: 0, height: 0 } }, + isVideoCallWindowPersistenceEnabled: false, + isAutoOpenEnabled: false, + })); + getRootWindow.mockImplementation(() => + rootWindowDeferred + ? rootWindowDeferred.promise + : Promise.resolve(fakeRootWindow) + ); + // clearAllMocks() keeps mockReturnValue impls, so reset the root-window + // window-state methods to deterministic defaults for each test. + fakeRootWindow.isDestroyed.mockReturnValue(false); + fakeRootWindow.isMinimized.mockReturnValue(false); + }); + + // ------------------------------------------------------------------------- + // open — shared vs fallback partition (behavior #1) + // ------------------------------------------------------------------------- + it('open (shared): resolvable server -> partition persist:, isSharedSession true', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(42)); + + expect(createdWindows).toHaveLength(1); + // partition propagated to the loadFile handshake query + expect(createdWindows[0].loadFileQuery.partition).toBe( + 'persist:https://chat.example' + ); + + // isSharedSession=true is proven by restore firing setupServerViewDisplayMedia: + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + fire(createdWindows[0].listeners, 'closed'); + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + // restore resolved the server URL stripped of the persist: prefix + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://chat.example' + ); + + // restore also re-installs the permission handler (after awaiting the + // root window) so the live main webview's prompts come back. + await flushPromises(); + expect(setupServerViewPermissionHandler).toHaveBeenCalledTimes(1); + expect(setupServerViewPermissionHandler).toHaveBeenCalledWith( + serverWc, + fakeRootWindow + ); + }); + + it('open (fallback): unresolvable server -> partition persist:jitsi-session, isSharedSession false', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(7)); + + expect(createdWindows).toHaveLength(1); + expect(createdWindows[0].loadFileQuery.partition).toBe( + 'persist:jitsi-session' + ); + + // isSharedSession=false -> restore is a no-op + fire(createdWindows[0].listeners, 'closed'); + await flushPromises(); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + expect(setupServerViewPermissionHandler).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // restore — shared / fallback / destroyed / idempotent (behavior #3) + // Driven through the BrowserWindow 'closed' listener (the real call site of + // restoreServerViewHandler with the captured call). + // ------------------------------------------------------------------------- + it('restore (shared): live, non-destroyed server webContents -> setupServerViewDisplayMedia called once', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://a.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + fire(createdWindows[0].listeners, 'closed'); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); + + it('restore (fallback): isSharedSession=false -> setupServerViewDisplayMedia NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => false), + }); + fire(createdWindows[0].listeners, 'closed'); + + expect(getWebContentsByServerUrl).not.toHaveBeenCalled(); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (unresolved server): getWebContentsByServerUrl returns undefined -> NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://gone.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue(undefined); + fire(createdWindows[0].listeners, 'closed'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://gone.example' + ); + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (destroyed server): isDestroyed()===true -> NOT called', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://dead.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => true), + }); + fire(createdWindows[0].listeners, 'closed'); + + expect(setupServerViewDisplayMedia).not.toHaveBeenCalled(); + }); + + it('restore (idempotent): firing restore via closed + render-process-gone 3x -> called 3x, no throw', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://idem.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + // three independent restore invocations across the real call sites: + expect(() => { + fire(createdWindows[0].listeners, 'closed'); // window 'closed' + fire(createdWindows[0].webContents.listeners, 'render-process-gone'); // crash path + fire(createdWindows[0].listeners, 'closed'); // again + }).not.toThrow(); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(3); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + + // The permission-handler restore is awaited; flush, then it must mirror the + // display-media restore (once per invocation). + await flushPromises(); + expect(setupServerViewPermissionHandler).toHaveBeenCalledTimes(3); + expect(setupServerViewPermissionHandler).toHaveBeenCalledWith( + serverWc, + fakeRootWindow + ); + }); + + // ------------------------------------------------------------------------- + // render-process-gone restore path (behavior #3, crash entry) + // ------------------------------------------------------------------------- + it('restore via render-process-gone: shared session restores handler', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://crash.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + fire(createdWindows[0].webContents.listeners, 'render-process-gone'); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledTimes(1); + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); + + // ------------------------------------------------------------------------- + // null-out guard (behavior #5) + // A stale prior-window 'closed' teardown firing AFTER a fresh open must not + // wipe the freshly-set activeCall. We prove the fresh activeCall survives by + // showing a restore for the SECOND server still works after the first + // window's delayed teardown ran. + // ------------------------------------------------------------------------- + it('null-out guard: stale first-window teardown does not wipe fresh activeCall', async () => { + // First open -> server A + getServerUrlByWebContentsId.mockReturnValue('https://first.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1), 'https://meet.example/a'); + const firstWindow = createdWindows[0]; + + // Second open -> server B (fresh activeCall = B). A DIFFERENT conference URL + // so the same-conference focus short-circuit is not taken; the existing- + // window guard in openVideoCallWindow closes the first window synchronously. + getServerUrlByWebContentsId.mockReturnValue('https://second.example'); + await open(openWindow, makeCallerWc(2), 'https://meet.example/b'); + expect(createdWindows).toHaveLength(2); + const secondWindow = createdWindows[1]; + + // Now fire the FIRST window's 'closed' teardown. Its captured call (A) !== + // current activeCall (B), so the `if (activeCall === capturedCall)` guard + // must NOT null activeCall. We let its 50ms setTimeout run. + fire(firstWindow.listeners, 'closed'); + await new Promise((r) => realSetTimeout(r, 80)); + + // activeCall must still be B: firing the SECOND window's restore resolves + // server B (not A, not undefined). + const serverBWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverBWc); + fire(secondWindow.listeners, 'closed'); + + expect(getWebContentsByServerUrl).toHaveBeenLastCalledWith( + 'https://second.example' + ); + expect(setupServerViewDisplayMedia).toHaveBeenLastCalledWith(serverBWc); + }); + + // ------------------------------------------------------------------------- + // serialization / race (behavior #6) + // openWindowQueue chains opens; getRootWindow() is the first awaited point in + // the body. Gating it on a deferred proves the 2nd open's BrowserWindow is + // NOT constructed until the 1st open's body completes. + // ------------------------------------------------------------------------- + it('serialization: two unawaited opens construct BrowserWindows serially, last open wins', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://serial.example'); + const { openWindow } = await loadModule(); + + // Gate the FIRST open at getRootWindow. + const d1 = makeDeferred(); + rootWindowDeferred = d1; + + const p1 = openWindow(makeCallerWc(1), 'https://meet.example/a', undefined); + const p2 = openWindow(makeCallerWc(2), 'https://meet.example/b', undefined); + await flushPromises(); + await flushPromises(); + + // First open is blocked at getRootWindow; second open must not have run its + // body yet because the queue serializes on p1. No window constructed. + expect(createdWindows).toHaveLength(0); + + // Release the first open. From here the queue lets the second proceed; swap + // back to immediate resolution so the second open's getRootWindow resolves. + rootWindowDeferred = null; + d1.resolve(fakeRootWindow); + await flushPromises(); + await flushPromises(); + await p1; + await flushPromises(); + await flushPromises(); + await p2; + await flushPromises(); + + // Both completed; exactly two windows created (one per open), in order. + expect(createdWindows).toHaveLength(2); + // The LAST open wins: loadFile carried the second URL. + expect(createdWindows[1].loadFileQuery.url).toBe('https://meet.example/b'); + }); + + // ------------------------------------------------------------------------- + // cleanup restore path (behavior #2 gate + behavior #3 restore) + // cleanupVideoCallResources() -> cleanupVideoCallWindow() -> restore. + // ------------------------------------------------------------------------- + it('cleanupVideoCallResources triggers restore for shared session', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://cleanup.example'); + const { mod, openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1)); + + const serverWc = { isDestroyed: jest.fn(() => false) }; + getWebContentsByServerUrl.mockReturnValue(serverWc); + + mod.cleanupVideoCallResources(); + await flushPromises(); + + expect(setupServerViewDisplayMedia).toHaveBeenCalledWith(serverWc); + }); + + // ------------------------------------------------------------------------- + // open-in-main-window: focus the main window + emit 'navigate-to-route' + // ------------------------------------------------------------------------- + describe('open-in-main-window', () => { + const loadHandler = async () => { + await loadModule(); + const handler = handleRegistry.get( + 'video-call-window/open-in-main-window' + ); + if (!handler) + throw new Error('open-in-main-window handler not registered'); + return handler; + }; + + const makeServerWc = () => ({ + isDestroyed: jest.fn(() => false), + send: jest.fn(), + }); + + it("caller's server resolves -> focuses main window and emits navigate-to-route", async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(42), '/channel/general'); + + expect(getServerUrlByWebContentsId).toHaveBeenCalledWith(42); + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://chat.example' + ); + expect(fakeRootWindow.show).toHaveBeenCalledTimes(1); + expect(fakeRootWindow.focus).toHaveBeenCalledTimes(1); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/channel/general' + ); + }); + + it('falls back to the active server when the caller is unresolved', async () => { + getServerUrlByWebContentsId.mockReturnValue(undefined); + select.mockImplementation((sel: any) => + sel({ currentView: { url: 'https://active.example' } }) + ); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(99), '/admin/rooms'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://active.example' + ); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/admin/rooms' + ); + }); + + it("prefers the active call's origin server over the active view when the caller is unresolved", async () => { + // Open a call from server A so `activeCall.serverWebContentsId` is set. + getServerUrlByWebContentsId.mockReturnValue('https://origin.example'); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(50)); + + // The open-in-main-window caller (the standalone video window) does not + // resolve to a server; the active view is a *different* server. The + // handler must target the call's origin server, not the active view. + const handler = handleRegistry.get( + 'video-call-window/open-in-main-window' + ); + if (!handler) + throw new Error('open-in-main-window handler not registered'); + + getServerUrlByWebContentsId.mockImplementation((id: number) => + id === 50 ? 'https://origin.example' : undefined + ); + select.mockImplementation((sel: any) => + sel({ currentView: { url: 'https://other.example' } }) + ); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + await handler(makeCallerWc(999), '/channel/general'); + + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://origin.example' + ); + expect(getWebContentsByServerUrl).not.toHaveBeenCalledWith( + 'https://other.example' + ); + expect(serverWc.send).toHaveBeenCalledWith( + 'navigate-to-route', + '/channel/general' + ); + }); + + it('restores the main window when minimized', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue(makeServerWc()); + fakeRootWindow.isMinimized.mockReturnValue(true); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), '/channel/general'); + + expect(fakeRootWindow.restore).toHaveBeenCalledTimes(1); + }); + + it.each([ + '//evil.example', + 'https://evil.example', + '/\\evil.example', + 'channel/general', + ])( + 'rejects non-relative path %p -> no focus, no navigate', + async (badPath) => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const serverWc = makeServerWc(); + getWebContentsByServerUrl.mockReturnValue(serverWc); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), badPath); + + expect(serverWc.send).not.toHaveBeenCalled(); + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + } + ); + + it('no-ops safely when the target server webview is missing', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue(undefined); + + const handler = await loadHandler(); + await expect( + handler(makeCallerWc(1), '/channel/general') + ).resolves.toBeUndefined(); + + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + }); + + it('no-ops safely when the target server webview is destroyed', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + getWebContentsByServerUrl.mockReturnValue({ + isDestroyed: jest.fn(() => true), + send: jest.fn(), + }); + + const handler = await loadHandler(); + await handler(makeCallerWc(1), '/channel/general'); + + expect(fakeRootWindow.focus).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // close: 'video-call-window/close' (ipcMain.on) closes the sender's window + // ------------------------------------------------------------------------- + describe('close', () => { + const getCloseHandler = async () => { + await loadModule(); + const electron = (await import('electron')) as any; + const call = electron.ipcMain.on.mock.calls.find( + ([channel]: [string]) => channel === 'video-call-window/close' + ); + if (!call) throw new Error('close listener not registered'); + const fromWebContents = electron.BrowserWindow + .fromWebContents as jest.Mock; + fromWebContents.mockReset(); + return { + listener: call[1] as (event: { sender: any }) => void, + fromWebContents, + }; + }; + + it('closes the window resolved from the sender', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const win = { isDestroyed: jest.fn(() => false), close: jest.fn() }; + fromWebContents.mockReturnValue(win); + + listener({ sender: { hostWebContents: null } }); + + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('falls back to the host window for a webview-guest sender', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const hostWebContents = { id: 5 }; + const win = { isDestroyed: jest.fn(() => false), close: jest.fn() }; + // Guest sender resolves to null; the hostWebContents resolves to the window. + fromWebContents.mockReturnValueOnce(null).mockReturnValueOnce(win); + + listener({ sender: { hostWebContents } }); + + expect(fromWebContents).toHaveBeenNthCalledWith(2, hostWebContents); + expect(win.close).toHaveBeenCalledTimes(1); + }); + + it('does not close an already-destroyed window', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + const win = { isDestroyed: jest.fn(() => true), close: jest.fn() }; + fromWebContents.mockReturnValue(win); + + listener({ sender: { hostWebContents: null } }); + + expect(win.close).not.toHaveBeenCalled(); + }); + + it('no-ops safely when no window resolves', async () => { + const { listener, fromWebContents } = await getCloseHandler(); + fromWebContents.mockReturnValue(null); + + expect(() => + listener({ sender: { hostWebContents: null } }) + ).not.toThrow(); + }); + }); + + // ------------------------------------------------------------------------- + // same-conference reopen: focus the existing window instead of recreating + // ------------------------------------------------------------------------- + describe('same-conference reopen', () => { + it('focuses the existing window (no recreate, no close) for the same URL', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-x'); + expect(createdWindows).toHaveLength(1); + const win = createdWindows[0] as any; + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-x'); + + expect(createdWindows).toHaveLength(1); // not recreated + expect(win.close).not.toHaveBeenCalled(); + expect(win.show).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + }); + + it('restores first when the existing window is minimized', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-y'); + const win = createdWindows[0] as any; + win.isMinimized.mockReturnValue(true); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-y'); + + expect(win.restore).toHaveBeenCalledTimes(1); + expect(win.focus).toHaveBeenCalledTimes(1); + expect(createdWindows).toHaveLength(1); + }); + + it('recreates the window for a different conference URL', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-1'); + const first = createdWindows[0] as any; + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room-2'); + + expect(createdWindows).toHaveLength(2); + expect(first.close).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // external links from the conference webview -> system browser + // ------------------------------------------------------------------------- + describe('conference webview external links', () => { + const attachGuest = async (sharedSession = true) => { + getServerUrlByWebContentsId.mockReturnValue( + sharedSession ? 'https://chat.example' : undefined + ); + const { openWindow } = await loadModule(); + await open(openWindow, makeCallerWc(1), 'https://meet.example/room'); + + const guest = { + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + session: { + setDisplayMediaRequestHandler: jest.fn(), + setPermissionRequestHandler: jest.fn(), + }, + isDestroyed: jest.fn(() => false), + }; + // did-attach-webview is registered on the host window's webContents. + fire( + createdWindows[0].webContents.listeners, + 'did-attach-webview', + {}, + guest + ); + return guest; + }; + + it('routes http(s) popups to the system browser and denies the Electron window', async () => { + const guest = await attachGuest(); + expect(guest.setWindowOpenHandler).toHaveBeenCalledTimes(1); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'https://example.com/page' })).toEqual({ + action: 'deny', + }); + const { openExternal } = (await import( + '../../utils/browserLauncher' + )) as any; + expect(openExternal).toHaveBeenCalledWith('https://example.com/page'); + }); + + it('allows in-app (non-external) popups', async () => { + const guest = await attachGuest(); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'about:blank' })).toEqual({ action: 'allow' }); + expect(handler({ url: 'blob:https://meet.example/abc' })).toEqual({ + action: 'allow', + }); + }); + + it('denies dangerous popup schemes', async () => { + const guest = await attachGuest(); + const handler = guest.setWindowOpenHandler.mock.calls[0][0]; + + expect(handler({ url: 'javascript:alert(1)' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'file:///etc/passwd' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'data:text/html,' })).toEqual({ + action: 'deny', + }); + expect(handler({ url: 'smb://share/path' })).toEqual({ action: 'deny' }); + }); + + it('registers a will-navigate handler on the guest webview', async () => { + const guest = await attachGuest(); + expect(guest.on).toHaveBeenCalledWith( + 'will-navigate', + expect.any(Function) + ); + }); + + it('installs a media permission handler on the fallback (isolated) webview session', async () => { + const { handleMediaPermissionRequest } = (await import( + '../../ui/main/mediaPermissions' + )) as any; + const guest = await attachGuest(false); + + expect(guest.session.setPermissionRequestHandler).toHaveBeenCalledTimes( + 1 + ); + const permissionHandler = + guest.session.setPermissionRequestHandler.mock.calls[0][0]; + const callback = jest.fn(); + await permissionHandler({}, 'media', callback, { + mediaTypes: ['audio', 'video'], + }); + expect(handleMediaPermissionRequest).toHaveBeenCalledWith( + ['audio', 'video'], + expect.anything(), + 'initiateCall', + callback + ); + }); + + it('does NOT install a webview permission handler on a shared session', async () => { + const guest = await attachGuest(true); + expect(guest.session.setPermissionRequestHandler).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/videoCallWindow/preload/index.ts b/src/videoCallWindow/preload/index.ts index 3c28ab6f70..f7e2a913a3 100644 --- a/src/videoCallWindow/preload/index.ts +++ b/src/videoCallWindow/preload/index.ts @@ -1,8 +1,36 @@ import { contextBridge, ipcRenderer } from 'electron'; import './jitsiBridge'; +// Accept only in-app relative routes ("/..."), rejecting absolute URLs, +// protocol-relative URLs ("//host") and the backslash variant ("/\\host") so +// this can't become an open-redirect / arbitrary-navigation primitive. +const isRelativeRoute = (path: unknown): path is string => + typeof path === 'string' && + path.startsWith('/') && + !path.startsWith('//') && + !path.startsWith('/\\'); + // Expose any necessary APIs to the webview content contextBridge.exposeInMainWorld('videoCallWindow', { + // Navigate the main app window to an in-app route and bring it to the front. + // `path` is a server-relative route, e.g. "/channel/general". + openInMainWindow: (path: string) => { + if (isRelativeRoute(path)) { + ipcRenderer + .invoke('video-call-window/open-in-main-window', path) + .catch((error) => + console.warn('Video call window: open-in-main-window failed:', error) + ); + return; + } + console.warn( + 'Video call window: openInMainWindow rejected non-relative path:', + path + ); + }, + // Close the video call window. The renderer can't close a window the main + // process created, so the main process does it. + close: () => ipcRenderer.send('video-call-window/close'), // Add methods here if needed for communication with the main process requestScreenSharing: async () => { // Directly invoke the screen picker diff --git a/src/videoCallWindow/video-call-window.ts b/src/videoCallWindow/video-call-window.ts index eb55c0b979..6b24484452 100644 --- a/src/videoCallWindow/video-call-window.ts +++ b/src/videoCallWindow/video-call-window.ts @@ -616,7 +616,7 @@ const validateVideoCallUrl = (url: string): string => { } }; -const createWebview = (url: string): void => { +const createWebview = (url: string, partition?: string | null): void => { const container = document.getElementById('webview-container'); if (!container) { throw new Error('Webview container not found'); @@ -647,7 +647,10 @@ const createWebview = (url: string): void => { 'nodeIntegration,nativeWindowOpen=true' ); webview.setAttribute('allowpopups', 'true'); - webview.setAttribute('partition', 'persist:jitsi-session'); + // Partition is supplied by the main process (which owns the default). + if (partition) { + webview.setAttribute('partition', partition); + } webview.src = validatedUrl; webview.style.cssText = ` @@ -784,6 +787,7 @@ const start = async (): Promise => { const params = new URLSearchParams(window.location.search); let url = params.get('url'); + let partition = params.get('partition'); const autoOpenDevtools = params.get('autoOpenDevtools') === 'true'; state.shouldAutoOpenDevtools = autoOpenDevtools; @@ -804,6 +808,9 @@ const start = async (): Promise => { if (urlResult.autoOpenDevtools !== undefined) { state.shouldAutoOpenDevtools = urlResult.autoOpenDevtools; } + if (urlResult.partition) { + partition = urlResult.partition; + } } } catch (error) { console.error( @@ -825,7 +832,7 @@ const start = async (): Promise => { return; } - createWebview(url); + createWebview(url, partition); await invokeWithRetry('video-call-window/url-received', { maxAttempts: 2, diff --git a/yarn.lock b/yarn.lock index ed158c2751..0627e494ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,9 +64,9 @@ __metadata: linkType: hard "@adobe/css-tools@npm:^4.4.0": - version: 4.5.0 - resolution: "@adobe/css-tools@npm:4.5.0" - checksum: 10/a332050614f7e08928aba518ac65534621672590bdfc2079886e9ead90da78c7fe2498152c5083318e93ee909260ec13854c21ac77c24053f7d30c5d2d2adcc1 + version: 4.4.4 + resolution: "@adobe/css-tools@npm:4.4.4" + checksum: 10/0abd4715737877e5aa5d730d6ec2cffae2131102ddc8310ac5ba3f457ffb2ef453324dbb5b927e3cbc3f81bdd29ce485754014c6e64f4577a49540c76e26ac6b languageName: node linkType: hard @@ -80,7 +80,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" dependencies: @@ -91,17 +91,6 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.10.4": - version: 7.29.7 - resolution: "@babel/code-frame@npm:7.29.7" - dependencies: - "@babel/helper-validator-identifier": "npm:^7.29.7" - js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.1.1" - checksum: 10/84da552e51a55795a50b3589116edb2f9e368a647d266380683775f18effd9acd4521b0246bebd0b049a7f32af1f87b1e8475d3bcb665f876bd04ade8da99697 - languageName: node - linkType: hard - "@babel/compat-data@npm:^7.23.5, @babel/compat-data@npm:^7.28.6": version: 7.29.0 resolution: "@babel/compat-data@npm:7.29.0" @@ -370,13 +359,6 @@ __metadata: languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/helper-validator-identifier@npm:7.29.7" - checksum: 10/2efa42701eb05babf26dff3332109c9e5e1a3400a71fb9e68ee27af28235036a2a72c2494c04bdab3f909075f42a58b2e8271074372bc7f8e79ec02bd364d7a7 - languageName: node - linkType: hard - "@babel/helper-validator-option@npm:^7.22.15, @babel/helper-validator-option@npm:^7.23.5, @babel/helper-validator-option@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-option@npm:7.27.1" @@ -4577,23 +4559,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^9.0.0, @testing-library/dom@npm:^9.3.4": - version: 9.3.4 - resolution: "@testing-library/dom@npm:9.3.4" +"@testing-library/dom@npm:~10.4.1": + version: 10.4.1 + resolution: "@testing-library/dom@npm:10.4.1" dependencies: "@babel/code-frame": "npm:^7.10.4" "@babel/runtime": "npm:^7.12.5" "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.1.3" - chalk: "npm:^4.1.0" + aria-query: "npm:5.3.0" dom-accessibility-api: "npm:^0.5.9" lz-string: "npm:^1.5.0" + picocolors: "npm:1.1.1" pretty-format: "npm:^27.0.2" - checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 + checksum: 10/7f93e09ea015f151f8b8f42cbab0b2b858999b5445f15239a72a612ef7716e672b14c40c421218194cf191cbecbde0afa6f3dc2cc83dda93ff6a4fb0237df6e6 languageName: node linkType: hard -"@testing-library/jest-dom@npm:^6.4.8": +"@testing-library/jest-dom@npm:~6.9.1": version: 6.9.1 resolution: "@testing-library/jest-dom@npm:6.9.1" dependencies: @@ -4607,26 +4589,32 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^14.3.1": - version: 14.3.1 - resolution: "@testing-library/react@npm:14.3.1" +"@testing-library/react@npm:~16.3.2": + version: 16.3.2 + resolution: "@testing-library/react@npm:16.3.2" dependencies: "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^9.0.0" - "@types/react-dom": "npm:^18.0.0" peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef languageName: node linkType: hard -"@testing-library/user-event@npm:^14.5.2": - version: 14.6.1 - resolution: "@testing-library/user-event@npm:14.6.1" +"@testing-library/user-event@npm:~14.5.2": + version: 14.5.2 + resolution: "@testing-library/user-event@npm:14.5.2" peerDependencies: "@testing-library/dom": ">=7.21.4" - checksum: 10/34b74fff56a0447731a94b40d4cf246deb8dbc1c1e3aec93acd1c3377a760bb062e979f1572bb34ec164ad28ee2a391744b42d0d6d6cc16c4ce527e5e09610e1 + checksum: 10/49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040 languageName: node linkType: hard @@ -5045,7 +5033,7 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:^18.0.0, @types/react-dom@npm:~18.3.5": +"@types/react-dom@npm:~18.3.5": version: 18.3.7 resolution: "@types/react-dom@npm:18.3.7" peerDependencies: @@ -5859,12 +5847,12 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.1.3": - version: 5.1.3 - resolution: "aria-query@npm:5.1.3" +"aria-query@npm:5.3.0": + version: 5.3.0 + resolution: "aria-query@npm:5.3.0" dependencies: - deep-equal: "npm:^2.0.5" - checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 + dequal: "npm:^2.0.3" + checksum: 10/c3e1ed127cc6886fea4732e97dd6d3c3938e64180803acfb9df8955517c4943760746ffaf4020ce8f7ffaa7556a3b5f85c3769a1f5ca74a1288e02d042f9ae4e languageName: node linkType: hard @@ -5875,7 +5863,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -6647,18 +6635,6 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.2, call-bind@npm:^1.0.5, call-bind@npm:^1.0.9": - version: 1.0.9 - resolution: "call-bind@npm:1.0.9" - dependencies: - call-bind-apply-helpers: "npm:^1.0.2" - es-define-property: "npm:^1.0.1" - get-intrinsic: "npm:^1.3.0" - set-function-length: "npm:^1.2.2" - checksum: 10/25b1a98d6158f0adf9fface594ca82be4e3ed481d8ff7f36ad1fccb0c8377e38c6a04ff3248693723222d378677e93077c739defc8a6741c82b7e00bcee1245d - languageName: node - linkType: hard - "call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" @@ -7698,32 +7674,6 @@ __metadata: languageName: node linkType: hard -"deep-equal@npm:^2.0.5": - version: 2.2.3 - resolution: "deep-equal@npm:2.2.3" - dependencies: - array-buffer-byte-length: "npm:^1.0.0" - call-bind: "npm:^1.0.5" - es-get-iterator: "npm:^1.1.3" - get-intrinsic: "npm:^1.2.2" - is-arguments: "npm:^1.1.1" - is-array-buffer: "npm:^3.0.2" - is-date-object: "npm:^1.0.5" - is-regex: "npm:^1.1.4" - is-shared-array-buffer: "npm:^1.0.2" - isarray: "npm:^2.0.5" - object-is: "npm:^1.1.5" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.4" - regexp.prototype.flags: "npm:^1.5.1" - side-channel: "npm:^1.0.4" - which-boxed-primitive: "npm:^1.0.2" - which-collection: "npm:^1.0.1" - which-typed-array: "npm:^1.1.13" - checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f - languageName: node - linkType: hard - "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -7808,6 +7758,13 @@ __metadata: languageName: node linkType: hard +"dequal@npm:^2.0.3": + version: 2.0.3 + resolution: "dequal@npm:2.0.3" + checksum: 10/6ff05a7561f33603df87c45e389c9ac0a95e3c056be3da1a0c4702149e3a7f6fe5ffbb294478687ba51a9e95f3a60e8b6b9005993acd79c292c7d15f71964b6b + languageName: node + linkType: hard + "detect-browsers@npm:~6.1.0": version: 6.1.0 resolution: "detect-browsers@npm:6.1.0" @@ -8446,23 +8403,6 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.1.3": - version: 1.1.3 - resolution: "es-get-iterator@npm:1.1.3" - dependencies: - call-bind: "npm:^1.0.2" - get-intrinsic: "npm:^1.1.3" - has-symbols: "npm:^1.0.3" - is-arguments: "npm:^1.1.1" - is-map: "npm:^2.0.2" - is-set: "npm:^2.0.2" - is-string: "npm:^1.0.7" - isarray: "npm:^2.0.5" - stop-iteration-iterator: "npm:^1.0.0" - checksum: 10/bc2194befbe55725f9489098626479deee3c801eda7e83ce0dff2eb266a28dc808edb9b623ff01d31ebc1328f09d661333d86b601036692c2e3c1a6942319433 - languageName: node - linkType: hard - "es-iterator-helpers@npm:^1.0.12": version: 1.2.2 resolution: "es-iterator-helpers@npm:1.2.2" @@ -9594,7 +9534,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -10394,17 +10334,7 @@ __metadata: languageName: node linkType: hard -"is-arguments@npm:^1.1.1": - version: 1.2.0 - resolution: "is-arguments@npm:1.2.0" - dependencies: - call-bound: "npm:^1.0.2" - has-tostringtag: "npm:^1.0.2" - checksum: 10/471a8ef631b8ee8829c43a8ab05c081700c0e25180c73d19f3bf819c1a8448c426a9e8e601f278973eca68966384b16ceb78b8c63af795b099cd199ea5afc457 - languageName: node - linkType: hard - -"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -10609,7 +10539,7 @@ __metadata: languageName: node linkType: hard -"is-map@npm:^2.0.2, is-map@npm:^2.0.3": +"is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" checksum: 10/8de7b41715b08bcb0e5edb0fb9384b80d2d5bcd10e142188f33247d19ff078abaf8e9b6f858e2302d8d05376a26a55cd23a3c9f8ab93292b02fcd2cc9e4e92bb @@ -10698,7 +10628,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": +"is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -10710,14 +10640,14 @@ __metadata: languageName: node linkType: hard -"is-set@npm:^2.0.2, is-set@npm:^2.0.3": +"is-set@npm:^2.0.3": version: 2.0.3 resolution: "is-set@npm:2.0.3" checksum: 10/5685df33f0a4a6098a98c72d94d67cad81b2bc72f1fb2091f3d9283c4a1c582123cd709145b02a9745f0ce6b41e3e43f1c944496d1d74d4ea43358be61308669 languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -10733,7 +10663,7 @@ __metadata: languageName: node linkType: hard -"is-string@npm:^1.0.7, is-string@npm:^1.1.1": +"is-string@npm:^1.1.1": version: 1.1.1 resolution: "is-string@npm:1.1.1" dependencies: @@ -13005,16 +12935,6 @@ __metadata: languageName: node linkType: hard -"object-is@npm:^1.1.5": - version: 1.1.6 - resolution: "object-is@npm:1.1.6" - dependencies: - call-bind: "npm:^1.0.7" - define-properties: "npm:^1.2.1" - checksum: 10/4f6f544773a595da21c69a7531e0e1d6250670f4e09c55f47eb02c516035cfcb1b46ceb744edfd3ecb362309dbccb6d7f88e43bf42e4d4595ac10a329061053a - languageName: node - linkType: hard - "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -13546,7 +13466,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 @@ -14452,7 +14372,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3, regexp.prototype.flags@npm:^1.5.4": +"regexp.prototype.flags@npm:^1.5.3, regexp.prototype.flags@npm:^1.5.4": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -14733,10 +14653,10 @@ __metadata: "@rollup/plugin-json": "npm:~6.1.0" "@rollup/plugin-node-resolve": "npm:~15.2.3" "@rollup/plugin-replace": "npm:~5.0.5" - "@testing-library/dom": "npm:^9.3.4" - "@testing-library/jest-dom": "npm:^6.4.8" - "@testing-library/react": "npm:^14.3.1" - "@testing-library/user-event": "npm:^14.5.2" + "@testing-library/dom": "npm:~10.4.1" + "@testing-library/jest-dom": "npm:~6.9.1" + "@testing-library/react": "npm:~16.3.2" + "@testing-library/user-event": "npm:~14.5.2" "@types/archiver": "npm:~7.0.0" "@types/dompurify": "npm:~3.2.0" "@types/electron-devtools-installer": "npm:~2.2.5" @@ -14804,6 +14724,7 @@ __metadata: ts-node: "npm:~10.9.2" typescript: "npm:~5.7.3" xvfb-maybe: "npm:~0.2.1" + yaml: "npm:^1.10.2" dependenciesMeta: fsevents: optional: true @@ -15141,16 +15062,6 @@ __metadata: languageName: node linkType: hard -"side-channel-list@npm:^1.0.1": - version: 1.0.1 - resolution: "side-channel-list@npm:1.0.1" - dependencies: - es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.4" - checksum: 10/3499671cd52adaee739eac1e14d07530b8e3530192741aeb05e7fe4ad1b51d1368ceea2cd3c21b0f62b05410a5c70a7c4d997ba4b143303ef73d0c65dfd1c252 - languageName: node - linkType: hard - "side-channel-map@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-map@npm:1.0.1" @@ -15176,19 +15087,6 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4": - version: 1.1.1 - resolution: "side-channel@npm:1.1.1" - dependencies: - es-errors: "npm:^1.3.0" - object-inspect: "npm:^1.13.4" - side-channel-list: "npm:^1.0.1" - side-channel-map: "npm:^1.0.1" - side-channel-weakmap: "npm:^1.0.2" - checksum: 10/5fa6393ff6ad25d8b4a38e9ba095481e498c8ebe5ab78481c1455146255a3d18ca37a6f936595cc671a6149134cdc295bbd2fa017620bdc73cbc7380634fa2fc - languageName: node - linkType: hard - "side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" @@ -15487,7 +15385,7 @@ __metadata: languageName: node linkType: hard -"stop-iteration-iterator@npm:^1.0.0, stop-iteration-iterator@npm:^1.1.0": +"stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" dependencies: @@ -16835,7 +16733,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -16869,7 +16767,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -16881,21 +16779,6 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13": - version: 1.1.22 - resolution: "which-typed-array@npm:1.1.22" - dependencies: - available-typed-arrays: "npm:^1.0.7" - call-bind: "npm:^1.0.9" - call-bound: "npm:^1.0.4" - for-each: "npm:^0.3.5" - get-proto: "npm:^1.0.1" - gopd: "npm:^1.2.0" - has-tostringtag: "npm:^1.0.2" - checksum: 10/59b0383347e2f3b0bc5be570c2dfae551b172a9c83e0a6b03c6e17401d6161dfa1d912c7657062fe9add254a0d3c25ef70593dbaec8fefa8714715ff69e0a3fc - languageName: node - linkType: hard - "which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19": version: 1.1.20 resolution: "which-typed-array@npm:1.1.20" @@ -17141,6 +17024,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^1.10.2": + version: 1.10.3 + resolution: "yaml@npm:1.10.3" + checksum: 10/e2ef2feb92c708138f016c69777a0f1e45f6d3c5e7cbcda30807a98a37eda2e008bd4fa57352b043c65245a4c799d0c99d1f9b3425de40e70929e26d2ea38215 + languageName: node + linkType: hard + "yaml@npm:^2.2.2": version: 2.8.2 resolution: "yaml@npm:2.8.2"