snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

6
packages/twenty-companion/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.yarn/
dist/
release/
.env
*.log

View File

@@ -0,0 +1,174 @@
# Twenty
See upcoming meetings, record calls, and open transcripts and summaries in your Twenty workspace.
Twenty brings your calendar and recording controls to your desktop. Join upcoming meetings, capture unscheduled conversations, and keep recordings connected to the workspace you choose. A native menu keeps your next meeting and recording controls within reach while you work.
## What you can do
- **See what's next.** Home shows your next three calendar events, with an option to see more. The native menu shows meeting titles, times, and shortcuts to join.
- **Join on time.** Open a meeting yourself or let auto-join open its link when the event starts. You can skip auto-join for an individual event; the meeting provider may still require confirmation to enter.
- **Record scheduled and unscheduled conversations.** Twenty records conversations, including Discord calls, using your microphone and system audio. A separately installed Call Recorder can continue handling calendar meetings with a bot.
- **Stay in control.** Start, pause, resume, and finish desktop recordings. Automatic recording of detected calls is optional and off by default.
- **Continue in Twenty.** See your five most recent recordings, open a recording directly, or choose **View all in Twenty** to browse and search your history in the workspace.
## Requirements and setup
This is an Electron development build for **Apple Silicon Macs running macOS 14.2 or newer**. Windows and Linux builds are planned; recording and packaging for those platforms are not implemented yet.
1. A workspace administrator installs the independent Desktop Recorder integration and configures Recall. See [Workspace setup](#workspace-setup).
2. Open Twenty, enter your workspace URL, and sign in through Twenty in your browser. After authorization, Twenty comes to the foreground automatically; the browser callback tab can be closed.
3. Grant microphone, system audio, and meeting-detection permissions in the guided setup.
4. Connect a calendar in Twenty to see upcoming events, or choose **New recording** to capture an unscheduled conversation.
The app uses your connected workspace's calendar and recording settings. Desktop recording requires an internet connection; AI summaries also require AI to be configured in Twenty. Let participants know when you record.
Open the main window from Raycast, Finder, the Dock, or **⌘⇧Space**. Closing the window keeps the app running in the menu bar; quitting stops it.
## Desktop flow
- Setup follows Connect → Permissions → Ready. Microphone, system audio, and meeting detection each have their own explanation and enable action. Continue requires all three permissions; completing setup never starts recording.
- Permission denial opens the corresponding system pane. Returning to the app refreshes permissions without interrupting capture.
- General settings include System/Light/Dark appearance, launch at login, and the meeting countdown beside the tray icon. Hiding the countdown keeps the recording and paused indicators visible. Appearance also applies to native Electron UI. Preferences persist locally; older installations retain their choices and receive defaults for new controls.
- Meetings settings include auto-join, automatic desktop recording, and call-detection notifications. Turning off call-detection notifications leaves detection and recording-interruption alerts active. The Speaker tags row reflects meeting-detection permission. Calendar settings opens the connected Twenty workspace's accounts page; Recording permissions opens the existing full permission checklist.
- The top-left workspace dropdown displays the workspace logo when available, falls back to the Twenty icon, and includes Settings, refresh, opening Twenty, and disconnect. The header has no Home tag. The dropdown supports keyboard navigation, Escape, and dismissal outside the menu.
- Home shows the next three events grouped by local calendar day, with large day numbers and meeting time ranges, followed by the five most recent recordings. See more expands the event list when additional meetings are available. View all in Twenty opens Call Recordings in the connected workspace. Clicking any recording opens its record directly in that workspace, including processing or failed recordings. Finishing capture keeps the current page open and updates the recording status in the list.
- Pause and Resume control the same Recall recording. The timer excludes paused time. Finish works while paused; failed pause/resume requests retain the actual previous status.
- The main window uses normal native window controls and remains visible after losing focus. The native meeting menu shows the next three events grouped by local day, with titles and time ranges, Now/starting-soon context, per-meeting Join and Skip auto-join actions, and calendar recording policy. A direct Join action appears when exactly one meeting is imminent and no call is active. View calendar opens Home. macOS 14.4+ uses native sublabels; other platforms keep timing inline. Launch at login remains in Settings. File → Meeting menu (⌘/Ctrl⇧M) opens the same menu when the tray icon is hard to reach. Native actions use the same recording and meeting commands as the main window. Menu updates wait until an open menu closes to preserve keyboard selection.
- Controls, cards, tags, avatars, and interface typography use Twenty UI directly in both themes. Emphasized actions use its blue primary accent. All typography uses Inter. Native popover positioning, desktop layouts, and the onboarding cloud are desktop-specific compositions using shared tokens.
- Transcripts and summaries open in Twenty’s standard Call Recording view. This iteration does not add Granola's note editor, AI chat, or folders.
The behavior reference is [Granola's desktop overview](https://docs.granola.ai/help-center/getting-started/granola-101) and [pause/resume interaction](https://docs.granola.ai/help-center/taking-notes/transcription), plus the live permissions screen. Desktop auto-recording is controlled by Twenty’s local preferences.
## Recording behavior
- **Optional calendar bot:** A separately installed Call Recorder can schedule bots independently. Twenty does not require it and uses its own backend, while both apps store results in Twenty's standard `callRecording` object.
- **Auto-join:** enabled by default. The companion opens the user's meeting link at its start time. A provider's pre-join or waiting-room confirmation may still require the user. It never turns the camera or microphone on. Stale agendas, overlapping meetings, an active call, and per-meeting skips prevent an automatic opening.
- **Desktop capture:** Recall's native SDK records supported detected calls. Automatic desktop recording is opt-in. Scheduled meetings on platforms that have no calendar bot can use desktop capture when automatic recording is enabled locally.
- **Discord and other audio:** choose **New recording**, then **Finish**. This captures system audio and microphone; Discord detection and participant names are not promised. Avoid playing unrelated audio during capture. Audio-only capture has no video.
- The desktop and authenticated backend reserve identified scheduled bot meetings for the bot. An unrelated calendar event does not block manual system-audio recording. Manual capture records all computer audio, so do not start it for a meeting already being recorded by its calendar bot.
- Participants should be told when recording. Capture requires macOS permissions. A network loss stops capture; restart it after reconnecting. There is no offline recording or automatic restart after a network loss.
- Completion, media import, transcript generation, and summaries use the independent Desktop Recorder integration. Desktop sessions additionally retain their owner and Recall upload ID. Missed completion webhooks are recovered by a bounded maintenance job. The shared credit-charging path has unresolved issues listed under [Verification boundary](#verification-boundary).
## Architecture
```text
Electron renderer → validated IPC → Electron main process
├─ macOS Keychain-backed OAuth storage
├─ Twenty OAuth discovery + PKCE + loopback callback
└─ Recall Desktop SDK ← short-lived upload token
Desktop Recorder authenticated /companion/desktop route
├─ personal calendar channels and agenda (runAs: user)
├─ recording ownership and active calendar-bot checks
└─ Recall SDK upload creation (server-held API key)
Recall signed webhooks → Desktop Recorder durable importer → CallRecording
```
No Recall API key or Twenty OAuth token is exposed to the renderer. OAuth tokens are encrypted using Electron `safeStorage`; only the main process can read them. Function domains and frontend workspace links are discovered from the authenticated Twenty server, including hosted isolated function domains.
## Development
Use Node 24 and the repository's Yarn version. Desktop is the `@twentyhq/companion` Yarn workspace and `twenty-companion` Nx project. Install from the repository root; the root lockfile owns desktop, Twenty UI, and their dependencies. Do not install a separate desktop dependency tree.
```sh
yarn install --immutable
yarn workspace @twentyhq/companion dev
```
`dev` shares the production Electron build configuration and watches main/preload dependencies. A successful rebuild requests a graceful Electron restart; capture startup, active or paused recording, and stopping defer it until idle. Repeated rebuilds use the latest successful output. Failed compilations leave the running app untouched. Renderer edits continue to use Vite. Quitting Electron closes the watcher and Vite server.
`yarn workspace @twentyhq/companion preview` serves the renderer at `http://127.0.0.1:4317`. Use `?preview` for an interactive sample Home, `?preview=welcome` for the full setup, `?preview=permissions` for permission setup, `?preview=denied` for denial, or `?preview=recording` for active capture controls. The native menu is available only in the desktop app. Preview actions simulate state transitions; no audio is captured, no workspace writes or external calls are made. This mode is excluded from production. Stop the preview before running the desktop `dev` command, which uses the same port.
From the repository root:
```sh
yarn workspace @twentyhq/companion typecheck
yarn workspace @twentyhq/companion test
yarn workspace @twentyhq/companion build
yarn workspace @twentyhq/companion package
"packages/twenty-companion/release/Twenty-darwin-arm64/Twenty.app/Contents/MacOS/Twenty" --smoke
```
The equivalent Nx project targets are `twenty-companion:build`, `:typecheck`, and `:test`. Native packaging targets are uncached and run only when requested. Desktop imports the current Twenty UI workspace source through Vite aliases; its explicit workspace dependency provides the shared dependency graph.
Root installs keep native install scripts disabled. `dev`, `start`, `package`, and `package:archive` run `native:setup`, which downloads missing Electron and Recall binaries on Apple Silicon macOS. Typecheck, unit tests, renderer preview, and JavaScript builds do not require these downloads.
Packaging stages only `dist`, native image assets, a runtime manifest, and Recall's resolved runtime dependencies in a temporary directory. It never packages the workspace `node_modules` tree. Recall's binaries and frameworks remain outside ASAR; the staging directory is removed after packaging.
The smoke command uses a temporary user-data directory, checks secure IPC in the single main window and construction of the native tray menu, initializes the native Recall SDK without requesting recording permissions, and exits. It does not record audio or launch a meeting bot. Quit a running normal copy before using it.
The package command verifies Recall's executable is outside the ASAR archive. The generated `.app` is a local unsigned development build, not a notarized distribution. Apple signing, notarization, a release update channel, and supported-provider recording checks are required before distributing to users. Follow Recall's current signing instructions for its embedded executable and frameworks.
## Workspace setup
The backend is [Desktop Recorder](../twenty-apps/public/companion/README.md), an independently installable Twenty application. It owns `/companion/desktop`, the `companionSession` ownership field, import/recovery functions, its summarizer agent, and Recall credentials. Follow its [setup instructions](../twenty-apps/public/companion/SETUP.md). Call Recorder is optional.
The desktop reuses the CLI OAuth client and browser sign-in flow used by `twenty remote:add`, with PKCE and a local callback. Tokens stay in the desktop's secure store. After sign-in, the desktop checks the backend and offers installation if it is missing. An administrator installs the integration in each target workspace. Existing completed desktop recordings can be transferred using the integration's one-time migration script without replacing their media or record IDs.
## Code structure
- `src/main/services/Companion.ts` owns workspace and recording state. Only the main process holds credentials and Recall upload tokens.
- `src/main/services/TwentyClient.ts` discovers the workspace endpoint, refreshes OAuth credentials, and validates backend responses. Credential writes are serialized so sign-out wins over an in-flight refresh.
- `src/shared/types.ts` defines the IPC commands, response schemas, and state types. Settings commands carry patches; the main process merges and persists them in order.
- `src/renderer/components/CompanionApp.tsx` composes the shell. `Onboarding`, `Home`, `RecordingList`, and `Settings` own their screen layout and local UI state, using the same shared controls.
- `HalftoneBackground` renders the bundled cloud photo through the image-only Halftone Studio shader in `halftone.ts`. The image covers the full window with centered cropping. Dash spacing stays fixed in CSS pixels while power and a soft contrast wave animate, with sparse drifting particles. It stops rendering when hidden or reduced motion is enabled, releases GPU resources on unmount, and leaves the normal background if WebGL is unavailable.
- The Desktop Recorder integration owns persistence, processing, and its Twenty billing calls. Lifecycle updates use conditional status filters; a stale webhook or recovery job cannot move a completed recording back to processing.
Capture controls remain visible until Recall acknowledges a stop. Network interruption triggers an explicit stop; a failed stop leaves the controls available for retry. Quitting waits for pending capture setup and releases SDK listeners.
## Verification boundary
Local checks have covered unit tests, TypeScript, the app manifest build, renderer navigation, packaging, and native SDK startup. A desktop recording was verified on September 7–8, 2026: Recall completed the audio and transcript, and the local **Apple** workspace stored the recording, transcript, and summary. This validates that desktop recording path; it does not validate every call provider or calendar-bot behavior.
The backend test suite covers desktop provisioning, webhook processing, recovery, import, summaries, and charge calculation. These tests mock billing. The local Twenty server has billing disabled, so the recording test did **not** verify a workspace credit deduction.
Desktop Recorder uses the existing SDK billing path and does not change platform pricing, credit accounting, or billing infrastructure. Validate deductions and exhausted-credit behavior in a billing-enabled test workspace before production distribution. Automatic retries of ambiguous charges are intentionally excluded from this delivery.
Signing, notarization, update delivery, and supported-provider recording checks are also required before distribution.
References: [Recall Desktop SDK](https://docs.recall.ai/docs/desktop-sdk), [platform support](https://docs.recall.ai/docs/dsdk-supported-platforms), [system audio capture](https://docs.recall.ai/docs/adhoc-meetings-in-person-meetings), [Muesli inspiration](https://github.com/recallai/muesli-public).
## Local end-to-end testing
The local test server runs in Docker as `twenty-app-dev-test` at
`http://localhost:2021`. Its seeded workspace is **Apple**; both the login email
and password are `tim@apple.dev`. Seeded meetings are sample data, not a live
Google or Microsoft calendar connection.
From `packages/twenty-apps/public/companion`:
```sh
yarn twenty -r companion-local apply
```
The `companion-local` remote targets port 2021. It is separate from the normal
local development database. The local server administrator must claim the
Desktop Recorder registration for Apple so its server webhook can resolve the
installed function. Configure Recall in that registration's server variables.
In Twenty Settings, disconnect the cloud workspace, enter
`http://localhost:2021`, and authorize the local Apple workspace. For the first
live test, complete permission setup, choose **New recording**, speak for about
20 seconds, then choose **Finish**. Check Recordings and the Call Recordings object in local
Twenty for processing and transcript arrival. Keep Docker and the webhook
tunnel running until processing finishes.
The Recall callback uses a temporary Cloudflare tunnel to port 2021 and the
path `/webhooks/server/2f955a7f-9d03-44c6-bb12-fbc8f6e33b07`. Restarting a quick
tunnel changes its URL; update the dedicated Recall local-test webhook endpoint
when that happens. Stop or disable that endpoint when the local test environment
is retired. Calendar bot testing additionally requires a real connected
calendar and a meeting you intend to record.
## Renderer messages
The renderer loads its compiled English Lingui catalog once in `src/renderer/main.tsx`.
Use `i18n._('Message')` where a label is declared; translate each branch of a
conditional separately so extraction can see every message. Pass translated
strings into presentation components. Typed recording notices are translated in
`notice-message.ts`, including named interpolation values.
Run `yarn lingui:extract` after changing copy. Build, preview, and packaging
commands compile the catalog. English is the only shipping locale for now.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" viewBox="16 16 64 64">
<path fill="black" d="M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 19.25 35.746094 "/>
<path fill="black" d="M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

View File

@@ -0,0 +1,26 @@
# Workspace installation and personal sign-in
1. A workspace administrator installs **Desktop Recorder** in Twenty Settings → Apps and configures its recording provider.
2. Its Settings tab includes **Download Twenty for macOS**. Members can also find the same read-only instructions in Settings → Desktop app, without application administration permission.
3. Each member installs **Twenty**, enters their workspace URL, and authorizes the existing Desktop Recorder OAuth client in their browser.
4. Twenty checks the backend recording configuration before saving the desktop connection. It never creates or installs another application during desktop sign-in.
Desktop authentication is a public OAuth client using loopback redirects and PKCE. Access and refresh tokens retain the authorizing user and workspace identity. Disconnect revokes the personal grant; it does not uninstall the workspace application.
## Download distribution
No desktop installer was found among Twenty's published GitHub releases on 2026-09-08. Do not use an assumed release URL.
Run `yarn package:archive` in `packages/twenty-companion` to create `release/Twenty-0.1.0-macos-arm64.zip` and its SHA-256 checksum. This is a development build. Production distribution still needs Developer ID signing and notarization before uploading to a release host, such as the existing `twentyhq/twenty` GitHub Releases.
Once the verified installer is hosted, set **macOS download URL** (`DESKTOP_DOWNLOAD_URL`) in Desktop Recorder's settings to its HTTPS download URL. An empty or invalid URL disables the download button. The current build supports Apple silicon and macOS 14.2+, not Windows or Intel Macs.
Deploy the updated Twenty server/frontend and sync or upgrade Desktop Recorder before distributing the updated desktop app. Registration creation and manifest updates configure its existing OAuth client as a public native client. Earlier OAuth-only registrations are not deleted automatically; reconnect through the installed integration before an administrator removes obsolete registrations.
## Architecture context
[Martmull app archi, September 8](https://app.fireflies.ai/view/01M20GB6PQH23MN9ZZCRN94Z0K) confirmed one installed app with separate user connections, ownership associated with each workspace member, and a two-user verification. General user-level app settings can follow later.
Linear connection providers authorize Twenty to call an external service. Desktop authorization runs in the other direction: Electron calls Twenty. Reusing the installed app registration avoids empty apps without creating a provider that has no external service to connect to. Provider import loops remain app-specific when integrating external services.
Personal desktop agenda and upload ownership checks do not themselves establish CRM row-level privacy. CRM record visibility follows workspace roles. User/workspace connection visibility must not be presented as a guarantee of private CRM records without explicit row-level permission enforcement and multi-user verification.

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: http://localhost:* http://127.0.0.1:*; connect-src 'self' ws://127.0.0.1:4317; object-src 'none'; base-uri 'none'"
/>
<title>Twenty</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/renderer/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
import { defineConfig } from '@lingui/conf';
export default defineConfig({
sourceLocale: 'en',
locales: ['en'],
catalogs: [
{
path: '<rootDir>/src/renderer/locales/{locale}',
include: ['<rootDir>/src/renderer'],
exclude: ['**/__tests__/**', '**/locales/**', '**/preview.ts'],
},
],
format: 'po',
compileNamespace: 'ts',
});

View File

@@ -0,0 +1,58 @@
{
"name": "@twentyhq/companion",
"productName": "Twenty",
"version": "0.1.0",
"private": true,
"description": "See upcoming meetings, record calls, and open transcripts and summaries in your Twenty workspace.",
"license": "AGPL-3.0",
"type": "module",
"main": "dist/main.cjs",
"packageManager": "yarn@4.13.0",
"engines": {
"node": "^24.5.0"
},
"scripts": {
"dev": "yarn native:setup && yarn lingui:compile && node scripts/dev.mjs",
"build": "yarn lingui:compile && node scripts/build.mjs",
"start": "yarn native:setup && electron .",
"preview": "yarn lingui:compile && vite --host 127.0.0.1 --port 4317",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"package": "yarn native:setup && yarn lingui:compile && node scripts/package.mjs",
"package:archive": "yarn native:setup && yarn lingui:compile && node scripts/archive.mjs",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile",
"native:setup": "node scripts/setup-native.mjs"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource/inter": "^5.2.8",
"@lingui/core": "5.9.5",
"@recallai/desktop-sdk": "2.0.29",
"clsx": "^2.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"twenty-ui": "workspace:*",
"zod": "^4.1.0"
},
"devDependencies": {
"@electron/packager": "^19.0.0",
"@lingui/cli": "5.9.5",
"@lingui/conf": "5.9.5",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.7.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"electron": "44.1.0",
"esbuild": "^0.28.1",
"jsdom": "^30.0.1",
"monaco-editor": "^0.52.2",
"sass": "^1.83.0",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vite-plugin-svgr": "^4.3.0",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,58 @@
{
"name": "twenty-companion",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "packages/twenty-companion/src",
"implicitDependencies": ["twenty-ui"],
"targets": {
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [],
"options": {
"cwd": "packages/twenty-companion",
"command": "yarn build"
},
"inputs": ["default", "^default"],
"outputs": ["{projectRoot}/dist"]
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [],
"options": {
"cwd": "packages/twenty-companion",
"command": "yarn typecheck"
},
"inputs": ["default", "^default"]
},
"test": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [],
"options": {
"cwd": "packages/twenty-companion",
"command": "yarn test"
},
"inputs": ["default", "^default"]
},
"package": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [],
"options": {
"cwd": "packages/twenty-companion",
"command": "yarn package"
}
},
"package:archive": {
"executor": "nx:run-commands",
"cache": false,
"dependsOn": [],
"options": {
"cwd": "packages/twenty-companion",
"command": "yarn package:archive"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="96px" height="96px" viewBox="0 0 96 96" version="1.1">
<defs>
<clipPath id="clip1">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip2">
<path d="M 19 26 L 48 26 L 48 45 L 19 45 Z M 19 26 "/>
</clipPath>
<clipPath id="clip3">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip4">
<path d="M 19 26 L 77 26 L 77 70 L 19 70 Z M 19 26 "/>
</clipPath>
<clipPath id="clip5">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
</defs>
<g id="surface1">
<g clip-path="url(#clip1)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 96 0 L 0 0 L 0 96 L 96 96 Z M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
<g clip-path="url(#clip2)" clip-rule="nonzero">
<g clip-path="url(#clip3)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 19.25 35.746094 "/>
</g>
</g>
<g clip-path="url(#clip4)" clip-rule="nonzero">
<g clip-path="url(#clip5)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,27 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import './package.mjs';
const { version } = JSON.parse(await readFile('package.json', 'utf8'));
const outputDirectory = resolve(
process.env.TWENTY_COMPANION_OUTPUT_DIR ?? 'release',
);
const archive = resolve(outputDirectory, `Twenty-${version}-macos-arm64.zip`);
execFileSync('ditto', [
'-c',
'-k',
'--sequesterRsrc',
'--keepParent',
resolve(outputDirectory, 'Twenty-darwin-arm64/Twenty.app'),
archive,
]);
const checksum = createHash('sha256');
for await (const chunk of createReadStream(archive)) checksum.update(chunk);
await writeFile(
`${archive}.sha256`,
`${checksum.digest('hex')} ${basename(archive)}\n`,
);
console.log(archive);

View File

@@ -0,0 +1,6 @@
import { build } from 'esbuild';
import { build as buildRenderer } from 'vite';
import { electronBuildOptions } from './electron-build.mjs';
await build(electronBuildOptions);
await buildRenderer();

View File

@@ -0,0 +1,97 @@
import { spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { createServer } from 'vite';
import { context } from 'esbuild';
import electron from 'electron';
import { electronBuildOptions } from './electron-build.mjs';
const server = await createServer();
let watcher;
let child;
let stopping = false;
let building = false;
let launchPending = false;
const stop = async (code = 0) => {
if (stopping) return;
stopping = true;
child?.kill('SIGTERM');
await watcher?.dispose();
await server.close();
process.exit(code);
};
const launch = () => {
if (building) {
launchPending = true;
return;
}
launchPending = false;
let restarting = false;
child = spawn(electron, ['.'], {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: { ...process.env, TWENTY_COMPANION_DEV_URL: 'http://127.0.0.1:4317' },
});
child.on('message', (message) => {
if (message?.type === 'development-restarting') restarting = true;
});
child.on('error', (error) => {
console.error('Could not launch Electron.', error);
void stop(1);
});
child.on('exit', (code) => {
child = undefined;
if (stopping) return;
if (restarting && code === 0) launch();
else void stop(code ?? 1);
});
};
try {
await server.listen();
watcher = await context({
...electronBuildOptions,
// A failed compilation must not replace either half of the working Electron build.
write: false,
plugins: [
{
name: 'restart-electron',
setup(build) {
build.onStart(() => {
building = true;
});
build.onEnd(async (result) => {
try {
if (stopping || result.errors.length) return;
for (const output of result.outputFiles) {
await mkdir(dirname(output.path), { recursive: true });
await writeFile(output.path, output.contents);
}
if (stopping) return;
if (!child) launch();
else if (child.connected) {
console.log(
'Electron rebuilt; restart will wait for recording to finish.',
);
child.send({ type: 'development-restart' }, (error) => {
if (error && child?.connected)
console.error('Could not request restart.', error);
});
}
} finally {
building = false;
if (!stopping && launchPending) launch();
}
});
},
},
],
});
await watcher.watch();
} catch (error) {
console.error(error);
await stop(1);
}
process.on('SIGINT', () => void stop());
process.on('SIGTERM', () => void stop());

View File

@@ -0,0 +1,11 @@
export const electronBuildOptions = {
entryPoints: { main: 'src/main/main.ts', preload: 'src/main/preload.ts' },
bundle: true,
platform: 'node',
target: 'node22',
format: 'cjs',
outdir: 'dist',
outExtension: { '.js': '.cjs' },
external: ['electron', '@recallai/desktop-sdk'],
sourcemap: true,
};

View File

@@ -0,0 +1,92 @@
import { packager } from '@electron/packager';
import { access, cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import './build.mjs';
const manifest = JSON.parse(await readFile('package.json', 'utf8'));
const stagingDirectory = await mkdtemp(
join(tmpdir(), 'twenty-desktop-package-'),
);
// Resolve each dependency from its owner so hoisting cannot change the packaged runtime.
const copyRuntimeDependency = async (name, ownerManifest, destination) => {
const manifestPath = createRequire(ownerManifest).resolve(
`${name}/package.json`,
);
const dependency = JSON.parse(await readFile(manifestPath, 'utf8'));
const target = join(destination, 'node_modules', name);
await cp(dirname(manifestPath), target, {
recursive: true,
verbatimSymlinks: true,
filter: (source) => source !== join(dirname(manifestPath), 'node_modules'),
});
for (const child of Object.keys(dependency.dependencies ?? {}))
await copyRuntimeDependency(child, manifestPath, target);
};
try {
await cp('dist', join(stagingDirectory, 'dist'), { recursive: true });
await cp('assets', join(stagingDirectory, 'assets'), { recursive: true });
await writeFile(
join(stagingDirectory, 'package.json'),
JSON.stringify(
{
name: manifest.name,
productName: manifest.productName,
version: manifest.version,
description: manifest.description,
license: manifest.license,
main: manifest.main,
dependencies: {
'@recallai/desktop-sdk':
manifest.dependencies['@recallai/desktop-sdk'],
},
},
null,
2,
),
);
await copyRuntimeDependency(
'@recallai/desktop-sdk',
resolve('package.json'),
stagingDirectory,
);
const paths = await packager({
dir: stagingDirectory,
out: process.env.TWENTY_COMPANION_OUTPUT_DIR ?? 'release',
name: 'Twenty',
executableName: 'Twenty',
appBundleId: 'com.twenty.companion',
icon: join(process.cwd(), 'assets/twenty.icns'),
platform: 'darwin',
electronVersion: manifest.devDependencies.electron,
arch: 'arm64',
overwrite: true,
prune: false,
electronZipDir: process.env.TWENTY_COMPANION_ELECTRON_ZIP_DIR,
// Recall launches its native framework by path outside the Electron archive.
asar: { unpackDir: 'node_modules/@recallai/desktop-sdk' },
extendInfo: {
LSUIElement: false,
LSMinimumSystemVersion: '14.2',
NSMicrophoneUsageDescription:
'Twenty records your voice during calls you choose to capture.',
NSAudioCaptureUsageDescription:
'Twenty records call audio so it can be saved to your Twenty workspace.',
NSAccessibilityUsageDescription:
'Twenty detects meetings and shows recording controls.',
},
});
for (const directory of paths)
await access(
join(
directory,
'Twenty.app/Contents/Resources/app.asar.unpacked/node_modules/@recallai/desktop-sdk/desktop_sdk_macos_exe',
),
);
console.log(paths.join('\n'));
} finally {
await rm(stagingDirectory, { recursive: true, force: true });
}

View File

@@ -0,0 +1,27 @@
import { access } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
if (process.platform !== 'darwin' || process.arch !== 'arm64')
throw new Error('Desktop launch and packaging require an Apple Silicon Mac.');
const require = createRequire(import.meta.url);
for (const [name, artifact, installer] of [
['electron', 'dist/Electron.app/Contents/MacOS/Electron', 'install.js'],
['@recallai/desktop-sdk', 'desktop_sdk_macos_exe', 'setup.js'],
]) {
const directory = dirname(require.resolve(`${name}/package.json`));
const artifactPath = join(directory, artifact);
try {
await access(artifactPath);
continue;
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
execFileSync(process.execPath, [join(directory, installer)], {
cwd: directory,
stdio: 'inherit',
});
await access(artifactPath);
}

View File

@@ -0,0 +1,119 @@
import { EventEmitter } from 'node:events';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
spawn: vi.fn(),
context: vi.fn(),
server: { listen: vi.fn(), close: vi.fn() },
watcher: { watch: vi.fn(), dispose: vi.fn() },
mkdir: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('node:child_process', () => ({ spawn: mocks.spawn }));
vi.mock('node:fs/promises', () => ({
mkdir: mocks.mkdir,
writeFile: mocks.writeFile,
}));
vi.mock('vite', () => ({ createServer: async () => mocks.server }));
vi.mock('esbuild', () => ({ context: mocks.context }));
vi.mock('electron', () => ({ default: '/electron' }));
let onStart;
let onEnd;
let children;
let previousSignals;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
previousSignals = new Map(
['SIGINT', 'SIGTERM'].map((signal) => [signal, process.listeners(signal)]),
);
vi.spyOn(process, 'exit').mockImplementation(() => undefined);
children = [];
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
child.connected = true;
child.send = vi.fn();
child.kill = vi.fn();
children.push(child);
return child;
});
mocks.context.mockImplementation(async (options) => {
options.plugins[0].setup({
onStart: (callback) => {
onStart = callback;
},
onEnd: (callback) => {
onEnd = callback;
},
});
return mocks.watcher;
});
await import('../../../scripts/dev.mjs');
});
afterEach(async () => {
for (const [signal, previous] of previousSignals) {
for (const listener of process.listeners(signal)) {
if (!previous.includes(listener)) {
if (signal === 'SIGTERM') listener();
process.removeListener(signal, listener);
}
}
}
await vi.waitFor(() => expect(mocks.server.close).toHaveBeenCalled());
vi.restoreAllMocks();
});
const successfulBuild = async () => {
onStart();
await onEnd({
errors: [],
outputFiles: [{ path: '/dist/main.cjs', contents: new Uint8Array([1]) }],
});
};
it('starts Electron only after a successful build and keeps it alive after errors', async () => {
onStart();
await onEnd({ errors: ['syntax error'] });
expect(mocks.spawn).not.toHaveBeenCalled();
expect(mocks.writeFile).not.toHaveBeenCalled();
await successfulBuild();
expect(mocks.spawn).toHaveBeenCalledOnce();
onStart();
await onEnd({ errors: ['syntax error'] });
expect(children[0].send).not.toHaveBeenCalled();
expect(children[0].kill).not.toHaveBeenCalled();
});
it('requests restarts without killing the child, then waits for its acknowledged exit', async () => {
await successfulBuild();
await successfulBuild();
await successfulBuild();
expect(children[0].send).toHaveBeenCalledWith(
{ type: 'development-restart' },
expect.any(Function),
);
expect(children[0].kill).not.toHaveBeenCalled();
expect(mocks.spawn).toHaveBeenCalledOnce();
children[0].emit('message', { type: 'development-restarting' });
children[0].emit('exit', 0);
expect(mocks.spawn).toHaveBeenCalledTimes(2);
});
it('waits for an ongoing build before relaunching an exiting child', async () => {
await successfulBuild();
await successfulBuild();
onStart();
children[0].emit('message', { type: 'development-restarting' });
children[0].emit('exit', 0);
expect(mocks.spawn).toHaveBeenCalledOnce();
await onEnd({ errors: [], outputFiles: [] });
expect(mocks.spawn).toHaveBeenCalledTimes(2);
});
it('closes Vite and the watcher when Electron quits normally', async () => {
await successfulBuild();
children[0].emit('exit', 0);
await vi.waitFor(() => expect(mocks.server.close).toHaveBeenCalled());
expect(mocks.watcher.dispose).toHaveBeenCalledOnce();
expect(mocks.spawn).toHaveBeenCalledOnce();
});

View File

@@ -0,0 +1,8 @@
export class DesktopRecorderUnavailableError extends Error {
constructor(readonly serverUrl: string) {
super(
'Desktop Recorder is unavailable in this workspace. Install it to continue.',
);
this.name = 'DesktopRecorderUnavailableError';
}
}

View File

@@ -0,0 +1,10 @@
import { type Settings } from '../../shared/types/Settings';
export class SettingsRecoveryError extends Error {
constructor(
readonly settings: Settings,
message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,11 @@
export class TwentyRequestError extends Error {
constructor(
readonly status: number,
url: string,
) {
super(
`Twenty returned ${status} for ${new URL(url).pathname}. ${status === 401 ? 'Reconnect your workspace.' : 'Please try again.'}`,
);
this.name = 'TwentyRequestError';
}
}

View File

@@ -0,0 +1,404 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
import RecallAiSdk from '@recallai/desktop-sdk';
import {
app,
BrowserWindow,
Tray,
nativeImage,
globalShortcut,
Menu,
powerMonitor,
dialog,
ipcMain,
} from 'electron';
import { getUpcomingMeetings } from '../shared/utils/getUpcomingMeetings';
import { Companion } from './services/Companion';
import { createDevelopmentRestart } from './utils/createDevelopmentRestart';
import { createTrayMenuTemplate } from './utils/createTrayMenuTemplate';
import { getTrayTitle } from './utils/getTrayTitle';
import { commandSchema } from '../shared/validation-schemas/commandSchema';
import { type CompanionPage } from '../shared/types/CompanionPage';
let mainWindow: BrowserWindow;
let tray: Tray;
let trayMenu: Menu;
let trayMenuSignature = '';
let trayMenuOpen = false;
let companion: Companion;
let quitting = false;
let shutdownPending = false;
let initialized = false;
const SMOKE = process.argv.includes('--smoke');
const OPEN_IN_BACKGROUND = process.argv.includes('--background');
const DEVELOPMENT_URL = process.env.TWENTY_COMPANION_DEV_URL;
const RENDERER_PATH = join(__dirname, 'renderer/index.html');
const rendererReady = new Set<number>();
let pendingPage: CompanionPage | undefined;
const [MACOS_MAJOR, MACOS_MINOR] =
process.platform === 'darwin'
? process.getSystemVersion().split('.').map(Number)
: [0, 0];
const MENU_PRESENTATION = {
supportsHeaders: MACOS_MAJOR >= 14,
supportsSublabels:
MACOS_MAJOR > 14 || (MACOS_MAJOR === 14 && MACOS_MINOR >= 4),
};
const showApp = (page?: CompanionPage) => {
if (quitting || !mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
if (page) {
if (rendererReady.has(mainWindow.webContents.id))
mainWindow.webContents.send('companion:navigate', page);
else pendingPage = page;
}
};
const runCommand = async (command: unknown) => {
const parsed = commandSchema.parse(command);
if (parsed.type === 'open-app') {
showApp(parsed.page);
return;
}
await companion.command(parsed);
if (
companion.state.error ||
(parsed.type === 'record' && companion.state.activeRecording)
)
showApp();
};
const updateTrayMenu = () => {
if (!companion || quitting || tray.isDestroyed() || trayMenuOpen) return;
const template = createTrayMenuTemplate(
companion.state,
{
command: (command) => void runCommand(command),
openApp: showApp,
setSetting: (key, value) =>
void runCommand({
type: 'settings',
settings: { [key]: value },
}),
quit: () => app.quit(),
},
Date.now(),
{ ...MENU_PRESENTATION, locale: app.getLocale() },
);
const signature = JSON.stringify({
template,
meetingIds: companion.state.meetings.map((meeting) => meeting.id),
recordingId: companion.state.activeRecording?.id,
});
if (signature === trayMenuSignature) return;
trayMenuSignature = signature;
trayMenu = Menu.buildFromTemplate(template);
// Replacing a menu during keyboard navigation would move the user's selection.
trayMenu.on('menu-will-show', () => {
trayMenuOpen = true;
});
trayMenu.on('menu-will-close', () => {
trayMenuOpen = false;
setImmediate(updateTrayMenu);
});
tray.setContextMenu(trayMenu);
};
const assertSender = (event: Electron.IpcMainInvokeEvent) => {
const url = event.senderFrame?.url;
const expected = DEVELOPMENT_URL
? new URL(DEVELOPMENT_URL).origin
: pathToFileURL(RENDERER_PATH).href;
if (
event.senderFrame !== event.sender.mainFrame ||
event.sender !== mainWindow.webContents ||
!url ||
(DEVELOPMENT_URL
? new URL(url).origin !== expected
: url.split('#')[0] !== expected)
)
throw new Error('Untrusted companion window.');
};
// Keep existing connections and settings when the visible app name changes.
if (!SMOKE)
app.setPath('userData', join(app.getPath('appData'), 'Twenty Companion'));
if (!app.requestSingleInstanceLock()) app.quit();
else {
// Electron binds macOS encryption to this name before ready.
app.setName('Twenty Companion');
if (SMOKE) {
const directory = mkdtempSync(join(tmpdir(), 'twenty-companion-smoke-'));
app.setPath('userData', directory);
app.on('quit', () => {
try {
// Chromium can finish writing its cache while the smoke app exits.
rmSync(directory, { recursive: true, force: true, maxRetries: 3 });
} catch {
console.warn(
'Smoke cache cleanup deferred to the system temporary directory.',
);
}
});
}
app.on('second-instance', () => showApp());
app.on('activate', () => showApp());
void app
.whenReady()
.then(async () => {
app.setName('Twenty');
await app.dock?.show();
const webPreferences = {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
};
mainWindow = new BrowserWindow({
width: 1040,
height: 740,
minWidth: 760,
minHeight: 580,
show: false,
title: 'Twenty',
titleBarStyle:
process.platform === 'darwin' ? 'hiddenInset' : 'default',
trafficLightPosition: { x: 18, y: 25 },
backgroundColor: '#171717',
webPreferences,
});
mainWindow.webContents.on('did-start-loading', () =>
rendererReady.delete(mainWindow.webContents.id),
);
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
mainWindow.webContents.on('will-navigate', (event) =>
event.preventDefault(),
);
mainWindow.on('focus', () => companion?.refreshPermissions());
mainWindow.on('close', (event) => {
if (!quitting) {
event.preventDefault();
mainWindow.hide();
}
});
const trayImage = nativeImage.createFromPath(
join(__dirname, '../assets/trayLogo.png'),
);
if (trayImage.isEmpty()) throw new Error('The menu bar icon is missing.');
if (process.platform === 'darwin') trayImage.setTemplateImage(true);
tray = new Tray(trayImage, '8fd0b192-e117-4987-8b6f-98ef6f6f0c35');
tray.setToolTip('Twenty');
if (process.platform === 'win32')
tray.on('click', () => tray.popUpContextMenu());
const updateApplicationMenu = (shortcut: string) =>
Menu.setApplicationMenu(
Menu.buildFromTemplate([
...(process.platform === 'darwin'
? [{ role: 'appMenu' as const }]
: []),
{
label: 'File',
submenu: [
{
id: 'open-companion',
label: 'Open app',
accelerator: shortcut,
click: () => showApp(),
},
{
label: 'Meeting menu',
accelerator: 'CommandOrControl+Shift+M',
click: () => {
updateTrayMenu();
tray.popUpContextMenu();
},
},
{
label: 'Settings…',
accelerator: 'CommandOrControl+,',
click: () => showApp('settings'),
},
{ type: 'separator' },
{ role: 'close' },
],
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
{ role: 'windowMenu' },
]),
);
updateApplicationMenu('CommandOrControl+Shift+Space');
let registeredOpenShortcut: string | undefined;
companion = new Companion(
(state) => {
if (!mainWindow.webContents.isDestroyed())
mainWindow.webContents.send('companion:state', state);
updateTrayMenu();
const next = getUpcomingMeetings({
meetings: state.meetings,
now: Date.now(),
})[0];
const recording = state.activeRecording;
tray.setTitle(getTrayTitle(state));
tray.setToolTip(
recording
? (recording.status === 'paused' ? 'Paused' : 'Recording') +
' · ' +
recording.title
: next
? 'Twenty · ' + next.title
: 'Twenty',
);
},
() => showApp(),
(shortcut) => {
if (shortcut === registeredOpenShortcut) return;
let registered = false;
try {
registered = globalShortcut.register(shortcut, () => showApp());
} catch {
throw new Error(
'This shortcut is invalid. Choose another key combination.',
);
}
if (!registered)
throw new Error(
'This shortcut is already in use. Choose another key combination.',
);
try {
// Electron accelerators are immutable after menu construction.
updateApplicationMenu(shortcut);
} catch (error) {
globalShortcut.unregister(shortcut);
throw error;
}
if (registeredOpenShortcut)
globalShortcut.unregister(registeredOpenShortcut);
registeredOpenShortcut = shortcut;
},
);
updateTrayMenu();
ipcMain.handle('companion:state', (event) => {
assertSender(event);
rendererReady.add(event.sender.id);
if (pendingPage) {
event.sender.send('companion:navigate', pendingPage);
pendingPage = undefined;
}
return companion.state;
});
ipcMain.handle('companion:command', async (event, value: unknown) => {
assertSender(event);
await runCommand(value);
});
if (DEVELOPMENT_URL) {
if (new URL(DEVELOPMENT_URL).origin !== 'http://127.0.0.1:4317')
throw new Error('Unexpected development server.');
await mainWindow.loadURL(DEVELOPMENT_URL);
} else {
await mainWindow.loadFile(RENDERER_PATH);
}
powerMonitor.on(
'resume',
() => void companion.command({ type: 'refresh' }),
);
await companion.initialize();
initialized = true;
if (SMOKE) {
await RecallAiSdk.init({
apiUrl: 'https://eu-central-1.recall.ai',
acquirePermissionsOnStartup: [],
});
await RecallAiSdk.shutdown();
if (
rendererReady.size !== 1 ||
BrowserWindow.getAllWindows().length !== 1 ||
!trayMenu?.items.length
)
throw new Error(
'The main window must establish secure IPC and the native tray menu must be ready.',
);
console.log(
'Companion smoke passed: one main window, native tray menu, secure IPC, native Recall SDK.',
);
await companion.shutdown();
quitting = true;
setImmediate(() => app.quit());
return;
}
if (!OPEN_IN_BACKGROUND && !app.getLoginItemSettings().wasOpenedAtLogin)
showApp();
})
.catch((error: unknown) => {
if (SMOKE) {
console.error('Companion smoke failed:', error);
app.exit(1);
return;
}
dialog.showErrorBox(
'Twenty could not start',
error instanceof Error
? error.message
: 'Restart the app to try again.',
);
quitting = true;
app.quit();
});
if (DEVELOPMENT_URL && process.send) {
const developmentRestart = createDevelopmentRestart(
() => !initialized || shutdownPending || companion.isRecording,
() => {
process.send?.({ type: 'development-restarting' });
app.quit();
},
);
process.on('message', (message) => {
if (
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'development-restart'
)
developmentRestart.request();
});
app.on('quit', developmentRestart.dispose);
}
app.on('before-quit', (event) => {
if (quitting) return;
event.preventDefault();
if (shutdownPending) return;
shutdownPending = true;
void (async () => {
try {
if (companion?.state.activeRecording) {
const { response } = await dialog.showMessageBox(mainWindow, {
type: 'question',
message: 'Stop recording and quit?',
detail: 'The recording will finish processing in Twenty.',
buttons: ['Keep recording', 'Stop and quit'],
cancelId: 0,
defaultId: 0,
});
if (response === 0) return;
}
await companion?.shutdown();
quitting = true;
globalShortcut.unregisterAll();
setImmediate(() => app.quit());
} catch (error) {
dialog.showErrorBox(
'Twenty could not finish quitting',
error instanceof Error ? error.message : 'Try quitting again.',
);
} finally {
shutdownPending = false;
}
})();
});
}

View File

@@ -0,0 +1,26 @@
import { contextBridge, ipcRenderer } from 'electron';
import { type CompanionBridge } from '../shared/types/CompanionBridge';
import { type CompanionState } from '../shared/types/CompanionState';
import { type CompanionPage } from '../shared/types/CompanionPage';
const bridge: CompanionBridge = {
getState: () => ipcRenderer.invoke('companion:state'),
command: (command) => ipcRenderer.invoke('companion:command', command),
onState: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
state: CompanionState,
) => listener(state);
ipcRenderer.on('companion:state', handler);
return () => {
ipcRenderer.removeListener('companion:state', handler);
};
},
onNavigate: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, page: CompanionPage) =>
listener(page);
ipcRenderer.on('companion:navigate', handler);
return () => ipcRenderer.removeListener('companion:navigate', handler);
},
};
contextBridge.exposeInMainWorld('companion', bridge);

View File

@@ -0,0 +1,508 @@
import { SettingsRecoveryError } from '../errors/SettingsRecoveryError';
import { app, shell, Notification, nativeTheme } from 'electron';
import { type Agenda } from '../../shared/types/Agenda';
import { type Settings } from '../../shared/types/Settings';
import { type CompanionCommand } from '../../shared/types/CompanionCommand';
import { type CompanionState } from '../../shared/types/CompanionState';
import { getDueMeetings } from '../../shared/utils/getDueMeetings';
import { getMeetingUrl } from '../../shared/utils/getMeetingUrl';
import { getMeetingOccurrenceKey } from '../../shared/utils/getMeetingOccurrenceKey';
import { createInitialState } from '../../shared/utils/createInitialState';
import { connectOAuth } from '../utils/connectOAuth';
import { getDesktopInstallationUrl } from '../utils/getDesktopInstallationUrl';
import { DesktopRecorderUnavailableError } from '../errors/DesktopRecorderUnavailableError';
import { SecureStore } from './SecureStore';
import { TwentyClient } from './TwentyClient';
import { RecordingController } from './RecordingController';
export class Companion {
state = createInitialState();
private store = new SecureStore(app.getPath('userData'));
private client = new TwentyClient(this.store);
private handledIds = new Set<string>();
private refreshing = false;
private connectionVersion = 0;
private changingConnection = false;
private connectionAbort: AbortController | null = null;
private stopped = false;
private settingsUpdate: Promise<void> = Promise.resolve();
private syncingTimer?: NodeJS.Timeout;
private joiningTimer?: NodeJS.Timeout;
private joiningMeetings = new Set<string>();
private recording = new RecordingController(this.state, this.client, {
emit: () => this.emit(),
openApp: () => this.openApp(),
notify: (title, body) => this.notify(title, body),
fail: (error) => this.fail(error),
refresh: () => this.refresh(),
canRecord: () => !this.stopped && !this.changingConnection,
isStopping: () => this.stopped,
});
constructor(
private publish: (state: CompanionState) => void,
private openApp: () => void = () => undefined,
private registerOpenShortcut: (shortcut: string) => void = () => undefined,
) {}
async initialize(): Promise<void> {
if (this.recording.listening) return;
this.stopped = false;
this.recording.listenToRecording();
let settingsError: unknown;
try {
this.state.settings = await this.store.readSettings();
} catch (error) {
if (error instanceof SettingsRecoveryError)
this.state.settings = error.settings;
else
this.state.settings = {
...this.state.settings,
autoJoin: false,
autoRecord: false,
};
settingsError = error;
}
try {
this.registerOpenShortcut(this.state.settings.openShortcut);
} catch (error) {
this.fail(error);
}
nativeTheme.themeSource = this.state.settings.appearance;
try {
this.handledIds = new Set(await this.store.readHandledMeetings());
} catch (error) {
this.state.settings.autoJoin = false;
settingsError = error;
}
try {
const credentials = await this.client.restore();
if (credentials) {
this.state.serverUrl = credentials.serverUrl;
this.state.connection = 'connected';
await this.refresh();
}
} catch (error) {
this.fail(error);
}
if (settingsError) this.fail(settingsError);
if (!this.stopped) this.startTimers();
this.emit();
}
private startTimers(): void {
this.syncingTimer = setInterval(
() => void this.refresh().catch((error) => this.fail(error)),
30_000,
);
this.joiningTimer = setInterval(
() => void this.tick().catch((error) => this.fail(error)),
1000,
);
this.emit();
}
async command(command: CompanionCommand): Promise<void> {
if (this.stopped) return;
try {
this.state.error = null;
switch (command.type) {
case 'cancel-connect':
this.connectionAbort?.abort();
break;
case 'connect': {
if (this.changingConnection || this.recording.isRecording) return;
this.changingConnection = true;
const previousConnection = this.state.connection;
let credentialsSaved = false;
const connectionAbort = new AbortController();
this.connectionAbort = connectionAbort;
try {
this.state.connection = 'connecting';
this.emit();
const credentials = await connectOAuth(
command.serverUrl,
(url) => shell.openExternal(url),
connectionAbort.signal,
);
connectionAbort.signal.throwIfAborted();
if (this.stopped) return;
this.openApp();
if (process.platform === 'darwin') app.focus({ steal: true });
await this.client.connect(credentials, connectionAbort.signal);
credentialsSaved = true;
this.connectionVersion++;
this.refreshing = false;
await this.recording.resetSdk();
connectionAbort.signal.throwIfAborted();
if (this.stopped) return;
this.resetWorkspaceState();
this.state.serverUrl = credentials.serverUrl;
this.state.connection = 'connected';
await this.refresh();
connectionAbort.signal.throwIfAborted();
} catch (error) {
if (credentialsSaved) {
this.connectionVersion++;
this.resetWorkspaceState();
await this.client.disconnect();
}
if (this.state.connection === 'connecting')
this.state.connection = previousConnection;
if (!connectionAbort.signal.aborted) throw error;
} finally {
this.connectionAbort = null;
this.changingConnection = false;
}
break;
}
case 'disconnect': {
if (this.recording.isRecording || this.changingConnection)
throw new Error(
'Stop the recording before disconnecting your workspace.',
);
this.connectionVersion++;
this.changingConnection = true;
this.resetWorkspaceState();
this.refreshing = false;
try {
await this.client.disconnect();
await this.recording.resetSdk();
await this.store.writeHandledMeetings([]);
} finally {
this.changingConnection = false;
}
break;
}
case 'settings':
await this.updateSettings(command.settings);
break;
case 'begin-permission-setup':
if (this.state.activeRecording) return;
this.state.permissionSetup = {};
await this.recording.initializeSdk();
this.recording.refreshPermissions();
this.openApp();
break;
case 'complete-setup':
this.recording.refreshPermissions();
if (
!Object.values(this.state.permissions).every(
(status) => status === 'granted',
)
)
throw new Error(
'Enable microphone, system audio, and meeting detection to continue.',
);
await this.updateSettings({ setupCompleted: true });
this.state.permissionSetup = null;
break;
case 'permission':
await this.recording.requestPermission(command.permission);
break;
case 'cancel-permission-setup':
this.state.permissionSetup = null;
break;
case 'refresh':
await this.refresh();
break;
case 'skip':
await this.markHandled(command.meetingId);
break;
case 'unskip': {
const meeting = this.state.meetings.find(
(item) => item.id === command.meetingId,
);
if (!meeting) throw new Error('This meeting is no longer available.');
this.handledIds.delete(getMeetingOccurrenceKey(meeting));
this.state.skippedMeetingIds = this.state.skippedMeetingIds.filter(
(id) => id !== meeting.id,
);
await this.store.writeHandledMeetings([...this.handledIds]);
break;
}
case 'join':
await this.join(command.meetingId);
break;
case 'record':
await this.recording.record(command.windowId);
break;
case 'stop':
await this.recording.stopRecording();
break;
case 'pause':
case 'resume':
await this.recording.pause(command.type === 'pause');
break;
case 'open-recording':
if (
!this.state.recordings.some(
(recording) => recording.id === command.recordingId,
)
)
throw new Error('Recording is unavailable. Refresh and try again.');
await shell.openExternal(
`${this.client.workspaceUrl ?? this.state.serverUrl}/object/callRecording/${command.recordingId}`,
);
break;
case 'open-recordings':
await shell.openExternal(
`${this.client.workspaceUrl ?? this.state.serverUrl}/objects/callRecordings`,
);
break;
case 'open-calendar-settings':
await shell.openExternal(
`${this.client.workspaceUrl ?? this.state.serverUrl}/settings/accounts`,
);
break;
case 'open-desktop-setup':
await shell.openExternal(
await getDesktopInstallationUrl(command.serverUrl),
);
break;
case 'open-desktop-installation':
await shell.openExternal(
await getDesktopInstallationUrl(command.serverUrl),
);
break;
case 'dismiss-error':
this.state.notice = null;
break;
}
} catch (error) {
if (this.state.connection === 'connecting')
this.state.connection = 'disconnected';
this.fail(error);
} finally {
this.emit();
}
}
private resetWorkspaceState(): void {
Object.assign(this.state, {
connection: 'disconnected',
workspace: null,
meetings: [],
recordings: [],
calendarConnected: false,
updatedAt: null,
detectedCalls: [],
permissionSetup: null,
skippedMeetingIds: [],
notice: null,
});
this.handledIds.clear();
this.recording.resetWorkspace();
}
private updateSettings(patch: Partial<Settings>): Promise<void> {
const update = this.settingsUpdate.then(async () => {
const settings = { ...this.state.settings, ...patch };
const shortcutChanged =
settings.openShortcut !== this.state.settings.openShortcut;
if (shortcutChanged) this.registerOpenShortcut(settings.openShortcut);
try {
await this.store.writeSettings(settings);
} catch (error) {
if (shortcutChanged)
this.registerOpenShortcut(this.state.settings.openShortcut);
throw error;
}
if (settings.launchAtLogin !== this.state.settings.launchAtLogin)
app.setLoginItemSettings({
openAtLogin: settings.launchAtLogin,
});
this.state.settings = settings;
nativeTheme.themeSource = settings.appearance;
});
this.settingsUpdate = update.catch(() => undefined);
return update;
}
async refresh(): Promise<void> {
if (
this.stopped ||
this.state.connection !== 'connected' ||
this.refreshing
)
return;
this.refreshing = true;
const connectionVersion = this.connectionVersion;
try {
const agenda = await this.client.companion<Agenda>({ action: 'agenda' });
if (
this.state.connection !== 'connected' ||
connectionVersion !== this.connectionVersion
)
return;
this.state.workspace = agenda.workspace;
if (this.client.workspace?.id === agenda.workspace.id)
this.state.workspace.logoUrl = this.client.workspace.logoUrl;
this.state.meetings = agenda.meetings;
this.state.skippedMeetingIds = agenda.meetings
.filter((meeting) =>
this.handledIds.has(getMeetingOccurrenceKey(meeting)),
)
.map((meeting) => meeting.id);
// Completion webhooks can lag behind the local recording-ended event.
const finishing = this.state.recordings.filter(
(recording) =>
recording.status === 'PROCESSING' &&
recording.endedAt &&
Date.now() - Date.parse(recording.endedAt) < 120_000 &&
!agenda.recordings.some(
(remote) =>
remote.id === recording.id &&
['COMPLETED', 'FAILED'].includes(remote.status),
),
);
this.state.recordings = [
...finishing,
...agenda.recordings.filter(
(recording) => !finishing.some((local) => local.id === recording.id),
),
];
this.state.recordings = this.state.recordings.map((recording) => ({
...recording,
participants: recording.participants?.map((participant) => ({
...participant,
avatarUrl: this.client.resolveImageUrl(participant.avatarUrl),
})),
}));
this.state.calendarConnected = agenda.calendarConnected;
this.state.updatedAt = new Date().toISOString();
this.state.error = null;
await this.recording.initializeSdk();
} catch (error) {
if (connectionVersion === this.connectionVersion && this.client.workspace)
this.state.workspace = this.client.workspace;
throw error;
} finally {
if (connectionVersion === this.connectionVersion) this.refreshing = false;
this.emit();
}
}
private async tick(): Promise<void> {
if (this.state.permissionSetup || !this.state.settings.setupCompleted)
this.recording.refreshPermissions();
if (
this.state.connection !== 'connected' ||
this.stopped ||
!this.state.settings.setupCompleted ||
!this.state.settings.autoJoin ||
!this.state.updatedAt
)
return;
const due = getDueMeetings({
meetings: this.state.meetings,
handledIds: this.handledIds,
now: Date.now(),
lastSyncedAt: Date.parse(this.state.updatedAt),
});
for (const meeting of due) {
if (
this.recording.isRecording ||
this.state.detectedCalls.length > 0 ||
due.length > 1
) {
await this.markHandled(meeting.id);
this.notify(
'Your next meeting is starting',
`${meeting.title}. Open Twenty to join when you are ready.`,
);
} else {
await this.join(meeting.id);
}
}
}
private async join(meetingId: string): Promise<void> {
const meeting = this.state.meetings.find((item) => item.id === meetingId);
if (!meeting?.url) throw new Error('This meeting has no join link.');
const connectionVersion = this.connectionVersion;
const url = getMeetingUrl(meeting.url);
if (this.joiningMeetings.has(meeting.id)) return;
if (
this.stopped ||
this.state.connection !== 'connected' ||
connectionVersion !== this.connectionVersion
)
return;
this.joiningMeetings.add(meeting.id);
try {
await shell.openExternal(url);
if (this.stopped || connectionVersion !== this.connectionVersion) return;
await this.markHandled(meeting.id);
this.state.notice = { type: 'opening-meeting', title: meeting.title };
} finally {
this.joiningMeetings.delete(meeting.id);
}
}
private async markHandled(id: string): Promise<void> {
const meeting = this.state.meetings.find((item) => item.id === id);
if (!meeting) throw new Error('This meeting is no longer available.');
const key = getMeetingOccurrenceKey(meeting);
if (this.handledIds.has(key)) return;
this.handledIds.add(key);
this.state.skippedMeetingIds = [
...new Set([...this.state.skippedMeetingIds, id]),
];
await this.store.writeHandledMeetings([...this.handledIds]);
}
private notify(title: string, body: string): void {
if (Notification.isSupported()) {
const notification = new Notification({ title, body });
notification.on('click', this.openApp);
notification.show();
}
}
private fail(error: unknown): void {
this.state.error = {
message:
error instanceof Error
? error.message
: 'Something went wrong. Please try again.',
...(error instanceof DesktopRecorderUnavailableError
? {
recovery: {
type: 'open-desktop-installation' as const,
serverUrl: error.serverUrl,
},
}
: {}),
};
this.emit();
}
private emit(): void {
this.publish(structuredClone(this.state));
}
get isRecording(): boolean {
return this.recording.isRecording;
}
refreshPermissions(): void {
this.recording.refreshPermissions();
}
async shutdown(): Promise<void> {
this.stopped = true;
this.connectionVersion++;
clearInterval(this.syncingTimer);
clearInterval(this.joiningTimer);
try {
await this.recording.shutdown();
await this.settingsUpdate;
} catch (error) {
this.stopped = false;
this.startTimers();
throw error;
}
this.recording.dispose();
}
}

View File

@@ -0,0 +1,498 @@
import { randomUUID } from 'node:crypto';
import { shell, systemPreferences } from 'electron';
import RecallAiSdk, {
type RecallAiSdkWindow,
type EventTypeToPayloadMap,
} from '@recallai/desktop-sdk';
import { type RecordingUpload } from '../../shared/types/RecordingUpload';
import { type RecordingConfiguration } from '../../shared/types/RecordingConfiguration';
import { type RecordingResult } from '../../shared/types/RecordingResult';
import { type CompanionState } from '../../shared/types/CompanionState';
import { matchMeeting } from '../../shared/utils/matchMeeting';
import { type TwentyClient } from './TwentyClient';
const RECALL_REGIONS = new Set([
'https://eu-central-1.recall.ai',
'https://us-east-1.recall.ai',
'https://us-west-2.recall.ai',
'https://ap-northeast-1.recall.ai',
]);
type RecordingEvents = {
emit: () => void;
openApp: () => void;
notify: (title: string, body: string) => void;
fail: (error: unknown) => void;
refresh: () => Promise<void>;
canRecord: () => boolean;
isStopping: () => boolean;
};
export class RecordingController {
private sdkRegion: string | null = null;
private sdkInitialization: Promise<void> | null = null;
private autoHandledWindows = new Set<string>();
private interruptedByNetwork = false;
private networkConnected = true;
private recordingPause: Promise<void> | null = null;
private recordingStop: Promise<void> | null = null;
private recordingStart: Promise<void> | null = null;
private unsubscribeRecording: (() => void)[] = [];
constructor(
private state: CompanionState,
private client: TwentyClient,
private events: RecordingEvents,
) {}
get isRecording(): boolean {
return !!this.state.activeRecording || !!this.recordingStart;
}
get listening(): boolean {
return this.unsubscribeRecording.length > 0;
}
async resetSdk(): Promise<void> {
await this.sdkInitialization;
await this.stopSdk();
}
resetWorkspace(): void {
this.autoHandledWindows.clear();
}
async shutdown(): Promise<void> {
await this.recordingStart?.catch(() => undefined);
await this.sdkInitialization?.catch(() => undefined);
await this.stopRecording();
await this.stopSdk();
}
dispose(): void {
this.unsubscribeRecording.forEach((unsubscribe) => unsubscribe());
this.unsubscribeRecording = [];
}
async pause(pausing: boolean): Promise<void> {
if (this.recordingPause) return;
this.recordingPause = this.changeRecordingPause(pausing).finally(() => {
this.recordingPause = null;
});
await this.recordingPause;
}
private async changeRecordingPause(pausing: boolean): Promise<void> {
const recording = this.state.activeRecording;
if (
this.recordingStop ||
!recording ||
recording.status !== (pausing ? 'recording' : 'paused')
)
return;
recording.status = pausing ? 'pausing' : 'resuming';
this.events.emit();
try {
if (pausing)
await RecallAiSdk.pauseRecording({
windowId: recording.windowId,
});
else
await RecallAiSdk.resumeRecording({
windowId: recording.windowId,
});
if (
this.state.activeRecording !== recording ||
recording.status !== (pausing ? 'pausing' : 'resuming')
)
return;
if (pausing) recording.pausedAt = new Date().toISOString();
else {
recording.pausedMilliseconds =
(recording.pausedMilliseconds ?? 0) +
(recording.pausedAt
? Date.now() - Date.parse(recording.pausedAt)
: 0);
recording.pausedAt = undefined;
}
recording.status = pausing ? 'paused' : 'recording';
} catch (error) {
if (
this.state.activeRecording === recording &&
recording.status === (pausing ? 'pausing' : 'resuming')
)
recording.status = pausing ? 'recording' : 'paused';
throw error;
}
}
stopRecording(): Promise<void> {
this.recordingStop ??= this.finishCapture().finally(() => {
this.recordingStop = null;
});
return this.recordingStop;
}
private async finishCapture(): Promise<void> {
await this.recordingStart?.catch(() => undefined);
await this.recordingPause?.catch(() => undefined);
const recording = this.state.activeRecording;
if (!recording) return;
const previousStatus = recording.status;
recording.status = 'stopping';
this.events.emit();
try {
await RecallAiSdk.stopRecording({ windowId: recording.windowId });
this.finishRecording(recording.windowId);
} catch (error) {
if (this.state.activeRecording === recording)
recording.status = previousStatus;
throw error;
}
}
async initializeSdk(region?: string): Promise<void> {
if (this.sdkInitialization) await this.sdkInitialization;
if (this.events.isStopping()) return;
if (this.sdkRegion && (!region || region === this.sdkRegion)) return;
this.sdkInitialization = this.startSdk(region).finally(() => {
this.sdkInitialization = null;
});
await this.sdkInitialization;
}
private async startSdk(region?: string): Promise<void> {
if (process.platform !== 'darwin' || process.arch !== 'arm64')
throw new Error('Recording requires an Apple Silicon Mac.');
const [major, minor] = process.getSystemVersion().split('.').map(Number);
if (major < 14 || (major === 14 && minor < 2))
throw new Error('Audio recording requires macOS 14.2 or later.');
const apiUrl =
region ??
(
await this.client.companion<RecordingConfiguration>({
action: 'configuration',
})
).apiUrl;
if (!RECALL_REGIONS.has(apiUrl))
throw new Error('The workspace returned an unsupported Recall region.');
if (this.sdkRegion === apiUrl) return;
await this.stopSdk();
await RecallAiSdk.init({ apiUrl, acquirePermissionsOnStartup: [] });
this.sdkRegion = apiUrl;
this.refreshPermissions();
}
private async stopSdk(): Promise<void> {
if (this.sdkRegion) await RecallAiSdk.shutdown();
this.sdkRegion = null;
}
private async openPermissionSettings(
permission: 'microphone' | 'system-audio' | 'accessibility',
): Promise<void> {
const pane = {
microphone: 'Privacy_Microphone',
accessibility: 'Privacy_Accessibility',
'system-audio': 'Privacy_ScreenCapture',
}[permission];
await shell.openExternal(
`x-apple.systempreferences:com.apple.preference.security?${pane}`,
);
}
refreshPermissions(): void {
if (process.platform !== 'darwin') return;
const accessibility = systemPreferences.isTrustedAccessibilityClient(false);
const status = accessibility
? 'granted'
: this.state.permissions.accessibility === 'not_requested'
? 'not_requested'
: 'denied';
// Recall can retain a denial after approval in System Settings.
// Audio permissions belong to the SDK's capture process.
if (status !== this.state.permissions.accessibility) {
this.state.permissions.accessibility = status;
this.events.emit();
}
}
async requestPermission(
permission: 'microphone' | 'system-audio' | 'accessibility',
): Promise<void> {
this.refreshPermissions();
// A known denial must still open Settings when SDK initialization is unavailable.
if (this.state.permissions[permission] !== 'denied')
await this.initializeSdk();
this.refreshPermissions();
if (this.state.permissions[permission] === 'granted') return;
if (this.state.permissions[permission] === 'denied') {
await this.openPermissionSettings(permission);
return;
}
await RecallAiSdk.requestPermission(permission);
}
async record(windowId?: string): Promise<void> {
if (this.state.activeRecording || this.recordingStart) return;
if (!this.events.canRecord() || this.state.connection !== 'connected')
throw new Error('Connect your workspace before recording.');
if (!this.networkConnected)
throw new Error('Reconnect to the internet before recording.');
this.recordingStart = this.startRecording(windowId);
try {
await this.recordingStart;
} finally {
this.recordingStart = null;
}
}
private async startRecording(windowId?: string): Promise<void> {
let recordingId: string | undefined;
let selectedWindowId: string | undefined;
try {
await this.initializeSdk();
this.refreshPermissions();
const missingPermission = (
['microphone', 'system-audio', 'accessibility'] as const
).find((permission) => this.state.permissions[permission] !== 'granted');
if (missingPermission) {
this.state.permissionSetup = { windowId, intent: 'record' };
this.events.emit();
this.events.openApp();
return;
}
this.state.permissionSetup = null;
const detected = windowId
? this.state.detectedCalls.find((call) => call.id === windowId)
: undefined;
if (windowId && !detected)
throw new Error('This call has ended. Refresh and try again.');
if (
!this.state.updatedAt ||
Date.now() - Date.parse(this.state.updatedAt) > 90_000
)
throw new Error('Refresh your agenda before recording.');
const calendarEvent = matchMeeting({
meetings: this.state.meetings,
url: detected?.url,
now: Date.now(),
});
if (
calendarEvent &&
(calendarEvent.usesCalendarBot || !calendarEvent.recordingEnabled)
)
throw new Error(
'This meeting uses its calendar bot. Desktop recording is available for unscheduled calls.',
);
const title =
calendarEvent?.title ?? detected?.title ?? 'Unscheduled conversation';
recordingId = randomUUID();
const upload = await this.client.companion<RecordingUpload>({
action: 'create-upload',
sessionId: recordingId,
title,
calendarEventId: calendarEvent?.id,
meetingUrl: detected?.url,
platform: detected?.platform ?? 'desktop-audio',
});
await this.initializeSdk(upload.apiUrl);
selectedWindowId =
windowId ?? (await RecallAiSdk.prepareDesktopAudioRecording());
if (!this.events.canRecord() || !this.networkConnected)
throw new Error('Recording setup was interrupted. Try again.');
this.state.activeRecording = {
id: upload.callRecordingId,
windowId: selectedWindowId,
title,
startedAt: new Date().toISOString(),
status: 'starting',
};
this.events.emit();
await RecallAiSdk.startRecording({
windowId: selectedWindowId,
uploadToken: upload.uploadToken,
});
if (
this.state.activeRecording?.windowId === selectedWindowId &&
this.state.activeRecording.status === 'starting'
)
this.state.activeRecording.status = 'recording';
} catch (error) {
if (selectedWindowId && this.state.activeRecording) {
try {
await RecallAiSdk.stopRecording({ windowId: selectedWindowId });
} catch {
this.state.activeRecording.status = 'recording';
throw new Error(
'Capture may still be running. Use Stop recording or quit the app to stop it.',
);
}
}
this.state.activeRecording = null;
if (recordingId)
await this.client
.companion<RecordingResult>({
action: 'capture-failed',
sessionId: recordingId,
})
.catch(() => undefined);
throw error;
} finally {
this.events.emit();
}
}
listenToRecording(): void {
const listen = <TEvent extends keyof EventTypeToPayloadMap>(
event: TEvent,
listener: (payload: EventTypeToPayloadMap[TEvent]) => void,
) => {
RecallAiSdk.addEventListener(event, listener);
this.unsubscribeRecording.push(() =>
RecallAiSdk.removeEventListener(event, listener),
);
};
const detected = (window: RecallAiSdkWindow) => {
const call = {
id: window.id,
title: window.title ?? 'Detected call',
platform: window.platform ?? 'Meeting',
url: window.url,
};
this.state.detectedCalls = [
...this.state.detectedCalls.filter((item) => item.id !== call.id),
call,
];
this.events.emit();
};
const handleDetection = (
window: RecallAiSdkWindow,
shouldNotify: boolean,
) => {
if (this.events.isStopping() || this.state.connection !== 'connected')
return;
detected(window);
// Calendar meetings keep their scheduled bot; unsupported platforms can use desktop capture.
const meeting = matchMeeting({
meetings: this.state.meetings,
url: window.url,
now: Date.now(),
});
if (
this.state.settings.autoRecord &&
this.state.settings.setupCompleted &&
this.networkConnected &&
(!meeting || (meeting.recordingEnabled && !meeting.usesCalendarBot)) &&
window.url &&
this.state.updatedAt &&
Date.now() - Date.parse(this.state.updatedAt) < 90_000 &&
!this.autoHandledWindows.has(window.id)
) {
this.autoHandledWindows.add(window.id);
void this.record(window.id)
.catch((error) => this.events.fail(error))
.finally(() => {
if (this.state.activeRecording?.windowId !== window.id)
this.autoHandledWindows.delete(window.id);
});
} else if (
shouldNotify &&
this.state.settings.notifyOnDetectedCall &&
!meeting?.usesCalendarBot
)
this.events.notify(
'Call detected',
'Open Twenty to record this conversation.',
);
};
listen('meeting-detected', ({ window }) => handleDetection(window, true));
listen('meeting-updated', ({ window }) => handleDetection(window, false));
listen('meeting-closed', ({ window }) => {
this.autoHandledWindows.delete(window.id);
this.state.detectedCalls = this.state.detectedCalls.filter(
(call) => call.id !== window.id,
);
this.events.emit();
});
listen('recording-started', ({ window }) => {
if (
this.state.activeRecording?.windowId === window.id &&
this.state.activeRecording.status === 'starting'
)
this.state.activeRecording.status = 'recording';
this.events.emit();
});
listen('recording-ended', ({ window }) => this.finishRecording(window.id));
listen('permission-status', ({ permission, status }) => {
if (
permission === 'microphone' ||
permission === 'accessibility' ||
permission === 'system-audio'
)
this.state.permissions[permission] = status;
this.refreshPermissions();
this.events.emit();
});
listen('network-status', ({ status }) => {
this.networkConnected = status !== 'disconnected';
if (!this.networkConnected && this.state.activeRecording) {
this.interruptedByNetwork = true;
this.state.notice = { type: 'network-lost' };
this.events.notify(
'Recording interrupted',
'Network connection lost. Stopping this recording. Reconnect before starting a new recording.',
);
void this.stopRecording().catch((error) => this.events.fail(error));
} else if (this.networkConnected && this.interruptedByNetwork) {
this.interruptedByNetwork = false;
this.state.notice = this.state.activeRecording
? { type: 'network-restored-active' }
: { type: 'network-restored' };
}
this.events.emit();
});
listen('error', () => {
this.events.fail(
new Error(
this.state.activeRecording
? 'Recall reported a capture error. Stop this recording before starting another.'
: 'Recording is unavailable. Check permissions and try again.',
),
);
});
listen('media-capture-status', ({ type, capturing, window }) => {
if (
type === 'audio' &&
!capturing &&
this.state.activeRecording?.windowId === window.id &&
this.state.activeRecording.status === 'recording'
) {
this.state.notice = { type: 'audio-interrupted' };
this.events.emit();
}
});
}
private finishRecording(windowId: string): void {
if (this.state.activeRecording?.windowId !== windowId) return;
const recording = this.state.activeRecording;
this.state.recordings = [
{
id: recording.id,
title: recording.title,
status: 'PROCESSING',
startedAt: recording.startedAt,
endedAt: new Date().toISOString(),
},
...this.state.recordings.filter((item) => item.id !== recording.id),
];
this.state.activeRecording = null;
this.state.notice = this.networkConnected
? { type: 'recording-finished' }
: { type: 'recording-stopped-offline' };
this.events.emit();
void this.events.refresh().catch((error) => this.events.fail(error));
}
}

View File

@@ -0,0 +1,157 @@
import { randomUUID } from 'node:crypto';
import {
readFile,
writeFile,
rename,
mkdir,
unlink,
copyFile,
} from 'node:fs/promises';
import { join } from 'node:path';
import { safeStorage } from 'electron';
import { z } from 'zod';
import { DEFAULT_SETTINGS } from '../../shared/constants/DEFAULT_SETTINGS';
import { settingsSchema } from '../../shared/validation-schemas/settingsSchema';
import { type Settings } from '../../shared/types/Settings';
import { credentialsSchema } from '../validation-schemas/credentialsSchema';
import { type Credentials } from '../types/Credentials';
import { SettingsRecoveryError } from '../errors/SettingsRecoveryError';
export class SecureStore {
private handledWrite: Promise<void> = Promise.resolve();
constructor(private directory: string) {}
async readCredentials(): Promise<Credentials | null> {
try {
return credentialsSchema.parse(
JSON.parse(
safeStorage.decryptString(
await readFile(join(this.directory, 'credentials')),
),
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw new Error(
'Could not unlock your Twenty connection. Reconnect to Twenty.',
);
}
}
async writeCredentials(credentials: Credentials): Promise<void> {
if (!safeStorage.isEncryptionAvailable())
throw new Error(
'macOS Keychain is unavailable. Your connection could not be saved.',
);
await this.writeAtomic(
'credentials',
safeStorage.encryptString(JSON.stringify(credentials)),
);
}
async clearCredentials(): Promise<void> {
await unlink(join(this.directory, 'credentials')).catch(
(error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
},
);
}
async readSettings(): Promise<Settings> {
const path = join(this.directory, 'settings.json');
let contents: string;
try {
contents = await readFile(path, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
return { ...DEFAULT_SETTINGS };
throw new SettingsRecoveryError(
{ ...DEFAULT_SETTINGS, autoJoin: false, autoRecord: false },
'Could not read your settings. Automatic joining and recording are off. Check Settings before continuing.',
);
}
let saved: unknown;
try {
saved = JSON.parse(contents);
} catch {
saved = null;
}
const savedObject = z.record(z.string(), z.unknown()).safeParse(saved);
const migrated = settingsSchema.safeParse({
...DEFAULT_SETTINGS,
...(savedObject.success ? savedObject.data : {}),
});
if (savedObject.success && migrated.success) return migrated.data;
const recovered = {
...DEFAULT_SETTINGS,
autoJoin: false,
autoRecord: false,
};
if (savedObject.success) {
for (const [key, schema] of Object.entries(settingsSchema.shape)) {
const field = schema.safeParse(savedObject.data[key]);
if (field.success) Object.assign(recovered, { [key]: field.data });
}
}
// Keep the original available even if a later preference change saves the recovered settings.
await copyFile(path, `${path}.damaged-${randomUUID()}`);
throw new SettingsRecoveryError(
recovered,
'Some settings could not be restored. A backup was saved and valid preferences were kept. Review Settings before continuing.',
);
}
async writeSettings(settings: Settings): Promise<void> {
await this.writeAtomic(
'settings.json',
JSON.stringify(settingsSchema.parse(settings)),
);
}
async readHandledMeetings(): Promise<string[]> {
try {
return z
.array(z.string())
.parse(
JSON.parse(
await readFile(
join(this.directory, 'handled-meetings.json'),
'utf8',
),
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw new Error(
'Could not restore skipped meetings. Automatic joining is off. Review Settings before continuing.',
{ cause: error },
);
}
}
async writeHandledMeetings(ids: string[]): Promise<void> {
const contents = JSON.stringify(ids.slice(-500));
const write = this.handledWrite.then(() =>
this.writeAtomic('handled-meetings.json', contents),
);
this.handledWrite = write.catch(() => undefined);
await write;
}
private async writeAtomic(
name: string,
data: string | Buffer,
): Promise<void> {
await mkdir(this.directory, { recursive: true, mode: 0o700 });
const path = join(this.directory, name);
const temporaryPath = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(temporaryPath, data, { mode: 0o600 });
await rename(temporaryPath, path);
} finally {
await unlink(temporaryPath).catch(() => undefined);
}
}
}

View File

@@ -0,0 +1,272 @@
import { getFunctionsBaseUrl } from '../../../../twenty-front/src/modules/settings/logic-functions/utils/getLogicFunctionHttpUrl';
import { type SecureStore } from './SecureStore';
import { type Credentials } from '../types/Credentials';
import { refreshCredentials } from '../utils/refreshCredentials';
import { requestJson } from '../utils/requestJson';
import { validateServerUrl } from '../utils/validateServerUrl';
import { TwentyRequestError } from '../errors/TwentyRequestError';
import { DesktopRecorderUnavailableError } from '../errors/DesktopRecorderUnavailableError';
import { type RecordingConfiguration } from '../../shared/types/RecordingConfiguration';
import { type Agenda } from '../../shared/types/Agenda';
import { getImageAbsoluteURI } from '../../../../twenty-shared/src/utils/image/getImageAbsoluteURI';
type ConnectionContext = {
endpoint: string;
workspaceUrl: string;
workspace: Agenda['workspace'];
};
const resolveImageUrl = (
imageUrl: string | null | undefined,
baseUrl: string,
): string | null => {
if (!imageUrl) return null;
try {
const url = new URL(getImageAbsoluteURI({ imageUrl, baseUrl }), baseUrl);
validateServerUrl(url.origin);
return !url.username && !url.password ? url.href : null;
} catch {
return null;
}
};
export class TwentyClient {
private credentials: Credentials | null = null;
private context: ConnectionContext | null = null;
get workspaceUrl() {
return this.context?.workspaceUrl ?? null;
}
get workspace() {
return this.context?.workspace ?? null;
}
private refreshing: Promise<void> | null = null;
private credentialWrite: Promise<void> = Promise.resolve();
private connectionVersion = 0;
constructor(
private store: Pick<
SecureStore,
'readCredentials' | 'writeCredentials' | 'clearCredentials'
>,
) {}
resolveImageUrl(imageUrl?: string | null): string | null {
return this.credentials
? resolveImageUrl(imageUrl, this.credentials.serverUrl)
: null;
}
async restore(): Promise<Credentials | null> {
const connectionVersion = this.connectionVersion;
const credentials = await this.store.readCredentials();
if (connectionVersion === this.connectionVersion)
this.credentials = credentials;
return this.credentials;
}
async connect(credentials: Credentials, signal?: AbortSignal): Promise<void> {
const previousConnectionVersion = this.connectionVersion;
const context = await this.discoverEndpoint(credentials, signal);
try {
const configuration = await requestJson<RecordingConfiguration>(
context.endpoint,
{
signal,
method: 'POST',
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'configuration' }),
},
);
validateServerUrl(configuration.apiUrl);
} catch (error) {
if (error instanceof TwentyRequestError && error.status === 404)
throw new DesktopRecorderUnavailableError(credentials.serverUrl);
throw error;
}
signal?.throwIfAborted();
if (previousConnectionVersion !== this.connectionVersion)
throw new Error('Your workspace connection changed. Try again.');
const connectionVersion = ++this.connectionVersion;
const previous = this.credentials;
const previousContext = this.context;
this.refreshing = null;
this.credentials = null;
this.context = null;
try {
await this.persistCredentials(async () => {
signal?.throwIfAborted();
await this.store.writeCredentials(credentials);
if (signal?.aborted) {
if (connectionVersion === this.connectionVersion) {
if (previous) await this.store.writeCredentials(previous);
else await this.store.clearCredentials();
}
signal.throwIfAborted();
}
});
if (connectionVersion === this.connectionVersion) {
this.credentials = credentials;
this.context = context;
}
} catch (error) {
if (connectionVersion === this.connectionVersion) {
this.credentials = previous;
this.context = previousContext;
}
throw error;
}
}
async disconnect(): Promise<void> {
const credentials = this.credentials;
this.connectionVersion++;
this.refreshing = null;
this.credentials = null;
this.context = null;
await this.persistCredentials(() => this.store.clearCredentials());
if (credentials) {
await fetch(`${credentials.serverUrl}/oauth/revoke`, {
method: 'POST',
body: new URLSearchParams({
token: credentials.refreshToken,
client_id: credentials.clientId,
}),
signal: AbortSignal.timeout(5000),
redirect: 'error',
}).catch(() => undefined);
}
}
private persistCredentials(write: () => Promise<void>): Promise<void> {
const pending = this.credentialWrite.then(write);
this.credentialWrite = pending.catch(() => undefined);
return pending;
}
async companion<TResponse>(body: object): Promise<TResponse> {
const connectionVersion = this.connectionVersion;
if (!this.credentials)
throw new Error('Connect your Twenty workspace first.');
if (this.credentials.expiresAt < Date.now() + 60_000) {
if (!this.refreshing) {
const refreshing = this.refresh().finally(() => {
if (this.refreshing === refreshing) this.refreshing = null;
});
this.refreshing = refreshing;
}
await this.refreshing;
}
if (!this.credentials || connectionVersion !== this.connectionVersion)
throw new Error('Reconnect your Twenty workspace.');
if (!this.context) {
const context = await this.discoverEndpoint(this.credentials);
if (connectionVersion === this.connectionVersion) this.context = context;
}
if (
!this.context ||
!this.credentials ||
connectionVersion !== this.connectionVersion
)
throw new Error('Reconnect your Twenty workspace.');
const serverUrl = this.credentials.serverUrl;
try {
const response = await requestJson<TResponse>(this.context.endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.credentials.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (connectionVersion !== this.connectionVersion)
throw new Error('Your workspace connection changed. Try again.');
return response;
} catch (error) {
if (error instanceof TwentyRequestError && error.status === 404) {
throw new DesktopRecorderUnavailableError(serverUrl);
}
throw error;
}
}
private async discoverEndpoint(
credentials: Credentials,
signal?: AbortSignal,
): Promise<ConnectionContext> {
const [configuration, metadata] = await Promise.all([
requestJson<{ publicFunctionDomain?: string | null }>(
`${credentials.serverUrl}/client-config`,
{ signal },
),
requestJson<{
errors?: { message: string }[];
data: {
currentWorkspace: {
id: string;
displayName: string | null;
logo?: string | null;
subdomain: string;
workspaceUrls: { subdomainUrl: string; customUrl?: string | null };
};
};
}>(`${credentials.serverUrl}/metadata`, {
signal,
method: 'POST',
headers: {
Authorization: `Bearer ${credentials.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query:
'query CompanionWorkspace { currentWorkspace { id displayName logo subdomain workspaceUrls { subdomainUrl customUrl } } }',
}),
}),
]);
if (metadata.errors?.length) {
throw new Error(
'Twenty could not load your workspace. Try refreshing, or reconnect in Settings if this continues.',
);
}
const { publicFunctionDomain } = configuration;
const { currentWorkspace } = metadata.data;
const workspace: Agenda['workspace'] = {
id: currentWorkspace.id,
name: currentWorkspace.displayName ?? currentWorkspace.subdomain,
};
const logoUrl = resolveImageUrl(
currentWorkspace.logo,
credentials.serverUrl,
);
if (logoUrl) workspace.logoUrl = logoUrl;
const workspaceUrl = validateServerUrl(
currentWorkspace.workspaceUrls.customUrl ??
currentWorkspace.workspaceUrls.subdomainUrl,
);
const functionsBaseUrl = getFunctionsBaseUrl({
serverBaseUrl: credentials.serverUrl,
publicFunctionDomain,
workspaceSubdomain: currentWorkspace.subdomain,
});
validateServerUrl(new URL(functionsBaseUrl).origin);
return {
endpoint: `${functionsBaseUrl}/companion/desktop`,
workspace,
workspaceUrl,
};
}
private async refresh(): Promise<void> {
const previous = this.credentials;
if (!previous) return;
const credentials = await refreshCredentials(previous);
if (this.credentials !== previous) return;
await this.persistCredentials(async () => {
if (this.credentials !== previous) return;
await this.store.writeCredentials(credentials);
if (this.credentials === previous) this.credentials = credentials;
});
}
}

View File

@@ -0,0 +1,996 @@
import { createDeferred } from '../../utils/createDeferred';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS } from '../../../shared/constants/DEFAULT_SETTINGS';
import { nativeTheme } from 'electron';
const mocks = vi.hoisted(() => ({
listeners: new Map<string, (event: Record<string, unknown>) => void>(),
init: vi.fn(),
shutdown: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
openApp: vi.fn(),
focusApp: vi.fn(),
writeSettings: vi.fn(),
prepare: vi.fn(),
permission: vi.fn(),
request: vi.fn(),
open: vi.fn(),
restore: vi.fn(),
handled: vi.fn(),
connect: vi.fn(),
oauth: vi.fn(),
accessibilityTrusted: vi.fn(),
loginSettings: vi.fn(),
registerShortcut: vi.fn(),
readSettings: vi.fn(),
notification: vi.fn(),
showNotification: vi.fn(),
}));
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/twenty-companion-test',
focus: mocks.focusApp,
setLoginItemSettings: mocks.loginSettings,
},
nativeTheme: { themeSource: 'system' },
shell: { openExternal: mocks.open },
systemPreferences: {
isTrustedAccessibilityClient: mocks.accessibilityTrusted,
},
Notification: class {
constructor(options: unknown) {
mocks.notification(options);
}
static isSupported() {
return true;
}
on = vi.fn();
show = mocks.showNotification;
},
}));
vi.mock('@recallai/desktop-sdk', () => ({
default: {
init: mocks.init,
shutdown: mocks.shutdown,
startRecording: mocks.start,
stopRecording: mocks.stop,
pauseRecording: mocks.pause,
resumeRecording: mocks.resume,
prepareDesktopAudioRecording: mocks.prepare,
requestPermission: mocks.permission,
removeEventListener: (name: string) => mocks.listeners.delete(name),
addEventListener: (
name: string,
callback: (event: Record<string, unknown>) => void,
) => mocks.listeners.set(name, callback),
},
}));
vi.mock('../SecureStore', async (importOriginal) => ({
...(await importOriginal<typeof import('../SecureStore')>()),
SecureStore: class {
readSettings = mocks.readSettings;
readHandledMeetings = async () => [];
writeHandledMeetings = mocks.handled;
writeSettings = mocks.writeSettings;
},
}));
vi.mock('../../utils/connectOAuth', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/connectOAuth')>()),
connectOAuth: mocks.oauth,
}));
vi.mock('../TwentyClient', () => ({
TwentyClient: class {
workspaceUrl = 'https://acme.twenty.com';
restore = mocks.restore;
connect = mocks.connect;
companion = mocks.request;
disconnect = vi.fn();
},
}));
import { Companion } from '../Companion';
const originalPlatform = process.platform;
const originalArch = process.arch;
const originalGetSystemVersionDescriptor = Object.getOwnPropertyDescriptor(
process,
'getSystemVersion',
);
let companion: Companion;
beforeEach(async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
});
Object.defineProperty(process, 'arch', {
value: 'arm64',
configurable: true,
});
vi.clearAllMocks();
vi.useFakeTimers();
mocks.listeners.clear();
mocks.accessibilityTrusted.mockReturnValue(false);
mocks.readSettings.mockResolvedValue({ ...DEFAULT_SETTINGS });
mocks.writeSettings.mockResolvedValue(undefined);
Object.defineProperty(process, 'getSystemVersion', {
configurable: true,
value: () => '15.0.0',
});
mocks.restore.mockResolvedValue({ serverUrl: 'https://api.twenty.com' });
mocks.request.mockImplementation(async (body: { action: string }) => {
if (body.action === 'configuration')
return { apiUrl: 'https://eu-central-1.recall.ai' };
if (body.action === 'agenda')
return {
workspace: { id: 'workspace-1', name: 'Acme' },
meetings: [],
recordings: [],
calendarConnected: true,
};
if (body.action === 'create-upload')
return {
callRecordingId: 'recording-1',
uploadToken: 'ephemeral-upload-token',
apiUrl: 'https://eu-central-1.recall.ai',
};
return {};
});
mocks.prepare.mockResolvedValue('system-audio-window');
mocks.stop.mockResolvedValue(undefined);
mocks.start.mockResolvedValue(undefined);
companion = new Companion(
() => undefined,
mocks.openApp,
mocks.registerShortcut,
);
await companion.initialize();
});
afterEach(async () => {
await companion.shutdown();
vi.useRealTimers();
Object.defineProperty(process, 'platform', { value: originalPlatform });
Object.defineProperty(process, 'arch', { value: originalArch });
if (originalGetSystemVersionDescriptor === undefined) {
Reflect.deleteProperty(process, 'getSystemVersion');
} else {
Object.defineProperty(
process,
'getSystemVersion',
originalGetSystemVersionDescriptor,
);
}
});
const allowPermissions = () => {
mocks.accessibilityTrusted.mockReturnValue(true);
for (const permission of ['microphone', 'accessibility', 'system-audio'])
mocks.listeners.get('permission-status')?.({
permission,
status: 'granted',
});
};
describe('desktop capture lifecycle', () => {
it('opens the recording library on the workspace frontend', async () => {
await companion.command({ type: 'open-recordings' });
expect(mocks.open).toHaveBeenCalledWith(
'https://acme.twenty.com/objects/callRecordings',
);
});
it('brings Companion forward after authorization while the workspace finishes connecting', async () => {
const authorization = createDeferred();
const workspace = createDeferred<void>();
mocks.oauth.mockReturnValueOnce(authorization.promise);
mocks.connect.mockReturnValueOnce(workspace.promise);
const pending = companion.command({
type: 'connect',
serverUrl: 'https://first.twenty.com',
});
expect(mocks.openApp).not.toHaveBeenCalled();
authorization.resolve({ serverUrl: 'https://first.twenty.com' });
await vi.waitFor(() => expect(mocks.connect).toHaveBeenCalled());
expect(mocks.openApp).toHaveBeenCalledTimes(1);
if (process.platform === 'darwin')
expect(mocks.focusApp).toHaveBeenCalledWith({ steal: true });
expect(companion.state.connection).toBe('connecting');
workspace.resolve();
await pending;
expect(companion.state.connection).toBe('connected');
});
it('cancels a pending sign-in and allows another workspace attempt', async () => {
mocks.oauth.mockImplementation(
(_url: string, _open: unknown, signal: AbortSignal) =>
new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('Canceled'))),
),
);
const pending = companion.command({
type: 'connect',
serverUrl: 'https://first.twenty.com',
});
expect(companion.state.connection).toBe('connecting');
await companion.command({ type: 'cancel-connect' });
await pending;
expect(companion.state.connection).not.toBe('connecting');
expect(companion.state.error).toBeNull();
expect(mocks.connect).not.toHaveBeenCalled();
const retry = companion.command({
type: 'connect',
serverUrl: 'https://second.twenty.com',
});
expect(mocks.oauth).toHaveBeenLastCalledWith(
'https://second.twenty.com',
expect.any(Function),
expect.any(AbortSignal),
);
await companion.command({ type: 'cancel-connect' });
await retry;
});
it('ignores credentials returned after cancellation', async () => {
const result = createDeferred();
mocks.oauth.mockReturnValue(result.promise);
const pending = companion.command({
type: 'connect',
serverUrl: 'https://first.twenty.com',
});
await companion.command({ type: 'cancel-connect' });
result.resolve({ serverUrl: 'https://first.twenty.com' });
await pending;
expect(mocks.connect).not.toHaveBeenCalled();
expect(companion.state.error).toBeNull();
expect(mocks.openApp).not.toHaveBeenCalled();
expect(mocks.focusApp).not.toHaveBeenCalled();
});
it('registers saved shortcuts and persists a replacement', async () => {
expect(mocks.registerShortcut).toHaveBeenCalledWith(
DEFAULT_SETTINGS.openShortcut,
);
await companion.command({
type: 'settings',
settings: { openShortcut: 'Control+Alt+K' },
});
expect(mocks.registerShortcut).toHaveBeenLastCalledWith('Control+Alt+K');
expect(companion.state.settings.openShortcut).toBe('Control+Alt+K');
expect(mocks.writeSettings).toHaveBeenLastCalledWith(
expect.objectContaining({ openShortcut: 'Control+Alt+K' }),
);
});
it('keeps the previous shortcut when registration fails', async () => {
mocks.registerShortcut.mockImplementationOnce(() => {
throw new Error('Shortcut in use');
});
await companion.command({
type: 'settings',
settings: { openShortcut: 'Control+Alt+K' },
});
expect(companion.state.settings.openShortcut).toBe(
DEFAULT_SETTINGS.openShortcut,
);
expect(companion.state.error?.message).toBe('Shortcut in use');
});
it('restores the shortcut if saving fails', async () => {
mocks.writeSettings.mockRejectedValueOnce(new Error('Cannot save'));
await companion.command({
type: 'settings',
settings: { openShortcut: 'Control+Alt+K' },
});
expect(mocks.registerShortcut).toHaveBeenLastCalledWith(
DEFAULT_SETTINGS.openShortcut,
);
expect(companion.state.settings.openShortcut).toBe(
DEFAULT_SETTINGS.openShortcut,
);
});
it('applies saved appearance at startup and changes it without changing launch at login', async () => {
await companion.shutdown();
mocks.readSettings.mockResolvedValue({
...DEFAULT_SETTINGS,
appearance: 'dark',
});
await companion.initialize();
expect(nativeTheme.themeSource).toBe('dark');
await companion.command({
type: 'settings',
settings: { ...companion.state.settings, appearance: 'light' },
});
expect(nativeTheme.themeSource).toBe('light');
expect(mocks.writeSettings).toHaveBeenLastCalledWith(
expect.objectContaining({ appearance: 'light' }),
);
expect(mocks.loginSettings).not.toHaveBeenCalled();
await companion.command({
type: 'settings',
settings: { ...companion.state.settings, launchAtLogin: true },
});
expect(mocks.loginSettings).toHaveBeenCalledWith({
openAtLogin: true,
});
});
it('retains the current settings when saving a preference fails', async () => {
mocks.writeSettings.mockRejectedValueOnce(
new Error('Could not save preferences'),
);
await companion.command({
type: 'settings',
settings: { ...companion.state.settings, appearance: 'dark' },
});
expect(companion.state.settings.appearance).toBe('system');
expect(nativeTheme.themeSource).toBe('system');
expect(companion.state.error?.message).toBe('Could not save preferences');
});
it('mutes call detection prompts without disabling detection or recording interruption alerts', async () => {
const event = {
window: {
id: 'call-window',
title: 'Quick call',
url: 'https://meet.google.com/adhoc',
},
};
mocks.listeners.get('meeting-detected')?.(event);
expect(mocks.notification).toHaveBeenLastCalledWith(
expect.objectContaining({ title: 'Call detected' }),
);
await companion.command({
type: 'settings',
settings: { ...companion.state.settings, notifyOnDetectedCall: false },
});
mocks.notification.mockClear();
mocks.listeners.get('meeting-detected')?.(event);
expect(mocks.notification).not.toHaveBeenCalled();
expect(companion.state.detectedCalls).toHaveLength(1);
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('network-status')?.({ status: 'disconnected' });
expect(mocks.notification).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording interrupted' }),
);
});
it('restores meeting detection without requesting permissions at startup', () => {
expect(mocks.init).toHaveBeenCalledWith({
apiUrl: 'https://eu-central-1.recall.ai',
acquirePermissionsOnStartup: [],
});
});
it('does not obtain an upload until all capture permissions are granted', async () => {
await companion.command({ type: 'record' });
expect(companion.state.error).toBeNull();
expect(companion.state.permissionSetup).toEqual({
windowId: undefined,
intent: 'record',
});
expect(mocks.permission).not.toHaveBeenCalled();
expect(mocks.openApp).toHaveBeenCalledOnce();
expect(mocks.start).not.toHaveBeenCalled();
expect(
mocks.request.mock.calls.some(
([body]) => body.action === 'create-upload',
),
).toBe(false);
});
it('opens macOS settings for a previously denied permission without prompting again', async () => {
companion.state.permissions.microphone = 'denied';
await companion.command({ type: 'record' });
expect(mocks.open).not.toHaveBeenCalled();
await companion.command({ type: 'permission', permission: 'microphone' });
expect(mocks.permission).not.toHaveBeenCalled();
expect(mocks.open).toHaveBeenCalledWith(
'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone',
);
expect(companion.state.permissionSetup).not.toBeNull();
});
it('guides the user to the next missing permission and retains the selected call', async () => {
companion.state.permissions.microphone = 'granted';
await companion.command({ type: 'record', windowId: 'call-1' });
expect(mocks.permission).not.toHaveBeenCalled();
expect(companion.state.permissionSetup).toEqual({
windowId: 'call-1',
intent: 'record',
});
await companion.command({ type: 'permission', permission: 'system-audio' });
expect(mocks.permission).toHaveBeenCalledWith('system-audio');
});
it('does not start recording when permissions change, and lets the user dismiss setup', async () => {
await companion.command({ type: 'record' });
allowPermissions();
expect(mocks.start).not.toHaveBeenCalled();
expect(companion.state.permissionSetup).not.toBeNull();
await companion.command({ type: 'cancel-permission-setup' });
expect(companion.state.permissionSetup).toBeNull();
});
it('recognizes approval in System Settings without prompting or restarting capture', async () => {
companion.state.permissions.accessibility = 'denied';
mocks.accessibilityTrusted.mockReturnValue(true);
companion.refreshPermissions();
expect(companion.state.permissions.accessibility).toBe('granted');
expect(mocks.accessibilityTrusted).toHaveBeenLastCalledWith(false);
await companion.command({
type: 'permission',
permission: 'accessibility',
});
expect(mocks.open).not.toHaveBeenCalled();
expect(mocks.permission).not.toHaveBeenCalled();
expect(mocks.shutdown).not.toHaveBeenCalled();
expect(mocks.start).not.toHaveBeenCalled();
});
it('updates the open setup guide after approval even with auto-join disabled', async () => {
await companion.command({ type: 'record' });
companion.state.settings.autoJoin = false;
mocks.accessibilityTrusted.mockReturnValue(true);
await vi.advanceTimersByTimeAsync(1000);
expect(companion.state.permissions.accessibility).toBe('granted');
expect(companion.state.permissions.microphone).toBe('not_requested');
expect(companion.state.permissions['system-audio']).toBe('not_requested');
expect(mocks.start).not.toHaveBeenCalled();
});
it('does not let a delayed SDK denial overwrite current macOS approval', () => {
mocks.accessibilityTrusted.mockReturnValue(true);
mocks.listeners.get('permission-status')?.({
permission: 'accessibility',
status: 'denied',
});
expect(companion.state.permissions.accessibility).toBe('granted');
});
it('recognizes revoked native permissions and blocks a new recording', async () => {
allowPermissions();
mocks.accessibilityTrusted.mockReturnValue(false);
await companion.command({ type: 'record' });
expect(companion.state.permissions.accessibility).toBe('denied');
expect(companion.state.permissionSetup).not.toBeNull();
expect(mocks.start).not.toHaveBeenCalled();
await companion.command({
type: 'permission',
permission: 'accessibility',
});
expect(mocks.open).toHaveBeenCalledWith(
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
);
});
it.each([
['microphone', 'Privacy_Microphone'],
['system-audio', 'Privacy_ScreenCapture'],
['accessibility', 'Privacy_Accessibility'],
] as const)(
'opens Settings for denied %s even when SDK initialization fails',
async (permission, pane) => {
await companion.shutdown();
mocks.init.mockRejectedValueOnce(new Error('SDK unavailable'));
await companion.initialize();
expect(companion.state.error).not.toBeNull();
companion.state.permissions[permission] = 'denied';
mocks.init.mockClear();
await companion.command({ type: 'permission', permission });
expect(mocks.init).not.toHaveBeenCalled();
expect(mocks.permission).not.toHaveBeenCalled();
expect(mocks.open).toHaveBeenLastCalledWith(
`x-apple.systempreferences:com.apple.preference.security?${pane}`,
);
expect(companion.state.error).toBeNull();
},
);
it.each(['microphone', 'system-audio', 'accessibility'] as const)(
'requests the native %s prompt before offering Settings after a denial',
async (permission) => {
mocks.permission.mockImplementationOnce(async () => {
mocks.listeners.get('permission-status')?.({
permission,
status: 'denied',
});
});
await companion.command({ type: 'permission', permission });
expect(mocks.permission).toHaveBeenCalledWith(permission);
expect(mocks.open).not.toHaveBeenCalled();
expect(companion.state.permissions[permission]).toBe('denied');
await companion.command({ type: 'permission', permission });
expect(mocks.permission).toHaveBeenCalledOnce();
const pane = {
microphone: 'Privacy_Microphone',
'system-audio': 'Privacy_ScreenCapture',
accessibility: 'Privacy_Accessibility',
}[permission];
expect(mocks.open).toHaveBeenCalledWith(
`x-apple.systempreferences:com.apple.preference.security?${pane}`,
);
},
);
it('records system audio with a server upload token and keeps that token out of renderer state', async () => {
allowPermissions();
await companion.command({ type: 'record' });
expect(mocks.prepare).toHaveBeenCalledOnce();
expect(mocks.start).toHaveBeenCalledWith({
windowId: 'system-audio-window',
uploadToken: 'ephemeral-upload-token',
});
expect(JSON.stringify(companion.state)).not.toContain(
'ephemeral-upload-token',
);
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
expect(companion.state.activeRecording?.status).toBe('recording');
await companion.command({ type: 'stop' });
expect(mocks.stop).toHaveBeenCalledWith({
windowId: 'system-audio-window',
});
mocks.listeners.get('recording-ended')?.({
window: { id: 'system-audio-window' },
});
expect(companion.state.activeRecording).toBeNull();
expect(companion.state.notice?.type).toBe('recording-finished');
});
it.each([
['zoom', 'https://zoom.us/j/123456789'],
['microsoft_teams', 'https://teams.microsoft.com/l/meetup-join/call'],
['google_meet', 'https://meet.google.com/abc-defg-hij'],
['slack', undefined],
])(
'records a detected %s call with its own capture window',
async (platform, url) => {
allowPermissions();
mocks.listeners.get('meeting-detected')?.({
window: { id: 'provider-window', title: 'Team call', platform, url },
});
await companion.command({ type: 'record', windowId: 'provider-window' });
expect(mocks.prepare).not.toHaveBeenCalled();
expect(mocks.start).toHaveBeenCalledWith({
windowId: 'provider-window',
uploadToken: 'ephemeral-upload-token',
});
expect(mocks.request).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create-upload',
platform,
meetingUrl: url,
}),
);
},
);
it('blocks a matching bot call but allows one manual recording during an unrelated calendar event', async () => {
allowPermissions();
companion.state.settings.autoRecord = true;
companion.state.meetings = [
{
id: 'event-1',
title: 'Scheduled',
startsAt: new Date(Date.now() - 1000).toISOString(),
endsAt: new Date(Date.now() + 60_000).toISOString(),
url: 'https://meet.google.com/scheduled',
recordingEnabled: true,
usesCalendarBot: true,
},
];
mocks.listeners.get('meeting-detected')?.({
window: {
id: 'window-1',
url: 'https://meet.google.com/scheduled',
title: 'Scheduled',
platform: 'google_meet',
},
});
await companion.command({ type: 'record', windowId: 'window-1' });
expect(mocks.start).not.toHaveBeenCalled();
await Promise.all([
companion.command({ type: 'record' }),
companion.command({ type: 'record' }),
]);
expect(mocks.start).toHaveBeenCalledOnce();
expect(mocks.prepare).toHaveBeenCalledOnce();
expect(mocks.request).toHaveBeenCalledWith(
expect.objectContaining({
action: 'create-upload',
platform: 'desktop-audio',
calendarEventId: undefined,
meetingUrl: undefined,
}),
);
});
it('stops an uncertain start before reporting capture failure', async () => {
allowPermissions();
mocks.start.mockRejectedValueOnce(new Error('Start timed out'));
await companion.command({ type: 'record' });
expect(mocks.stop).toHaveBeenCalledWith({
windowId: 'system-audio-window',
});
expect(companion.state.activeRecording).toBeNull();
expect(mocks.request).toHaveBeenCalledWith(
expect.objectContaining({ action: 'capture-failed' }),
);
});
it('shows network interruption without claiming the recording has uploaded', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('network-status')?.({ status: 'disconnected' });
expect(companion.state.activeRecording).not.toBeNull();
expect(companion.state.notice?.type).toBe('network-lost');
await vi.advanceTimersByTimeAsync(0);
expect(mocks.stop).toHaveBeenCalledOnce();
expect(companion.state.activeRecording).toBeNull();
mocks.listeners.get('network-status')?.({ status: 'reconnected' });
expect(companion.state.notice?.type).toBe('network-restored');
expect(mocks.start).toHaveBeenCalledOnce();
});
it('cannot complete setup until all permissions are enabled and never records just by continuing', async () => {
await companion.command({ type: 'complete-setup' });
expect(companion.state.settings.setupCompleted).toBe(false);
expect(mocks.writeSettings).not.toHaveBeenCalled();
allowPermissions();
await companion.command({ type: 'complete-setup' });
expect(companion.state.settings.setupCompleted).toBe(true);
expect(mocks.writeSettings).toHaveBeenCalledWith(
expect.objectContaining({ setupCompleted: true }),
);
expect(mocks.start).not.toHaveBeenCalled();
});
it('opens permission review without prompting or creating a recording', async () => {
await companion.command({ type: 'begin-permission-setup' });
expect(companion.state.permissionSetup).toEqual({});
expect(mocks.openApp).toHaveBeenCalledOnce();
expect(mocks.permission).not.toHaveBeenCalled();
expect(mocks.start).not.toHaveBeenCalled();
});
it('pauses and resumes the same recording and excludes paused time', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
await companion.command({ type: 'pause' });
expect(mocks.pause).toHaveBeenCalledWith({
windowId: 'system-audio-window',
});
expect(companion.state.activeRecording?.status).toBe('paused');
expect(companion.isRecording).toBe(true);
await vi.advanceTimersByTimeAsync(10_000);
await companion.command({ type: 'resume' });
expect(mocks.resume).toHaveBeenCalledWith({
windowId: 'system-audio-window',
});
expect(companion.state.activeRecording?.status).toBe('recording');
expect(companion.state.activeRecording?.pausedMilliseconds).toBe(10_000);
expect(companion.state.activeRecording?.pausedAt).toBeUndefined();
expect(mocks.start).toHaveBeenCalledOnce();
});
it('does not report a successful pause when Recall rejects it', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
mocks.pause.mockRejectedValueOnce(new Error('Could not pause capture'));
await companion.command({ type: 'pause' });
expect(companion.state.activeRecording?.status).toBe('recording');
expect(companion.state.error?.message).toBe('Could not pause capture');
});
it('can finish a paused recording and retains the pause if stopping fails', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
await companion.command({ type: 'pause' });
mocks.stop.mockRejectedValueOnce(new Error('Stop failed'));
await companion.command({ type: 'stop' });
expect(companion.state.activeRecording?.status).toBe('paused');
expect(companion.isRecording).toBe(true);
await companion.command({ type: 'stop' });
expect(companion.state.activeRecording).toBeNull();
expect(companion.isRecording).toBe(false);
});
it('does not restore a recording that ended while its pause request was in flight', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
let finishPause: () => void = () => undefined;
mocks.pause.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishPause = resolve;
}),
);
const pending = companion.command({ type: 'pause' });
mocks.listeners.get('recording-ended')?.({
window: { id: 'system-audio-window' },
});
finishPause();
await pending;
expect(companion.state.activeRecording).toBeNull();
});
it('ignores expected audio interruption events during a pause', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-started')?.({
window: { id: 'system-audio-window' },
});
await companion.command({ type: 'pause' });
mocks.listeners.get('media-capture-status')?.({
window: { id: 'system-audio-window' },
type: 'audio',
capturing: false,
});
expect(companion.state.notice).toBeNull();
});
it('keeps a finished recording visible while its webhook catches up, then accepts completion', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.listeners.get('recording-ended')?.({
window: { id: 'system-audio-window' },
});
await vi.advanceTimersByTimeAsync(0);
expect(companion.state.recordings[0]).toMatchObject({
id: 'recording-1',
status: 'PROCESSING',
});
mocks.request.mockResolvedValueOnce({
workspace: { id: 'workspace-1', name: 'Acme' },
meetings: [],
calendarConnected: true,
recordings: [
{
id: 'recording-1',
title: 'Unscheduled conversation',
status: 'COMPLETED',
},
],
});
await companion.refresh();
expect(companion.state.recordings).toHaveLength(1);
expect(companion.state.recordings[0].status).toBe('COMPLETED');
});
});
it('merges simultaneous preference changes and does not reset omitted values', async () => {
const { commandSchema } =
await import('../../../shared/validation-schemas/commandSchema');
await Promise.all([
companion.command(
commandSchema.parse({
type: 'settings',
settings: { appearance: 'dark' },
}),
),
companion.command(
commandSchema.parse({ type: 'settings', settings: { autoJoin: false } }),
),
]);
expect(companion.state.settings).toMatchObject({
appearance: 'dark',
autoJoin: false,
});
expect(mocks.writeSettings).toHaveBeenLastCalledWith(
expect.objectContaining({ appearance: 'dark', autoJoin: false }),
);
});
it('keeps controls when stopping after a network failure fails and allows retry', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.stop.mockRejectedValueOnce(new Error('Could not stop capture'));
mocks.listeners.get('network-status')?.({ status: 'disconnected' });
await vi.advanceTimersByTimeAsync(0);
expect(companion.state.activeRecording?.status).toBe('recording');
expect(companion.state.error?.message).toBe('Could not stop capture');
await companion.command({ type: 'stop' });
expect(companion.state.activeRecording).toBeNull();
await companion.command({ type: 'record' });
expect(mocks.start).toHaveBeenCalledOnce();
expect(companion.state.error?.message).toContain('Reconnect to the internet');
});
it('clears workspace data and stops detecting calls after disconnect', async () => {
companion.state.updatedAt = new Date().toISOString();
companion.state.recordings = [
{ id: 'recording-1', title: 'Private', status: 'COMPLETED' },
];
mocks.listeners.get('meeting-detected')?.({ window: { id: 'call-1' } });
await companion.command({ type: 'disconnect' });
mocks.listeners.get('meeting-detected')?.({ window: { id: 'late-call' } });
expect(companion.state).toMatchObject({
connection: 'disconnected',
workspace: null,
recordings: [],
detectedCalls: [],
calendarConnected: false,
updatedAt: null,
});
});
it('waits for a pending setup before shutting down and never starts capture after quitting', async () => {
allowPermissions();
const { promise, resolve } = createDeferred<string>();
mocks.prepare.mockReturnValueOnce(promise);
const recording = companion.command({ type: 'record' });
await vi.advanceTimersByTimeAsync(0);
expect(companion.isRecording).toBe(true);
const shutdown = companion.shutdown();
resolve('system-audio-window');
await Promise.all([recording, shutdown]);
expect(mocks.start).not.toHaveBeenCalled();
expect(companion.state.activeRecording).toBeNull();
expect(mocks.listeners.size).toBe(0);
});
it('waits for pause to settle before stopping and retains a usable state if stop fails', async () => {
allowPermissions();
await companion.command({ type: 'record' });
const { promise, resolve } = createDeferred<void>();
mocks.pause.mockReturnValueOnce(promise);
const pause = companion.command({ type: 'pause' });
const stop = companion.command({ type: 'stop' });
mocks.stop.mockRejectedValueOnce(new Error('Stop failed'));
resolve();
await Promise.all([pause, stop]);
expect(companion.state.activeRecording?.status).toBe('paused');
await companion.command({ type: 'stop' });
expect(companion.state.activeRecording).toBeNull();
});
it('keeps controls usable if quitting fails to stop a recording', async () => {
allowPermissions();
await companion.command({ type: 'record' });
mocks.stop.mockRejectedValueOnce(new Error('Stop failed'));
await expect(companion.shutdown()).rejects.toThrow('Stop failed');
await companion.command({ type: 'stop' });
expect(companion.state.activeRecording).toBeNull();
});
it('does not switch workspaces while recording setup is pending', async () => {
allowPermissions();
const prepare = createDeferred<string>();
mocks.prepare.mockReturnValueOnce(prepare.promise);
const record = companion.command({ type: 'record' });
await vi.advanceTimersByTimeAsync(0);
await companion.command({
type: 'connect',
serverUrl: 'https://other.twenty.com',
});
expect(mocks.oauth).not.toHaveBeenCalled();
expect(companion.state.workspace?.name).toBe('Acme');
prepare.resolve('system-audio-window');
await record;
});
it('keeps the existing workspace usable when a new login is canceled', async () => {
mocks.oauth.mockRejectedValueOnce(new Error('Connection canceled'));
await companion.command({
type: 'connect',
serverUrl: 'https://other.twenty.com',
});
expect(companion.state.connection).toBe('connected');
expect(companion.state.workspace?.name).toBe('Acme');
expect(mocks.shutdown).not.toHaveBeenCalled();
});
it('does not save a new login that finishes after quitting', async () => {
const oauth = createDeferred<{ serverUrl: string }>();
mocks.oauth.mockReturnValueOnce(oauth.promise);
const connect = companion.command({
type: 'connect',
serverUrl: 'https://other.twenty.com',
});
await companion.shutdown();
oauth.resolve({ serverUrl: 'https://other.twenty.com' });
await connect;
expect(mocks.connect).not.toHaveBeenCalled();
});
it('initializes with recovered settings and reports the recovery after loading the workspace', async () => {
await companion.shutdown();
const { SettingsRecoveryError } =
await import('../../errors/SettingsRecoveryError');
mocks.readSettings.mockRejectedValueOnce(
new SettingsRecoveryError(
{
...DEFAULT_SETTINGS,
autoJoin: false,
autoRecord: false,
appearance: 'dark',
},
'Review recovered settings',
),
);
await companion.initialize();
expect(companion.state.connection).toBe('connected');
expect(companion.state.settings).toMatchObject({
autoJoin: false,
autoRecord: false,
appearance: 'dark',
});
expect(companion.state.error?.message).toBe('Review recovered settings');
expect(mocks.writeSettings).not.toHaveBeenCalled();
});
it('keeps the installation recovery action with the failure', async () => {
const { DesktopRecorderUnavailableError } =
await import('../../errors/DesktopRecorderUnavailableError');
mocks.request.mockRejectedValueOnce(
new DesktopRecorderUnavailableError('https://acme.twenty.com'),
);
await companion.command({ type: 'refresh' });
expect(companion.state.error).toMatchObject({
recovery: {
type: 'open-desktop-installation',
serverUrl: 'https://acme.twenty.com',
},
});
await companion.command({ type: 'dismiss-error' });
expect(companion.state.error).toBeNull();
});
it('disconnects consistently if resetting the SDK fails after credentials were saved', async () => {
mocks.oauth.mockResolvedValueOnce({ serverUrl: 'https://other.twenty.com' });
mocks.shutdown.mockRejectedValueOnce(new Error('SDK reset failed'));
await companion.command({
type: 'connect',
serverUrl: 'https://other.twenty.com',
});
expect(companion.state.connection).toBe('disconnected');
expect(companion.state.workspace).toBeNull();
expect(companion.state.meetings).toEqual([]);
expect(companion.state.error?.message).toBe('SDK reset failed');
});
it('retries a failed browser launch on the next auto-join tick', async () => {
companion.state.settings.setupCompleted = true;
companion.state.meetings = [
{
id: 'event-1',
title: 'Meeting',
startsAt: new Date(Date.now()).toISOString(),
endsAt: new Date(Date.now() + 60_000).toISOString(),
url: 'https://meet.google.com/test',
recordingEnabled: true,
usesCalendarBot: false,
},
];
mocks.open.mockRejectedValueOnce(new Error('Browser unavailable'));
await vi.advanceTimersByTimeAsync(1000);
expect(mocks.handled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(mocks.open).toHaveBeenCalledTimes(2);
expect(mocks.handled).toHaveBeenCalledOnce();
});
it('allows a later detection update to retry after capture fails', async () => {
allowPermissions();
companion.state.settings.setupCompleted = true;
companion.state.settings.autoRecord = true;
const event = {
window: {
id: 'retry-call',
title: 'Meeting',
url: 'https://meet.google.com/test',
platform: 'google_meet',
},
};
mocks.start.mockRejectedValueOnce(new Error('Capture unavailable'));
mocks.listeners.get('meeting-detected')?.(event);
await vi.advanceTimersByTimeAsync(0);
expect(mocks.start).toHaveBeenCalledOnce();
expect(companion.state.activeRecording).toBeNull();
mocks.listeners.get('meeting-updated')?.(event);
await vi.advanceTimersByTimeAsync(0);
expect(mocks.start).toHaveBeenCalledTimes(2);
expect(companion.state.activeRecording?.windowId).toBe('retry-call');
});

View File

@@ -0,0 +1,117 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS } from '../../../shared/constants/DEFAULT_SETTINGS';
import { SecureStore } from '../SecureStore';
import { SettingsRecoveryError } from '../../errors/SettingsRecoveryError';
vi.mock('electron', () => ({ safeStorage: {} }));
let directory: string;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'companion-settings-'));
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
describe('saved preferences', () => {
it('adds new defaults to existing installations without resetting their choices', async () => {
await writeFile(
join(directory, 'settings.json'),
JSON.stringify({
autoJoin: false,
autoRecord: true,
launchAtLogin: true,
setupCompleted: true,
}),
);
expect(await new SecureStore(directory).readSettings()).toEqual({
...DEFAULT_SETTINGS,
autoJoin: false,
autoRecord: true,
launchAtLogin: true,
setupCompleted: true,
});
});
it('restores custom appearance and notification preferences from disk', async () => {
const settings = {
...DEFAULT_SETTINGS,
appearance: 'dark' as const,
notifyOnDetectedCall: false,
showMeetingCountdown: false,
};
await new SecureStore(directory).writeSettings(settings);
expect(await new SecureStore(directory).readSettings()).toEqual(settings);
});
});
it('returns isolated defaults for separate instances', async () => {
const store = new SecureStore(directory);
const settings = await store.readSettings();
settings.autoRecord = true;
expect((await store.readSettings()).autoRecord).toBe(false);
});
it('keeps valid preferences and a backup when an individual setting is invalid', async () => {
const { readdir, readFile } = await import('node:fs/promises');
const original = JSON.stringify({
...DEFAULT_SETTINGS,
appearance: 'sepia',
autoJoin: false,
launchAtLogin: true,
});
await writeFile(join(directory, 'settings.json'), original);
const error = await new SecureStore(directory)
.readSettings()
.catch((error: unknown) => error);
expect(error).toBeInstanceOf(SettingsRecoveryError);
expect((error as SettingsRecoveryError).settings).toMatchObject({
appearance: 'system',
autoJoin: false,
launchAtLogin: true,
});
const backup = (await readdir(directory)).find((name) =>
name.startsWith('settings.json.damaged-'),
);
expect(backup).toBeDefined();
expect(await readFile(join(directory, backup!), 'utf8')).toBe(original);
});
it('does not enable automation when the saved file is malformed', async () => {
await writeFile(join(directory, 'settings.json'), '{');
const error = await new SecureStore(directory)
.readSettings()
.catch((error: unknown) => error);
expect(error).toBeInstanceOf(SettingsRecoveryError);
expect((error as SettingsRecoveryError).settings).toMatchObject({
autoJoin: false,
autoRecord: false,
});
});
it('reports read failures instead of treating them as a new installation', async () => {
const { mkdir } = await import('node:fs/promises');
await mkdir(join(directory, 'settings.json'));
await expect(new SecureStore(directory).readSettings()).rejects.toThrow(
'Could not read your settings',
);
});
it('does not forget skipped meetings when their file is corrupted', async () => {
await writeFile(join(directory, 'handled-meetings.json'), '{broken');
await expect(
new SecureStore(directory).readHandledMeetings(),
).rejects.toThrow('Automatic joining is off');
});
it('preserves the newest skipped-meeting snapshot across concurrent writes', async () => {
const store = new SecureStore(directory);
await Promise.all([
store.writeHandledMeetings(['first']),
store.writeHandledMeetings(['first', 'second']),
store.writeHandledMeetings(['second']),
]);
expect(await store.readHandledMeetings()).toEqual(['second']);
});

View File

@@ -0,0 +1,426 @@
import { createDeferred } from '../../utils/createDeferred';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type Agenda } from '../../../shared/types/Agenda';
import { TwentyClient } from '../TwentyClient';
import { DesktopRecorderUnavailableError } from '../../errors/DesktopRecorderUnavailableError';
const fetchMock = vi.fn();
const credentials = {
serverUrl: 'https://crm.twenty.com',
clientId: 'client-1',
accessToken: 'test-access',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3_600_000,
};
const store = {
readCredentials: vi.fn(),
clearCredentials: vi.fn(),
writeCredentials: vi.fn(),
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal('fetch', fetchMock);
vi.mocked(store.readCredentials).mockResolvedValue(credentials);
});
afterEach(() => vi.unstubAllGlobals());
const discovery = (logo?: string) =>
fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/client-config'))
return json({ publicFunctionDomain: 'withtwenty.com' });
if (url.endsWith('/metadata'))
return json({
data: {
currentWorkspace: {
id: 'workspace-1',
displayName: 'Twenty',
logo,
subdomain: 'twenty',
workspaceUrls: {
subdomainUrl: 'https://twenty.twenty.com/',
customUrl: 'https://crm.twenty.com/',
},
},
},
});
return json({ message: 'No Route trigger found' }, 404);
});
describe('cloud custom-domain connection', () => {
it('does not save a new account when the recording backend is unavailable', async () => {
discovery();
const client = new TwentyClient(store);
await client.restore();
await expect(
client.connect({ ...credentials, clientId: 'new-client' }),
).rejects.toThrow('Install it to continue');
expect(store.writeCredentials).not.toHaveBeenCalled();
await client.disconnect();
expect(fetchMock).toHaveBeenLastCalledWith(
`${credentials.serverUrl}/oauth/revoke`,
expect.objectContaining({
body: new URLSearchParams({
token: credentials.refreshToken,
client_id: credentials.clientId,
}),
}),
);
});
it('resolves the workspace logo using Twenty image URLs', async () => {
discovery('workspace-logo.png?token=signed-image-token');
const client = new TwentyClient(store);
await client.restore();
await client.companion<Agenda>({ action: 'agenda' }).catch(() => undefined);
expect(client.workspace?.logoUrl).toBe(
'https://crm.twenty.com/files/workspace-logo.png?token=signed-image-token',
);
expect(fetchMock).toHaveBeenCalledWith(
'https://crm.twenty.com/metadata',
expect.objectContaining({
body: expect.stringContaining('displayName logo'),
}),
);
});
it('ignores image URLs that cannot be loaded safely', async () => {
discovery('https://user:password@example.com/logo.png');
const client = new TwentyClient(store);
await client.restore();
await client.companion<Agenda>({ action: 'agenda' }).catch(() => undefined);
expect(client.workspace).toEqual({ id: 'workspace-1', name: 'Twenty' });
});
it('uses the workspace function domain and distinguishes a missing backend from failed login', async () => {
discovery();
const client = new TwentyClient(store);
await client.restore();
await expect(
client.companion<Agenda>({ action: 'agenda' }),
).rejects.toThrow(
'Desktop Recorder is unavailable in this workspace. Install it to continue.',
);
expect(fetchMock).toHaveBeenCalledWith(
'https://twenty.withtwenty.com/companion/desktop',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'agenda' }),
redirect: 'error',
}),
);
expect(client.workspace).toEqual({ id: 'workspace-1', name: 'Twenty' });
expect(client.workspaceUrl).toBe('https://crm.twenty.com');
});
it('does not misdiagnose a missing metadata endpoint as an uninstalled companion', async () => {
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/client-config')
? json({ publicFunctionDomain: 'withtwenty.com' })
: json({}, 404),
);
const client = new TwentyClient(store);
await client.restore();
await expect(
client.companion<Agenda>({ action: 'agenda' }),
).rejects.toThrow('404 for /metadata');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('handles GraphQL errors returned with HTTP 200', async () => {
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/client-config')
? json({ publicFunctionDomain: 'withtwenty.com' })
: json({ errors: [{ message: 'Unauthorized' }] }),
);
const client = new TwentyClient(store);
await client.restore();
await expect(
client.companion<Agenda>({ action: 'agenda' }),
).rejects.toThrow('Twenty could not load your workspace');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('returns the personal agenda once the backend is available', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/companion/desktop')
? json({
workspace: { id: 'workspace-1', name: 'Twenty' },
meetings: [],
recordings: [],
calendarConnected: true,
})
: fallback?.(url),
);
const client = new TwentyClient(store);
await client.restore();
expect(await client.companion<Agenda>({ action: 'agenda' })).toEqual({
workspace: { id: 'workspace-1', name: 'Twenty' },
recordings: [],
meetings: [],
calendarConnected: true,
});
});
});
it('does not restore a signed-out session when a token refresh finishes late', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
const refresh = createDeferred<Response>();
const requested = createDeferred<void>();
fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/oauth/token')) {
requested.resolve();
return refresh.promise;
}
return url.endsWith('/oauth/revoke') ? json({}) : fallback?.(url);
});
vi.mocked(store.readCredentials).mockResolvedValue({
...credentials,
expiresAt: 0,
});
const client = new TwentyClient(store);
await client.restore();
const request = client
.companion<Agenda>({ action: 'agenda' })
.catch((error: unknown) => error);
await requested.promise;
await client.disconnect();
refresh.resolve(
json({
access_token: 'rotated',
refresh_token: 'rotated-refresh',
expires_in: 3600,
}),
);
expect(await request).toBeInstanceOf(Error);
expect(store.writeCredentials).not.toHaveBeenCalled();
await expect(client.companion<Agenda>({ action: 'agenda' })).rejects.toThrow(
'Connect your Twenty workspace',
);
});
it('clears credentials after an in-flight refresh write, preserving disk sign-out', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/oauth/token')
? json({
access_token: 'rotated',
refresh_token: 'rotated-refresh',
expires_in: 3600,
})
: url.endsWith('/oauth/revoke')
? json({})
: fallback?.(url),
);
const writing = createDeferred<void>();
const finishWrite = createDeferred<void>();
vi.mocked(store.readCredentials).mockResolvedValue({
...credentials,
expiresAt: 0,
});
vi.mocked(store.writeCredentials).mockImplementationOnce(() => {
writing.resolve();
return finishWrite.promise;
});
const client = new TwentyClient(store);
await client.restore();
const request = client
.companion<Agenda>({ action: 'agenda' })
.catch((error: unknown) => error);
await writing.promise;
const disconnect = client.disconnect();
expect(store.clearCredentials).not.toHaveBeenCalled();
finishWrite.resolve();
await disconnect;
expect(await request).toBeInstanceOf(Error);
expect(store.clearCredentials).toHaveBeenCalledOnce();
expect(client.workspace).toBeNull();
});
it('accepts null timestamps while a recording is being prepared', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop')
? json({
workspace: { id: 'workspace-1', name: 'Twenty' },
meetings: [],
calendarConnected: true,
recordings: [
{
id: 'recording-1',
title: 'Pending conversation',
status: 'JOINING',
startedAt: null,
endedAt: null,
calendarEventId: null,
},
],
})
: fallback?.(url),
);
const client = new TwentyClient(store);
await client.restore();
expect(
(await client.companion<Agenda>({ action: 'agenda' })).recordings[0],
).toMatchObject({ status: 'JOINING', startedAt: null });
});
it.each([401, 403, 500, 503])(
'does not suggest reinstalling after HTTP %s',
async (status) => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop') ? json({}, status) : fallback?.(url),
);
const client = new TwentyClient(store);
await expect(client.connect(credentials)).rejects.toThrow(
`Twenty returned ${status}`,
);
expect(store.writeCredentials).not.toHaveBeenCalled();
},
);
it.each([
new TypeError('Failed to fetch'),
new DOMException('Request timed out', 'TimeoutError'),
])('preserves connection failures: %s', async (error) => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/desktop')) throw error;
return fallback?.(url);
});
const client = new TwentyClient(store);
await expect(client.connect(credentials)).rejects.toBe(error);
expect(store.writeCredentials).not.toHaveBeenCalled();
});
it('does not suggest reinstalling for an invalid configuration response', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop') ? json({}) : fallback?.(url),
);
const error = await new TwentyClient(store)
.connect(credentials)
.catch((error: unknown) => error);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(DesktopRecorderUnavailableError);
expect(store.writeCredentials).not.toHaveBeenCalled();
});
it('offers installation after sign-in when the backend route is missing', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop')
? json({ message: 'No Route trigger found' }, 404)
: fallback?.(url),
);
await expect(
new TwentyClient(store).connect(credentials),
).rejects.toBeInstanceOf(DesktopRecorderUnavailableError);
expect(store.writeCredentials).not.toHaveBeenCalled();
});
it('reuses validated workspace discovery after saving a connection', async () => {
discovery('logo.png');
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) =>
url.endsWith('/desktop')
? json(
JSON.parse(String(init?.body)).action === 'configuration'
? { apiUrl: 'https://eu-central-1.recall.ai' }
: {
workspace: { id: 'workspace-1', name: 'Twenty' },
meetings: [],
recordings: [],
calendarConnected: true,
},
)
: fallback?.(url),
);
const client = new TwentyClient(store);
await client.connect(credentials);
expect(client.workspace?.logoUrl).toBe(
'https://crm.twenty.com/files/logo.png',
);
await client.companion<Agenda>({ action: 'agenda' });
expect(
fetchMock.mock.calls.filter(([url]) => url.endsWith('/metadata')),
).toHaveLength(1);
expect(
fetchMock.mock.calls.filter(([url]) => url.endsWith('/client-config')),
).toHaveLength(1);
});
it('preserves the current workspace when saving a replacement connection fails', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop')
? json({ apiUrl: 'https://eu-central-1.recall.ai' })
: fallback?.(url),
);
const client = new TwentyClient(store);
await client.connect(credentials);
const workspace = client.workspace;
vi.mocked(store.writeCredentials).mockRejectedValueOnce(
new Error('Keychain unavailable'),
);
await expect(
client.connect({ ...credentials, clientId: 'replacement' }),
).rejects.toThrow('Keychain unavailable');
expect(client.workspace).toEqual(workspace);
expect(client.workspaceUrl).toBe('https://crm.twenty.com');
});
it('aborts the configuration probe without saving credentials', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
const controller = new AbortController();
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (!url.endsWith('/desktop')) return fallback?.(url);
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true },
);
controller.abort();
});
});
await expect(
new TwentyClient(store).connect(credentials, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
expect(store.writeCredentials).not.toHaveBeenCalled();
});
it('restores the previous account when canceled during the credential write', async () => {
discovery();
const fallback = fetchMock.getMockImplementation();
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/desktop')
? json({ apiUrl: 'https://eu-central-1.recall.ai' })
: fallback?.(url),
);
const client = new TwentyClient(store);
await client.connect(credentials);
const controller = new AbortController();
vi.mocked(store.writeCredentials).mockImplementationOnce(async () => {
controller.abort();
});
await expect(
client.connect(
{ ...credentials, clientId: 'replacement' },
controller.signal,
),
).rejects.toMatchObject({ name: 'AbortError' });
expect(store.writeCredentials).toHaveBeenLastCalledWith(credentials);
expect(client.workspace?.id).toBe('workspace-1');
});

View File

@@ -0,0 +1,4 @@
import { z } from 'zod';
import { credentialsSchema } from '../validation-schemas/credentialsSchema';
export type Credentials = z.infer<typeof credentialsSchema>;

View File

@@ -0,0 +1,6 @@
export type OAuthDiscovery = {
issuer: string;
authorization_endpoint: string;
cli_client_id?: string;
authorization_response_iss_parameter_supported?: boolean;
};

View File

@@ -0,0 +1,5 @@
export type TokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number;
};

View File

@@ -0,0 +1,232 @@
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { connectOAuth } from '../connectOAuth';
import { getDesktopInstallationUrl } from '../getDesktopInstallationUrl';
import { validateServerUrl } from '../validateServerUrl';
const servers: ReturnType<typeof createServer>[] = [];
const listen = async (server: ReturnType<typeof createServer>) => {
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Missing address');
return `http://127.0.0.1:${address.port}`;
};
afterEach(async () => {
vi.unstubAllGlobals();
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
),
);
});
describe('Twenty OAuth', () => {
it('links to the integration installation page on the discovered frontend', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
issuer: 'https://api.twenty.com',
authorization_endpoint: 'https://app.twenty.com/authorize',
}),
),
),
);
await expect(
getDesktopInstallationUrl('https://api.twenty.com'),
).resolves.toBe(
'https://app.twenty.com/settings/applications/available/8bdaaa9f-dc53-4247-a89b-aa386c9b3244',
);
});
it('does not open the browser or register an app when CLI discovery is missing', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
issuer: 'https://api.twenty.com',
authorization_endpoint: 'https://app.twenty.com/authorize',
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const openBrowser = vi.fn();
await expect(
connectOAuth('https://api.twenty.com', openBrowser),
).rejects.toThrow('does not support CLI sign-in');
expect(openBrowser).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('opens setup on the discovered frontend when the input is an API host', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
issuer: 'https://api.twenty.com',
authorization_endpoint:
'https://app.twenty.com/authorize?iss=https%3A%2F%2Fapi.twenty.com',
}),
),
),
);
await expect(
getDesktopInstallationUrl('https://api.twenty.com'),
).resolves.toBe(
'https://app.twenty.com/settings/applications/available/8bdaaa9f-dc53-4247-a89b-aa386c9b3244',
);
});
it('uses the existing CLI client with state validation and PKCE through real HTTP boundaries', async () => {
let origin = '';
let challenge = '';
let issuedToken = false;
const server = createServer(async (request, response) => {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const body = Buffer.concat(chunks).toString();
response.setHeader('Content-Type', 'application/json');
if (request.url === '/.well-known/oauth-authorization-server')
response.end(
JSON.stringify({
issuer: origin,
cli_client_id: 'cli-client',
authorization_endpoint: `${origin}/authorize?iss=${encodeURIComponent(origin)}`,
authorization_response_iss_parameter_supported: true,
}),
);
else if (request.url === '/oauth/register') {
throw new Error('Desktop sign-in must not register an OAuth-only app');
} else if (request.url === '/oauth/token') {
const parameters = new URLSearchParams(body);
expect(parameters.get('client_secret')).toBeNull();
expect(parameters.get('client_id')).toBe('cli-client');
expect(
createHash('sha256')
.update(parameters.get('code_verifier') ?? '')
.digest('base64url'),
).toBe(challenge);
expect(parameters.get('code')).toBe('test-code');
issuedToken = true;
response.end(
JSON.stringify({
access_token: 'access',
refresh_token: 'refresh',
expires_in: 3600,
}),
);
} else response.writeHead(404).end('{}');
});
origin = await listen(server);
const credentials = await connectOAuth(origin, async (value) => {
const authorization = new URL(value);
expect(authorization.pathname).toBe('/authorize');
expect(authorization.searchParams.get('iss')).toBe(origin);
challenge = authorization.searchParams.get('code_challenge') ?? '';
const callback = new URL(
authorization.searchParams.get('redirect_uri') ?? '',
);
callback.search = new URLSearchParams({
state: 'wrong',
code: 'test-code',
iss: origin,
}).toString();
expect((await fetch(callback)).status).toBe(400);
expect(issuedToken).toBe(false);
callback.searchParams.set(
'state',
authorization.searchParams.get('state') ?? '',
);
const response = await fetch(callback);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.text()).toBe(
'Returning to Twenty… You can close this tab.',
);
});
expect(credentials.accessToken).toBe('access');
expect(issuedToken).toBe(true);
});
it('only accepts HTTPS origins and explicit loopback development origins', () => {
expect(validateServerUrl('https://acme.twenty.com/')).toBe(
'https://acme.twenty.com',
);
expect(validateServerUrl('http://localhost:3000')).toBe(
'http://localhost:3000',
);
for (const url of [
'http://crm.example.com',
'https://user:pass@crm.example.com',
'https://crm.example.com/path',
'https://crm.example.com?key=secret',
])
expect(() => validateServerUrl(url)).toThrow();
});
});
it.each(['discovery', 'token'])(
'cancels a stalled OAuth %s request',
async (phase) => {
const controller = new AbortController();
let origin = '';
const server = createServer((request, response) => {
if (phase === 'discovery' || request.url === '/oauth/token') {
controller.abort();
return;
}
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
issuer: origin,
cli_client_id: 'cli-client',
authorization_endpoint: `${origin}/authorize`,
}),
);
});
origin = await listen(server);
const openBrowser = vi.fn(async (url: string) => {
const authorization = new URL(url);
const callback = new URL(authorization.searchParams.get('redirect_uri')!);
callback.searchParams.set(
'state',
authorization.searchParams.get('state')!,
);
callback.searchParams.set('code', 'code');
await fetch(callback);
});
await expect(
connectOAuth(origin, openBrowser, controller.signal),
).rejects.toMatchObject({ name: 'AbortError' });
expect(openBrowser).toHaveBeenCalledTimes(phase === 'discovery' ? 0 : 1);
},
);
it('keeps a request deadline when a caller supplies its own signal', async () => {
const { requestJson } = await import('../requestJson');
const caller = new AbortController();
const timeout = new AbortController();
const deadline = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(timeout.signal);
const server = createServer(() =>
timeout.abort(new DOMException('Timed out', 'TimeoutError')),
);
const origin = await listen(server);
try {
await expect(
requestJson(origin, {
signal: caller.signal,
}),
).rejects.toMatchObject({ name: 'TimeoutError' });
expect(caller.signal.aborted).toBe(false);
} finally {
deadline.mockRestore();
}
});

View File

@@ -0,0 +1,50 @@
import { afterEach, expect, it, vi } from 'vitest';
import { createDevelopmentRestart } from '../createDevelopmentRestart';
let dispose: (() => void) | undefined;
afterEach(() => {
dispose?.();
vi.useRealTimers();
});
it('restarts immediately while idle and coalesces repeated requests', () => {
vi.useFakeTimers();
const restart = vi.fn();
const controller = createDevelopmentRestart(() => false, restart);
dispose = controller.dispose;
controller.request();
controller.request();
expect(restart).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('waits through capture startup, recording, pause, and finishing', () => {
vi.useFakeTimers();
let busy = true;
const restart = vi.fn();
const controller = createDevelopmentRestart(() => busy, restart);
dispose = controller.dispose;
controller.request();
for (let build = 0; build < 3; build++) {
controller.request();
vi.advanceTimersByTime(30_000);
}
expect(restart).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(1);
busy = false;
vi.advanceTimersByTime(250);
expect(restart).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it('cancels a pending restart on disposal', () => {
vi.useFakeTimers();
const restart = vi.fn();
const controller = createDevelopmentRestart(() => true, restart);
dispose = controller.dispose;
controller.request();
controller.dispose();
vi.advanceTimersByTime(1000);
expect(restart).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});

View File

@@ -0,0 +1,347 @@
import { describe, expect, it, vi } from 'vitest';
import {
type MenuItemConstructorOptions,
type MenuItem,
type KeyboardEvent,
} from 'electron';
import { DEFAULT_SETTINGS } from '../../../shared/constants/DEFAULT_SETTINGS';
import { type CompanionState } from '../../../shared/types/CompanionState';
import { type Meeting } from '../../../shared/types/Meeting';
import { createTrayMenuTemplate } from '../createTrayMenuTemplate';
import { getTrayTitle } from '../getTrayTitle';
const NOW = Date.parse('2026-09-07T12:00:00Z');
const state = (overrides: Partial<CompanionState> = {}): CompanionState => ({
connection: 'connected',
serverUrl: 'https://api.twenty.com',
workspace: { id: 'workspace', name: 'Apple' },
meetings: [],
recordings: [],
calendarConnected: true,
detectedCalls: [],
activeRecording: null,
permissions: {
microphone: 'granted',
accessibility: 'granted',
'system-audio': 'granted',
},
settings: { ...DEFAULT_SETTINGS, setupCompleted: true },
skippedMeetingIds: [],
updatedAt: new Date(NOW).toISOString(),
error: null,
notice: null,
permissionSetup: null,
...overrides,
});
const meeting = (overrides: Partial<Meeting> = {}): Meeting => ({
id: 'f8e44a6f-c367-4003-8ab2-1cc8a22727cb',
title: 'Product catch-up',
startsAt: '2026-09-07T13:00:00Z',
endsAt: '2026-09-07T14:00:00Z',
url: 'https://meet.google.com/example',
recordingEnabled: true,
usesCalendarBot: true,
...overrides,
});
const actions = () => ({
command: vi.fn(),
openApp: vi.fn(),
setSetting: vi.fn(),
quit: vi.fn(),
});
const select = (menu: MenuItemConstructorOptions[], label: string) => {
const item = menu.find((entry) => entry.label === label);
expect(item, label).toBeDefined();
expect(item?.enabled, label).not.toBe(false);
item?.click?.({} as MenuItem, undefined, {} as KeyboardEvent);
};
describe('native tray menu actions', () => {
it('can hide the meeting countdown without hiding the active recording indicator', () => {
const current = state({ meetings: [meeting()] });
expect(getTrayTitle(current, NOW)).toBe('60m');
current.settings.showMeetingCountdown = false;
expect(getTrayTitle(current, NOW)).toBe('');
current.activeRecording = {
id: 'recording',
windowId: 'call',
title: 'Quick call',
startedAt: new Date(NOW).toISOString(),
status: 'recording',
};
expect(getTrayTitle(current, NOW)).toBe('●');
current.activeRecording.status = 'paused';
expect(getTrayTitle(current, NOW)).toBe('Paused');
});
it('shows the next meeting countdown when it is more than one hour away', () => {
expect(
getTrayTitle(
state({
meetings: [meeting({ startsAt: '2026-09-07T14:30:00Z' })],
}),
NOW,
),
).toBe('2h 30m');
});
it('opens setup without starting capture when permissions have not been set up', () => {
const handlers = actions();
const menu = createTrayMenuTemplate(
state({ settings: DEFAULT_SETTINGS }),
handlers,
NOW,
);
select(menu, 'Continue setup…');
expect(handlers.openApp).toHaveBeenCalledOnce();
expect(handlers.command).not.toHaveBeenCalled();
expect(menu.some((item) => item.label === 'New recording')).toBe(false);
});
it('offers connection before workspace actions when disconnected', () => {
const handlers = actions();
const menu = createTrayMenuTemplate(
state({ connection: 'disconnected' }),
handlers,
NOW,
);
select(menu, 'Connect workspace…');
expect(handlers.openApp).toHaveBeenCalledOnce();
expect(menu.some((item) => item.label === 'Recordings…')).toBe(false);
});
it('routes recording and settings to the existing app commands', () => {
const handlers = actions();
const menu = createTrayMenuTemplate(state(), handlers, NOW);
select(menu, 'New recording');
select(menu, 'Settings…');
expect(handlers.command).toHaveBeenCalledWith({ type: 'record' });
expect(handlers.openApp.mock.calls).toEqual([['settings']]);
});
it('allows resume and finish while paused, and locks controls during transitions', () => {
const handlers = actions();
const activeRecording = {
id: 'recording',
windowId: 'call',
title: 'Conversation',
startedAt: new Date(NOW).toISOString(),
status: 'paused' as const,
};
const menu = createTrayMenuTemplate(
state({ activeRecording }),
handlers,
NOW,
);
select(menu, 'Resume recording');
select(menu, 'Finish recording');
expect(handlers.command.mock.calls).toEqual([
[{ type: 'resume' }],
[{ type: 'stop' }],
]);
for (const status of [
'starting',
'pausing',
'resuming',
'stopping',
] as const) {
const changing = createTrayMenuTemplate(
state({ activeRecording: { ...activeRecording, status } }),
handlers,
NOW,
);
expect(
changing.find((item) => item.label === 'Finish recording')?.enabled,
).toBe(false);
expect(changing.some((item) => item.label === 'New recording')).toBe(
false,
);
}
});
it('keeps join and skip tied to the selected meeting and excludes past meetings', () => {
const handlers = actions();
const next = meeting();
const menu = createTrayMenuTemplate(
state({
meetings: [
meeting({ title: 'Past', endsAt: '2026-09-07T11:00:00Z' }),
next,
],
}),
handlers,
NOW,
);
const submenu = menu.find((item) =>
item.label?.includes(next.title),
)?.submenu;
expect(Array.isArray(submenu)).toBe(true);
select(submenu as MenuItemConstructorOptions[], 'Join meeting');
select(submenu as MenuItemConstructorOptions[], 'Skip auto-join');
expect(handlers.command.mock.calls).toEqual([
[{ type: 'join', meetingId: next.id }],
[{ type: 'skip', meetingId: next.id }],
]);
expect(menu.some((item) => item.label?.includes('Past'))).toBe(false);
const skipped = createTrayMenuTemplate(
state({ meetings: [next], skippedMeetingIds: [next.id] }),
handlers,
NOW,
);
const skippedSubmenu = skipped.find((item) =>
item.label?.includes(next.title),
)?.submenu as MenuItemConstructorOptions[];
expect(
skippedSubmenu.find((item) => item.label === 'Restore auto-join')
?.enabled,
).toBe(true);
select(skippedSubmenu, 'Restore auto-join');
expect(handlers.command).toHaveBeenLastCalledWith({
type: 'unskip',
meetingId: next.id,
});
});
it('updates individual preferences without overwriting the rest of the settings', () => {
const handlers = actions();
const menu = createTrayMenuTemplate(state(), handlers, NOW);
expect(
menu.find((item) => item.label === 'Automatically join meetings'),
).toMatchObject({ type: 'checkbox', checked: true });
select(menu, 'Automatically join meetings');
expect(handlers.setSetting.mock.calls).toEqual([['autoJoin', false]]);
expect(menu.some((item) => item.label === 'Launch at login')).toBe(false);
});
it('groups the next three events by local day with titles above their times', () => {
const localNow = new Date(2026, 8, 7, 12).getTime();
const at = (day: number, hour: number) =>
new Date(2026, 8, day, hour).toISOString();
const menu = createTrayMenuTemplate(
state({
meetings: [
meeting({
id: 'fourth',
title: 'Later',
startsAt: at(10, 10),
endsAt: at(10, 11),
}),
meeting({
id: 'tomorrow',
title: 'Planning',
startsAt: at(8, 10),
endsAt: at(8, 11),
}),
meeting({
id: 'today',
title: 'Design sync',
startsAt: at(7, 13),
endsAt: at(7, 14),
}),
meeting({
id: 'third',
title: 'Review',
startsAt: at(9, 10),
endsAt: at(9, 11),
}),
],
}),
actions(),
localNow,
{ supportsHeaders: true, supportsSublabels: true },
);
expect(
menu
.filter((item) => item.type === 'header')
.slice(0, 2)
.map((item) => item.label),
).toEqual(['Today', 'Tomorrow']);
const events = menu.filter((item) => item.submenu);
expect(events.map((item) => item.label)).toEqual([
'Design sync',
'Planning',
'Review',
]);
expect(events[0].sublabel).toContain('In 60 min');
expect(events[0].sublabel).toContain(' – ');
expect(events[0].accessibilityLabel).toContain('Today');
});
it('offers a direct join action for one imminent meeting without starting capture', () => {
const handlers = actions();
const next = meeting({
startsAt: new Date(NOW + 5 * 60_000).toISOString(),
});
const menu = createTrayMenuTemplate(
state({ meetings: [next] }),
handlers,
NOW,
);
select(menu, `Join ${next.title}`);
expect(handlers.command.mock.calls).toEqual([
[{ type: 'join', meetingId: next.id }],
]);
});
it('does not promote one event when several meetings overlap or a call is active', () => {
const imminent = meeting({
startsAt: new Date(NOW - 60_000).toISOString(),
});
for (const current of [
state({
meetings: [
imminent,
{ ...imminent, id: 'other', title: 'Other meeting' },
],
}),
state({
meetings: [imminent],
detectedCalls: [
{ id: 'call', title: 'Live conversation', platform: 'zoom' },
],
}),
]) {
const menu = createTrayMenuTemplate(current, actions(), NOW);
expect(menu.some((item) => item.label === `Join ${imminent.title}`)).toBe(
false,
);
}
});
it('retains event times on platforms without native sublabels and marks missing links', () => {
const current = state({
meetings: [meeting({ url: null, recordingEnabled: false })],
});
const menu = createTrayMenuTemplate(current, actions(), NOW);
const event = menu.find((item) => item.submenu);
expect(event?.sublabel).toBeUndefined();
expect(event?.label).toContain(' – ');
expect(event?.label).toContain('No meeting link');
const submenu = event?.submenu as MenuItemConstructorOptions[];
expect(
submenu.find((item) => item.label === 'No meeting link')?.enabled,
).toBe(false);
expect(submenu.some((item) => item.label === 'Recording off')).toBe(true);
expect(menu.some((item) => item.type === 'header')).toBe(false);
});
it('shows an ongoing event as Now and preserves the full title for accessibility', () => {
const title = 'Product & Design ' + 'review '.repeat(10);
const menu = createTrayMenuTemplate(
state({
meetings: [
meeting({ title, startsAt: new Date(NOW - 60_000).toISOString() }),
],
}),
actions(),
NOW,
{ supportsHeaders: true, supportsSublabels: true },
);
const event = menu.find((item) => item.submenu);
expect(event?.sublabel).toContain('Now');
expect(event?.label).toContain('…');
expect(event?.label).toContain('&&');
expect(event?.accessibilityLabel).toContain(title);
expect(event?.toolTip).toContain(title);
});
});

View File

@@ -0,0 +1,136 @@
import { type OAuthDiscovery } from '../types/OAuthDiscovery';
import { type TokenResponse } from '../types/TokenResponse';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { type Credentials } from '../types/Credentials';
import { validateServerUrl } from './validateServerUrl';
import { requestJson } from './requestJson';
export const connectOAuth = async (
serverUrlInput: string,
openBrowser: (url: string) => Promise<void>,
signal?: AbortSignal,
): Promise<Credentials> => {
const serverUrl = validateServerUrl(serverUrlInput);
const discovery = await requestJson<OAuthDiscovery>(
`${serverUrl}/.well-known/oauth-authorization-server`,
{ signal },
);
if (discovery.issuer !== serverUrl)
throw new Error('Twenty returned a different OAuth issuer.');
if (!discovery.cli_client_id)
throw new Error(
'This Twenty server does not support CLI sign-in. Update Twenty to connect.',
);
const clientId = discovery.cli_client_id;
const authorizationEndpoint = new URL(discovery.authorization_endpoint);
validateServerUrl(authorizationEndpoint.origin);
const verifier = randomBytes(32).toString('base64url');
const state = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
let resolveCode: (value: string) => void;
let rejectCode: (error: Error) => void;
const codePromise = new Promise<string>((resolve, reject) => {
resolveCode = resolve;
rejectCode = reject;
});
// Handle cancellation even if the browser takes longer than the callback deadline.
void codePromise.catch(() => undefined);
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
if (request.method !== 'GET' || url.pathname !== '/callback') {
response.writeHead(404).end();
return;
}
const receivedState = Buffer.from(url.searchParams.get('state') ?? '');
const expectedState = Buffer.from(state);
if (
receivedState.length !== expectedState.length ||
!timingSafeEqual(receivedState, expectedState)
) {
response.writeHead(400).end('Invalid authorization state.');
return;
}
if (
(discovery.authorization_response_iss_parameter_supported ||
url.searchParams.has('iss')) &&
url.searchParams.get('iss') !== discovery.issuer
) {
response.writeHead(400).end('Invalid authorization issuer.');
rejectCode(new Error('Twenty returned a different OAuth issuer.'));
return;
}
const code = url.searchParams.get('code');
response
.writeHead(200, {
'Content-Type': 'text/plain',
'Cache-Control': 'no-store',
})
.end(
code
? 'Returning to Twenty… You can close this tab.'
: 'Connection canceled. Return to Twenty.',
);
if (code) resolveCode(code);
else rejectCode(new Error('Twenty connection was canceled.'));
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, 'localhost', resolve);
});
const timer = setTimeout(
() => rejectCode(new Error('Connection timed out. Try connecting again.')),
180_000,
);
const abort = () => rejectCode(new Error('Connection canceled.'));
signal?.addEventListener('abort', abort, { once: true });
try {
signal?.throwIfAborted();
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('Could not start the OAuth callback.');
const redirectUri = `http://localhost:${address.port}/callback`;
const authorizationUrl = authorizationEndpoint;
const authorizationParameters = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: redirectUri,
scope: 'api profile',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
authorizationParameters.forEach((value, name) =>
authorizationUrl.searchParams.set(name, value),
);
signal?.throwIfAborted();
await openBrowser(authorizationUrl.href);
const code = await codePromise;
const result = await requestJson<TokenResponse>(
`${serverUrl}/oauth/token`,
{
method: 'POST',
signal,
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: verifier,
}),
},
);
return {
serverUrl,
clientId,
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
};
} finally {
signal?.removeEventListener('abort', abort);
clearTimeout(timer);
server.closeAllConnections();
server.close();
}
};

View File

@@ -0,0 +1,7 @@
export const createDeferred = <TData>() => {
let resolve!: (value: TData | PromiseLike<TData>) => void;
const promise = new Promise<TData>((settle) => {
resolve = settle;
});
return { promise, resolve };
};

View File

@@ -0,0 +1,25 @@
export const createDevelopmentRestart = (
isBusy: () => boolean,
restart: () => void,
) => {
let timer: ReturnType<typeof setInterval> | undefined;
let restarting = false;
const dispose = () => {
clearInterval(timer);
timer = undefined;
};
const attempt = () => {
if (isBusy()) return;
restarting = true;
dispose();
restart();
};
return {
request: () => {
if (timer || restarting) return;
timer = setInterval(attempt, 250);
attempt();
},
dispose,
};
};

View File

@@ -0,0 +1,258 @@
import { getUpcomingMeetings } from '../../shared/utils/getUpcomingMeetings';
import { type MenuItemConstructorOptions } from 'electron';
import { i18n } from '@lingui/core';
import { type CompanionCommand } from '../../shared/types/CompanionCommand';
import { type CompanionPage } from '../../shared/types/CompanionPage';
import { type CompanionState } from '../../shared/types/CompanionState';
import { type Meeting } from '../../shared/types/Meeting';
type TrayMenuActions = {
command: (command: CompanionCommand) => void;
openApp: (page?: CompanionPage) => void;
setSetting: (key: 'autoJoin', value: boolean) => void;
quit: () => void;
};
i18n.load('en', {});
i18n.activate('en');
const text = (message: string) => i18n._(message);
const menuLabel = (value: string) =>
value.replace(/[\r\n\t]/g, ' ').replace(/&/g, '&&');
type MenuPresentation = {
supportsSublabels: boolean;
supportsHeaders: boolean;
locale?: string;
};
const meetingTime = (meeting: Meeting, locale?: string) => {
const format = (value: string) =>
new Date(value).toLocaleTimeString(locale, {
hour: '2-digit',
minute: '2-digit',
});
return `${format(meeting.startsAt)} – ${format(meeting.endsAt)}`;
};
const meetingDay = (meeting: Meeting, now: number, locale?: string) => {
const date = new Date(meeting.startsAt);
const today = new Date(now);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
if (date.toDateString() === today.toDateString()) return text('Today');
if (date.toDateString() === tomorrow.toDateString()) return text('Tomorrow');
return date.toLocaleDateString(locale, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
};
export const createTrayMenuTemplate = (
state: CompanionState,
actions: TrayMenuActions,
now = Date.now(),
presentation: MenuPresentation = {
supportsSublabels: false,
supportsHeaders: false,
},
): MenuItemConstructorOptions[] => {
const connected = state.connection === 'connected';
const recording = state.activeRecording;
const age = now - Date.parse(state.updatedAt ?? '');
const ready = connected && age >= 0 && age <= 90_000;
const menu: MenuItemConstructorOptions[] = [
{
label: text('Open app'),
accelerator: state.settings.openShortcut,
click: () => actions.openApp(),
},
];
if (recording) {
const paused = recording.status === 'paused';
const stable = recording.status === 'recording' || paused;
menu.push(
{
label: menuLabel(recording.title),
enabled: false,
},
{
label: text(
{
starting: 'Starting recording…',
recording: 'Pause recording',
pausing: 'Pausing…',
paused: 'Resume recording',
resuming: 'Resuming…',
stopping: 'Finishing recording…',
}[recording.status],
),
enabled: stable,
click: () => actions.command({ type: paused ? 'resume' : 'pause' }),
},
{
label: text('Finish recording'),
enabled: stable,
click: () => actions.command({ type: 'stop' }),
},
);
} else if (!connected || !state.settings.setupCompleted) {
menu.push({
label: text(connected ? 'Continue setup…' : 'Connect workspace…'),
enabled: state.connection !== 'connecting',
click: () => actions.openApp(),
});
} else {
menu.push({
label: text('New recording'),
enabled: ready,
click: () => actions.command({ type: 'record' }),
});
}
if (connected) {
const meetings = getUpcomingMeetings({
meetings: state.meetings,
now: now,
}).slice(0, 3);
menu.push({ type: 'separator' });
if (meetings.length) {
const imminent = meetings.filter(
(meeting) =>
meeting.url && Date.parse(meeting.startsAt) <= now + 10 * 60_000,
);
if (
ready &&
imminent.length === 1 &&
!recording &&
!state.detectedCalls.length
) {
const next = imminent[0];
menu.push(
{
label: menuLabel(
`${text('Join')} ${next.title.length > 42 ? next.title.slice(0, 41) + '…' : next.title}`,
),
toolTip: next.title,
click: () => actions.command({ type: 'join', meetingId: next.id }),
},
{ type: 'separator' },
);
}
let previousDay = '';
for (const meeting of meetings) {
const day = meetingDay(meeting, now, presentation.locale);
if (day !== previousDay) {
if (previousDay) menu.push({ type: 'separator' });
menu.push({
label: day,
type: presentation.supportsHeaders ? 'header' : 'normal',
enabled: false,
});
previousDay = day;
}
const minutes = Math.ceil(
(Date.parse(meeting.startsAt) - now) / 60_000,
);
const when = meetingTime(meeting, presentation.locale);
const skipped = state.skippedMeetingIds.includes(meeting.id);
const title =
meeting.title.length > 42
? meeting.title.slice(0, 41) + '…'
: meeting.title;
const timing =
minutes <= 0
? text('Now')
: minutes <= 60
? `${text('In')} ${minutes} ${text('min')}`
: '';
const details = [
when,
timing,
skipped ? text('Auto-join skipped') : '',
!meeting.url ? text('No meeting link') : '',
]
.filter(Boolean)
.join(' · ');
menu.push({
label: menuLabel(
presentation.supportsSublabels ? title : `${details} · ${title}`,
),
...(presentation.supportsSublabels ? { sublabel: details } : {}),
accessibilityLabel: `${meeting.title}, ${day}, ${details}`,
toolTip: `${meeting.title}\n${day} · ${when}`,
submenu: [
{
label: text(meeting.url ? 'Join meeting' : 'No meeting link'),
enabled: ready && !!meeting.url,
click: () =>
actions.command({ type: 'join', meetingId: meeting.id }),
},
{
label: text(skipped ? 'Restore auto-join' : 'Skip auto-join'),
enabled:
ready &&
!!meeting.url &&
state.settings.autoJoin &&
Date.parse(meeting.startsAt) > now,
click: () =>
actions.command({
type: skipped ? 'unskip' : 'skip',
meetingId: meeting.id,
}),
},
{ type: 'separator' },
{
label: text(
!meeting.recordingEnabled
? 'Recording off'
: meeting.usesCalendarBot
? 'Calendar bot scheduled'
: 'Desktop recording available',
),
enabled: false,
},
],
});
}
} else {
menu.push({ label: text('No upcoming meetings'), enabled: false });
}
if (!state.calendarConnected) {
menu.push({
label: text('Connect a calendar…'),
click: () => actions.command({ type: 'open-calendar-settings' }),
});
}
}
if (state.error) {
menu.push({
label: text('Something needs your attention…'),
click: () => actions.openApp(),
});
}
menu.push(
{ type: 'separator' },
{
label: text('Automatically join meetings'),
type: 'checkbox',
checked: state.settings.autoJoin,
enabled: connected,
click: () => actions.setSetting('autoJoin', !state.settings.autoJoin),
},
{
label: text('Settings…'),
accelerator: 'CommandOrControl+,',
click: () => actions.openApp('settings'),
},
{ type: 'separator' },
{
label: text('Quit'),
click: actions.quit,
},
);
return menu;
};

View File

@@ -0,0 +1,18 @@
import { type OAuthDiscovery } from '../types/OAuthDiscovery';
import { validateServerUrl } from './validateServerUrl';
import { requestJson } from './requestJson';
export const getDesktopInstallationUrl = async (
serverUrlInput: string,
): Promise<string> => {
const serverUrl = validateServerUrl(serverUrlInput);
const discovery = await requestJson<OAuthDiscovery>(
`${serverUrl}/.well-known/oauth-authorization-server`,
);
if (discovery.issuer !== serverUrl)
throw new Error('Twenty returned a different OAuth issuer.');
const frontendUrl = validateServerUrl(
new URL(discovery.authorization_endpoint).origin,
);
return `${frontendUrl}/settings/applications/available/8bdaaa9f-dc53-4247-a89b-aa386c9b3244`;
};

View File

@@ -0,0 +1,21 @@
import { getUpcomingMeetings } from '../../shared/utils/getUpcomingMeetings';
import { type CompanionState } from '../../shared/types/CompanionState';
export const getTrayTitle = (
state: CompanionState,
now = Date.now(),
): string => {
if (state.activeRecording)
return state.activeRecording.status === 'paused' ? 'Paused' : '●';
if (!state.settings.showMeetingCountdown) return '';
const next = getUpcomingMeetings({ meetings: state.meetings, now: now })[0];
if (!next) return '';
const minutes = Math.max(
0,
Math.ceil((Date.parse(next.startsAt) - now) / 60_000),
);
if (minutes <= 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};

View File

@@ -0,0 +1,25 @@
import { type TokenResponse } from '../types/TokenResponse';
import { type Credentials } from '../types/Credentials';
import { requestJson } from './requestJson';
export const refreshCredentials = async (
credentials: Credentials,
): Promise<Credentials> => {
const result = await requestJson<TokenResponse>(
`${credentials.serverUrl}/oauth/token`,
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: credentials.refreshToken,
client_id: credentials.clientId,
}),
},
);
return {
...credentials,
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
};
};

View File

@@ -0,0 +1,18 @@
import { TwentyRequestError } from '../errors/TwentyRequestError';
export const requestJson = async <TResponse = unknown>(
url: string,
init: RequestInit = {},
): Promise<TResponse> => {
const response = await fetch(url, {
...init,
redirect: 'error',
signal: init.signal
? AbortSignal.any([init.signal, AbortSignal.timeout(30_000)])
: AbortSignal.timeout(30_000),
});
if (!response.ok) {
throw new TwentyRequestError(response.status, url);
}
return response.json();
};

View File

@@ -0,0 +1,17 @@
export const validateServerUrl = (value: string): string => {
const url = new URL(value);
const loopback = ['localhost', '127.0.0.1'].includes(url.hostname);
if (
(url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== '/'
) {
throw new Error(
'Enter your Twenty workspace URL, using HTTPS or localhost.',
);
}
return url.origin;
};

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const credentialsSchema = z.object({
serverUrl: z.string().url(),
clientId: z.string(),
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: z.number(),
});

View File

@@ -0,0 +1,5 @@
declare module '*.svg?react' {
import { type FunctionComponent, type SVGProps } from 'react';
const component: FunctionComponent<SVGProps<SVGSVGElement>>;
export default component;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="96px" height="96px" viewBox="0 0 96 96" version="1.1">
<defs>
<clipPath id="clip1">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip2">
<path d="M 19 26 L 48 26 L 48 45 L 19 45 Z M 19 26 "/>
</clipPath>
<clipPath id="clip3">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
<clipPath id="clip4">
<path d="M 19 26 L 77 26 L 77 70 L 19 70 Z M 19 26 "/>
</clipPath>
<clipPath id="clip5">
<path d="M 11.292969 0 L 84.707031 0 C 90.945312 0 96 5.054688 96 11.292969 L 96 84.707031 C 96 90.945312 90.945312 96 84.707031 96 L 11.292969 96 C 5.054688 96 0 90.945312 0 84.707031 L 0 11.292969 C 0 5.054688 5.054688 0 11.292969 0 Z M 11.292969 0 "/>
</clipPath>
</defs>
<g id="surface1">
<g clip-path="url(#clip1)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 96 0 L 0 0 L 0 96 L 96 96 Z M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
<g clip-path="url(#clip2)" clip-rule="nonzero">
<g clip-path="url(#clip3)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 19.25 35.746094 "/>
</g>
</g>
<g clip-path="url(#clip4)" clip-rule="nonzero">
<g clip-path="url(#clip5)" clip-rule="nonzero">
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(100%,100%,100%);fill-opacity:1;" d="M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><path fill="white" d="M 19.25 35.746094 C 19.25 30.5 23.507812 26.246094 28.75 26.246094 L 47.039062 26.246094 C 47.308594 26.246094 47.554688 26.40625 47.667969 26.652344 C 47.78125 26.902344 47.730469 27.191406 47.546875 27.394531 L 43.539062 31.75 C 42.839844 32.507812 41.859375 32.945312 40.828125 32.945312 L 28.800781 32.945312 C 27.226562 32.945312 25.949219 34.222656 25.949219 35.796875 L 25.949219 42.980469 C 25.949219 43.90625 25.199219 44.652344 24.273438 44.652344 L 20.929688 44.652344 C 20.003906 44.652344 19.257812 43.90625 19.257812 42.980469 L 19.257812 35.746094 Z M 19.25 35.746094 "/><path fill="white" d="M 76.152344 60.253906 C 76.152344 65.5 71.894531 69.753906 66.648438 69.753906 L 58.878906 69.753906 C 53.632812 69.753906 49.375 65.5 49.375 60.253906 L 49.375 46.652344 C 49.375 45.726562 49.722656 44.835938 50.351562 44.152344 L 54.882812 39.234375 C 55.074219 39.027344 55.371094 38.957031 55.636719 39.054688 C 55.898438 39.164062 56.074219 39.410156 56.074219 39.691406 L 56.074219 60.210938 C 56.074219 61.785156 57.351562 63.0625 58.925781 63.0625 L 66.605469 63.0625 C 68.179688 63.0625 69.457031 61.785156 69.457031 60.210938 L 69.457031 35.796875 C 69.457031 34.222656 68.179688 32.945312 66.605469 32.945312 L 57.675781 32.945312 C 56.652344 32.945312 55.679688 33.375 54.980469 34.121094 L 28.347656 63.0625 L 44.351562 63.0625 C 45.273438 63.0625 46.023438 63.8125 46.023438 64.738281 L 46.023438 68.082031 C 46.023438 69.007812 45.273438 69.753906 44.351562 69.753906 L 22.785156 69.753906 C 20.832031 69.753906 19.242188 68.167969 19.242188 66.210938 L 19.242188 64.441406 C 19.242188 63.550781 19.574219 62.695312 20.179688 62.039062 L 50.039062 29.605469 C 52.015625 27.457031 54.789062 26.246094 57.707031 26.246094 L 66.640625 26.246094 C 71.886719 26.246094 76.144531 30.5 76.144531 35.746094 L 76.144531 60.253906 Z M 76.152344 60.253906 "/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,25 @@
.label {
text-align: center;
width: var(--t-spacing-6);
font-family: var(--t-font-family);
font-size: var(--t-font-size-md);
color: var(--t-font-color-primary);
}
.weekday {
color: var(--t-font-color-tertiary);
font-size: var(--t-font-size-xxs);
font-weight: var(--t-font-weight-semi-bold);
}
.day {
font-weight: var(--t-font-weight-medium);
}
.indicator {
background: var(--t-tag-background-gray);
border-radius: var(--t-border-radius-xs);
height: 100%;
width: var(--t-spacing-1);
flex-shrink: 0;
}
.indicator[data-active] {
background: var(--t-tag-background-red);
}

View File

@@ -0,0 +1,7 @@
declare const styles: {
readonly indicator: string;
readonly label: string;
readonly weekday: string;
readonly day: string;
};
export default styles;

View File

@@ -0,0 +1,10 @@
import styles from './CalendarDayLabel.module.scss';
type CalendarDayLabelProps = { weekday: string; day: string };
export const CalendarDayLabel = ({ weekday, day }: CalendarDayLabelProps) => (
<div className={styles.label}>
<div className={styles.weekday}>{weekday}</div>
<div className={styles.day}>{day}</div>
</div>
);

View File

@@ -0,0 +1,15 @@
import { clsx } from 'clsx';
import styles from './CalendarDayLabel.module.scss';
type CalendarEventIndicatorProps = { active?: boolean; className?: string };
export const CalendarEventIndicator = ({
active,
className,
}: CalendarEventIndicatorProps) => (
<div
aria-hidden
className={clsx(styles.indicator, className)}
data-active={active || undefined}
/>
);

View File

@@ -0,0 +1,135 @@
import { i18n } from '@lingui/core';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import {
IconRefresh,
IconCircleDot,
IconHome,
IconSettings,
} from 'twenty-ui/icon';
import { NavigationModeSwitcher } from './NavigationModeSwitcher';
import { useCompanion } from '../hooks/useCompanion';
import { IS_PREVIEW } from '../constants/IS_PREVIEW';
import { Button } from '@ui/primitives/input/Button/Button';
import { Empty } from './Empty';
import { Notice } from './Notice';
import { RecordingControls } from './RecordingControls';
import { Onboarding } from './Onboarding';
import { Home } from './Home';
import { Settings } from './Settings';
import { HalftoneBackground } from './HalftoneBackground';
import { CompanionTheme } from './CompanionTheme';
export const CompanionApp = () => {
const { state, isPending, loaded, command, page, navigate } = useCompanion();
const connected = state.connection === 'connected';
const setup =
connected && (!state.settings.setupCompleted || !!state.permissionSetup);
const onboarding = !connected || setup;
return (
<CompanionTheme appearance={state.settings.appearance}>
<main className={'app-shell ' + (onboarding ? 'onboarding' : '')}>
<div className="main-area">
{onboarding && <HalftoneBackground />}
<header
className={
'titlebar ' +
(onboarding ? 'setup-titlebar ' : '') +
(window.companion && navigator.platform.startsWith('Mac')
? 'mac-titlebar'
: '')
}
>
{!onboarding && (
<div className="header-navigation">
<NavigationModeSwitcher
ariaLabel={i18n._('Navigation modes')}
size="medium"
activeItemName={page === 'settings' ? 'settings' : 'home'}
items={[
{
name: 'home',
label: i18n._('Home'),
Icon: IconHome,
onClick: () => navigate('agenda'),
},
{
name: 'settings',
label: i18n._('Settings'),
Icon: IconSettings,
onClick: () => navigate('settings'),
},
]}
/>
</div>
)}
{!onboarding && (
<div className="button-group">
<Button
variant="solid"
color="accent"
size="md"
elevated
disabled={
isPending('record') ||
!!state.activeRecording ||
!state.updatedAt
}
onClick={() => void command({ type: 'record' })}
startIcon={<IconCircleDot />}
>
{i18n._('New recording')}
</Button>
</div>
)}
</header>
{IS_PREVIEW && (
<div className="preview-label">
{i18n._(
'Interactive preview · sample data · no audio is captured',
)}
</div>
)}
<Notice state={state} command={command} isPending={isPending} />
<div className="scroll-area">
{!loaded ? (
<Empty
icon={
<IconRefresh
size={THEME_COMMON.icon.size.xl}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Opening your workspace')}
>
<p>{i18n._('Loading your calendar and conversations…')}</p>
</Empty>
) : onboarding ? (
<Onboarding
state={state}
isPending={isPending}
command={command}
/>
) : (
<div className="page-content">
<RecordingControls
state={state}
isPending={isPending}
command={command}
/>
{page === 'settings' ? (
<Settings
state={state}
isPending={isPending}
command={command}
/>
) : (
<Home state={state} isPending={isPending} command={command} />
)}
</div>
)}
</div>
</div>
</main>
</CompanionTheme>
);
};

View File

@@ -0,0 +1,30 @@
import { type ReactNode, useSyncExternalStore } from 'react';
import { ThemeProvider } from '@ui/theme-constants/ThemeProvider';
import '@ui/theme-constants/theme-light.css';
import '@ui/theme-constants/theme-dark.css';
import '@fontsource/inter/latin-400.css';
import '@fontsource/inter/latin-500.css';
import '@fontsource/inter/latin-600.css';
import { type Settings } from '../../shared/types/Settings';
const subscribe = (listener: () => void) => {
const query = window.matchMedia('(prefers-color-scheme: dark)');
query.addEventListener('change', listener);
return () => query.removeEventListener('change', listener);
};
const getSnapshot = () =>
window.matchMedia('(prefers-color-scheme: dark)').matches;
export const CompanionTheme = ({
appearance,
children,
}: {
appearance: Settings['appearance'];
children: ReactNode;
}) => {
const systemDark = useSyncExternalStore(subscribe, getSnapshot);
const colorScheme =
appearance === 'system' ? (systemDark ? 'dark' : 'light') : appearance;
return <ThemeProvider colorScheme={colorScheme}>{children}</ThemeProvider>;
};

View File

@@ -0,0 +1,30 @@
import { AnimatedPlaceholder } from '@ui/primitives/feedback/AnimatedPlaceholder/AnimatedPlaceholder';
import type { AnimatedPlaceholderType } from '@ui/primitives/feedback/AnimatedPlaceholder/types/AnimatedPlaceholderType';
import { type ReactNode } from 'react';
export const Empty = ({
icon,
animatedPlaceholderType,
title,
children,
actions,
}: {
icon?: ReactNode;
animatedPlaceholderType?: AnimatedPlaceholderType;
title: string;
children: ReactNode;
actions?: ReactNode;
}) => (
<div className="empty">
{animatedPlaceholderType ? (
<AnimatedPlaceholder type={animatedPlaceholderType} assetBasePath="." />
) : (
<span className="empty-icon">{icon}</span>
)}
<div className="empty-copy">
<h2>{title}</h2>
<div className="muted">{children}</div>
</div>
{actions != null && <div className="empty-actions">{actions}</div>}
</div>
);

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
import { createHalftone } from '../utils/createHalftone';
import cloudImageUrl from '../assets/onboarding-clouds.png';
const useHalftoneBackground = () => {
const canvasReference = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasReference.current;
if (!canvas) return;
const image = new Image();
let disposed = false;
let disposeRenderer: (() => void) | undefined;
const start = () => {
if (disposed) return;
disposeRenderer?.();
disposeRenderer = undefined;
try {
disposeRenderer = createHalftone(canvas, image);
} catch (error) {
console.warn('Could not render the onboarding background.', error);
}
};
const onContextLost = (event: Event) => {
event.preventDefault();
disposeRenderer?.();
disposeRenderer = undefined;
};
canvas.addEventListener('webglcontextlost', onContextLost);
canvas.addEventListener('webglcontextrestored', start);
image.src = cloudImageUrl;
void image
.decode()
.then(start)
.catch((error: unknown) => {
if (image.complete && image.naturalWidth > 0) {
start();
return;
}
if (!disposed)
console.warn('Could not load the onboarding background.', error);
});
return () => {
disposed = true;
disposeRenderer?.();
canvas.removeEventListener('webglcontextlost', onContextLost);
canvas.removeEventListener('webglcontextrestored', start);
};
}, []);
return canvasReference;
};
export const HalftoneBackground = () => {
const canvasReference = useHalftoneBackground();
return (
<canvas
className="halftone-background"
ref={canvasReference}
aria-hidden="true"
/>
);
};

View File

@@ -0,0 +1,161 @@
import { Section } from '@ui/components/Section/Section';
import { useNow } from '../hooks/useNow';
import { MeetingRow } from './MeetingRow';
import { i18n } from '@lingui/core';
import { SettingsCardContent } from './SettingsCardContent';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { Card } from '@ui/primitives/surfaces/Card/Card';
import { CalendarDayLabel } from './CalendarDayLabel';
import { useState } from 'react';
import {
IconCalendarEvent,
IconArrowUpRight,
IconChevronRight,
} from 'twenty-ui/icon';
import { getUpcomingMeetings } from '../../shared/utils/getUpcomingMeetings';
import { Button } from '@ui/primitives/input/Button/Button';
import { type ActionProps } from '../types/ActionProps';
import { RecordingList } from './RecordingList';
const UpcomingMeetings = ({ state, isPending, command }: ActionProps) => {
const [showAllMeetings, setShowAllMeetings] = useState(false);
const now = useNow(1000);
const meetings = getUpcomingMeetings({ meetings: state.meetings, now: now });
const calendarTitle = new Date(
meetings[0]?.startsAt ?? now,
).toLocaleDateString([], { month: 'long' });
const visibleMeetings = showAllMeetings ? meetings : meetings.slice(0, 3);
const meetingDays = new Map<string, typeof visibleMeetings>();
for (const meeting of visibleMeetings) {
const day = new Date(meeting.startsAt).toDateString();
const dayMeetings = meetingDays.get(day) ?? [];
dayMeetings.push(meeting);
meetingDays.set(day, dayMeetings);
}
return (
<section aria-label={calendarTitle}>
<Section.Header title={calendarTitle} />
{meetings.length ? (
<>
<Card
className="agenda-list"
backgroundColor="var(--t-background-secondary)"
>
{Array.from(meetingDays, ([day, dayMeetings]) => (
<div className="agenda-day" key={day}>
<time
className="agenda-date"
dateTime={dayMeetings[0].startsAt}
aria-label={new Date(
dayMeetings[0].startsAt,
).toLocaleDateString([], { dateStyle: 'full' })}
>
<CalendarDayLabel
weekday={new Date(
dayMeetings[0].startsAt,
).toLocaleDateString([], {
weekday: 'short',
})}
day={new Date(dayMeetings[0].startsAt).toLocaleDateString(
[],
{ day: 'numeric' },
)}
/>
</time>
<div className="agenda-day-meetings">
{dayMeetings.map((meeting) => (
<MeetingRow
key={meeting.id}
meeting={meeting}
skipped={state.skippedMeetingIds.includes(meeting.id)}
isNext={meeting.id === meetings[0]?.id}
autoJoin={state.settings.autoJoin}
now={now}
command={command}
isPending={isPending}
/>
))}
</div>
</div>
))}
</Card>
{meetings.length > 3 && (
<div>
<Button
variant="ghost"
className="see-more"
aria-expanded={showAllMeetings}
onClick={() => setShowAllMeetings(!showAllMeetings)}
size="md"
startIcon={<IconChevronRight />}
>
{showAllMeetings ? i18n._('Show less') : i18n._('See more')}
</Button>
</div>
)}
</>
) : (
<Card backgroundColor="var(--t-background-secondary)">
<SettingsCardContent
icon={
<IconCalendarEvent
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={
state.calendarConnected
? i18n._('Your calendar is clear')
: i18n._('Bring your calendar along')
}
description={
state.calendarConnected
? i18n._('Start a recording for an unscheduled conversation.')
: i18n._(
'Connect a calendar in Twenty to see your upcoming meetings.',
)
}
>
{!state.calendarConnected && (
<Button
onClick={() => void command({ type: 'open-calendar-settings' })}
variant="outline"
size="md"
startIcon={<IconArrowUpRight />}
>
{i18n._('Connect calendar')}
</Button>
)}
</SettingsCardContent>
</Card>
)}
</section>
);
};
export const Home = ({ state, command, isPending }: ActionProps) => {
return (
<>
<UpcomingMeetings state={state} command={command} isPending={isPending} />
<section
className="section recordings-section"
aria-label={i18n._('Recordings')}
>
<Section.Header
title={i18n._('Recordings')}
adornment={
<Button
variant="ghost"
startIcon={<IconArrowUpRight />}
disabled={isPending('open-recordings')}
onClick={() => void command({ type: 'open-recordings' })}
>
{i18n._('View all in Twenty')}
</Button>
}
/>
<RecordingList state={state} isPending={isPending} command={command} />
</section>
</>
);
};

View File

@@ -0,0 +1,9 @@
.popup {
backdrop-filter: var(--t-blur-medium);
background: var(--t-background-transparent-primary);
border: 1px solid var(--t-border-color-medium);
border-radius: var(--t-border-radius-md);
box-shadow: var(--t-box-shadow-strong);
overflow: hidden;
padding: var(--t-spacing-1);
}

View File

@@ -0,0 +1,4 @@
declare const styles: {
readonly popup: string;
};
export default styles;

View File

@@ -0,0 +1,127 @@
import { i18n } from '@lingui/core';
import { Menu } from '@base-ui/react/menu';
import { CalendarEventIndicator } from './CalendarEventIndicator';
import { IconButton } from '@ui/components/IconButton/IconButton';
import { Button } from '@ui/primitives/input/Button/Button';
import { MenuItem } from '@ui/primitives/navigation/MenuItem/MenuItem';
import styles from './MeetingRow.module.scss';
import {
IconCalendarEvent,
IconCalendarX,
IconDotsVertical,
IconArrowUpRight,
} from 'twenty-ui/icon';
import { type Meeting } from '../../shared/types/Meeting';
import { type ActionProps } from '../types/ActionProps';
import { formatTime } from '../utils/formatTime';
type MeetingRowProps = Pick<ActionProps, 'command' | 'isPending'> & {
meeting: Meeting;
skipped: boolean;
isNext: boolean;
autoJoin: boolean;
now: number;
};
export const MeetingRow = ({
meeting,
skipped,
isNext,
autoJoin,
now,
command,
isPending,
}: MeetingRowProps) => (
<div className="agenda-row" data-next={isNext || undefined}>
<div className="grow agenda-meeting-details">
<CalendarEventIndicator className="agenda-event-indicator" />
<strong>{meeting.title}</strong>
<span
className="small muted"
title={
skipped
? i18n._('Auto-join skipped')
: meeting.usesCalendarBot
? i18n._('Calendar bot recording')
: meeting.recordingEnabled
? i18n._('Desktop recording available')
: i18n._('Recording is off')
}
>
{formatTime(meeting.startsAt)} – {formatTime(meeting.endsAt)}
{(skipped || !meeting.recordingEnabled || meeting.usesCalendarBot) && (
<>
{' '}
·{' '}
{skipped
? i18n._('Auto-join skipped')
: meeting.usesCalendarBot
? i18n._('Calendar bot')
: i18n._('Recording off')}
</>
)}
</span>
</div>
<div className="agenda-actions">
{autoJoin && meeting.url && Date.parse(meeting.startsAt) > now && (
<Menu.Root>
<Menu.Trigger
render={
<IconButton
variant="ghost"
size="md"
aria-label={i18n._('Meeting options')}
>
<IconDotsVertical />
</IconButton>
}
/>
<Menu.Portal>
<Menu.Positioner
side="bottom"
align="end"
sideOffset={4}
className="meeting-menu"
>
<Menu.Popup className={styles.popup}>
<Menu.Item
disabled={isPending('skip', 'unskip')}
onClick={() =>
void command({
type: skipped ? 'unskip' : 'skip',
meetingId: meeting.id,
})
}
>
<MenuItem
disabled={isPending('skip', 'unskip')}
LeftIcon={skipped ? IconCalendarEvent : IconCalendarX}
text={
skipped
? i18n._('Restore auto-join')
: i18n._('Skip auto-join')
}
/>
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
<Button
disabled={!meeting.url || isPending('join')}
onClick={() =>
void command({
type: 'join',
meetingId: meeting.id,
})
}
variant="outline"
size="md"
startIcon={<IconArrowUpRight />}
>
{i18n._('Join')}
</Button>
</div>
</div>
);

View File

@@ -0,0 +1,72 @@
.container {
align-items: center;
display: flex;
gap: var(--t-spacing-0_5);
}
.mode {
align-items: center;
background: transparent;
border: none;
border-radius: var(--t-border-radius-sm-round);
color: var(--t-font-color-tertiary);
corner-shape: round;
cursor: pointer;
display: flex;
flex-shrink: 0;
font-family: inherit;
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-medium);
gap: 0;
height: var(--t-spacing-7);
padding: 0 var(--t-spacing-1_5);
transition:
background duration(fast) ease,
color duration(fast) ease,
gap duration(normal) ease;
&[data-size='medium'] {
height: var(--t-spacing-8);
}
&[data-active] {
background: var(--t-background-transparent-light);
color: var(--t-font-color-primary);
gap: var(--t-spacing-1);
}
&:hover {
background: var(--t-background-transparent-lighter);
color: var(--t-font-color-primary);
}
&[data-active]:hover {
background: var(--t-background-transparent-light);
}
&:focus-visible {
outline: 2px solid var(--t-color-blue);
outline-offset: 2px;
}
}
.icon {
align-items: center;
display: flex;
flex-shrink: 0;
height: var(--t-spacing-4);
justify-content: center;
width: var(--t-spacing-4);
}
.label {
display: grid;
grid-template-columns: 0fr;
transition: grid-template-columns duration(normal) ease;
}
.mode[data-active] .label {
grid-template-columns: 1fr;
}
.labelText {
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.mode,
.label {
transition: none;
}
}

View File

@@ -0,0 +1,8 @@
declare const styles: {
readonly container: string;
readonly mode: string;
readonly icon: string;
readonly label: string;
readonly labelText: string;
};
export default styles;

View File

@@ -0,0 +1,46 @@
import { type IconComponent } from '@ui/icon/types/IconComponent';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import styles from './NavigationModeSwitcher.module.scss';
type NavigationModeSwitcherProps = {
activeItemName: string;
ariaLabel: string;
size?: 'small' | 'medium';
items: {
name: string;
label: string;
Icon: IconComponent;
onClick: () => void;
}[];
};
export const NavigationModeSwitcher = ({
activeItemName,
ariaLabel,
items,
size = 'small',
}: NavigationModeSwitcherProps) => (
<div className={styles.container} role="group" aria-label={ariaLabel}>
{items.map(({ name, label, Icon, onClick }) => {
const isActive = name === activeItemName;
return (
<button
key={name}
type="button"
className={styles.mode}
data-active={isActive || undefined}
data-size={size}
aria-current={isActive}
onClick={onClick}
>
<span className={styles.icon}>
<Icon size={THEME_COMMON.icon.size.md} aria-hidden />
</span>
<span className={styles.label}>
<span className={styles.labelText}>{label}</span>
</span>
</button>
);
})}
</div>
);

View File

@@ -0,0 +1,48 @@
import { noticeMessage } from '../utils/noticeMessage';
import { Banner } from '@ui/primitives/feedback/Banner/Banner';
import { Notification } from './Notification';
import { IconButton } from '@ui/components/IconButton/IconButton';
import { i18n } from '@lingui/core';
import { Button } from '@ui/primitives/input/Button/Button';
import { IconX } from 'twenty-ui/icon';
import { type ActionProps } from '../types/ActionProps';
export const Notice = ({ state, command }: ActionProps) =>
!state.error && state.notice?.type === 'recording-finished' ? (
<div className="notification-container">
<Notification
message={noticeMessage(state.notice)}
closeLabel={i18n._('Dismiss message')}
onClose={() => void command({ type: 'dismiss-error' })}
/>
</div>
) : state.error || state.notice ? (
<div className="notice" role={state.error ? 'alert' : 'status'}>
<Banner color={state.error ? 'danger' : 'blue'}>
<span className="notice-text">
{state.error?.message ??
(state.notice && noticeMessage(state.notice))}
</span>
{state.error?.recovery && (
<Button
className="notice-recovery"
variant="outline"
onClick={() =>
state.error?.recovery && void command(state.error.recovery)
}
>
{i18n._('Install Desktop Recorder')}
</Button>
)}
<IconButton
className="notice-close"
variant="ghost"
aria-label={i18n._('Dismiss message')}
onClick={() => void command({ type: 'dismiss-error' })}
size="md"
>
<IconX />
</IconButton>
</Banner>
</div>
) : null;

View File

@@ -0,0 +1,35 @@
.container {
backdrop-filter: var(--t-blur-medium);
background: var(--t-background-transparent-primary);
border-radius: var(--t-border-radius-md);
box-shadow: var(--t-box-shadow-strong);
box-sizing: border-box;
padding: var(--t-spacing-2);
position: relative;
width: 296px;
max-width: 100%;
overflow: hidden;
}
.content {
position: relative;
display: flex;
align-items: center;
gap: var(--t-spacing-2);
> svg {
flex-shrink: 0;
}
}
.message {
flex: 1;
color: var(--t-font-color-secondary);
font-family: var(--t-font-family);
font-size: var(--t-font-size-sm);
}
.progress {
position: absolute;
inset: 0;
pointer-events: none;
> [role='progressbar'] {
height: 100%;
}
}

View File

@@ -0,0 +1,7 @@
declare const styles: {
readonly container: string;
readonly content: string;
readonly message: string;
readonly progress: string;
};
export default styles;

View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { IconInfoCircle, IconX } from '@ui/icon';
import { IconButton } from '@ui/components/IconButton/IconButton';
import { ProgressBar } from '@ui/primitives/feedback/ProgressBar/ProgressBar';
import { useTheme } from '@ui/theme-constants';
import styles from './Notification.module.scss';
type NotificationProps = {
message: string;
closeLabel: string;
onClose: () => void;
};
export const Notification = ({
message,
closeLabel,
onClose,
}: NotificationProps) => {
const theme = useTheme();
const [paused, setPaused] = useState(false);
return (
<div
className={styles.container}
role="status"
aria-live="polite"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className={styles.progress}>
<ProgressBar
value={100}
barColor={theme.snackBar.info.backgroundColor}
countdownDurationInMs={6000}
isCountdownPaused={paused}
onCountdownComplete={onClose}
/>
</div>
<div className={styles.content}>
<IconInfoCircle
size={theme.icon.size.md}
color={theme.snackBar.info.color}
aria-hidden
/>
<span className={styles.message}>{message}</span>
<IconButton
aria-label={closeLabel}
onClick={onClose}
variant="ghost"
size="md"
>
<IconX />
</IconButton>
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { i18n } from '@lingui/core';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { useState } from 'react';
import { type CompanionState } from '../../shared/types/CompanionState';
import { Field } from '@ui/primitives/input/Field/Field';
import { MainButton } from '@ui/components/MainButton/MainButton';
import { IconArrowUpRight, IconRefresh } from 'twenty-ui/icon';
import { Button } from '@ui/primitives/input/Button/Button';
import { type ActionProps } from '../types/ActionProps';
import { SetupHeading } from './SetupHeading';
import { Empty } from './Empty';
import { IS_PREVIEW } from '../constants/IS_PREVIEW';
import { Permissions } from './Permissions';
const Welcome = ({ state, isPending, command }: ActionProps) => {
const [serverUrl, setServerUrl] = useState(state.serverUrl);
return (
<section className="setup-card welcome">
<SetupHeading
title={i18n._('Connect your workspace')}
description={i18n._(
'Enter your workspace URL to sign in through your browser.',
)}
/>
<form
onSubmit={(event) => {
event.preventDefault();
void command({ type: 'connect', serverUrl });
}}
>
<Field.Root className="workspace-url-field">
<Field.Control
id="workspace-url"
aria-label={i18n._('Workspace URL')}
value={serverUrl}
onChange={(event) => {
setServerUrl(event.target.value);
if (state.connection === 'connecting')
void command({ type: 'cancel-connect' });
}}
placeholder="https://your-workspace.twenty.com"
type="url"
required
autoComplete="url"
/>
<Field.Error />
</Field.Root>
<MainButton
fullWidth
type="submit"
disabled={isPending('connect') || (!window.companion && !IS_PREVIEW)}
startIcon={<IconArrowUpRight />}
>
{state.connection === 'connecting'
? i18n._('Waiting for your browser…')
: i18n._('Connect to Twenty')}
</MainButton>
</form>
</section>
);
};
const getOnboardingStep = (state: CompanionState) => {
if (state.connection !== 'connected') return 'welcome';
if (!state.updatedAt) return 'loading';
return 'permissions';
};
const OnboardingContent = ({ state, isPending, command }: ActionProps) => {
switch (getOnboardingStep(state)) {
case 'welcome':
return <Welcome state={state} command={command} isPending={isPending} />;
case 'loading':
return (
<Empty
icon={
<IconRefresh
size={THEME_COMMON.icon.size.xl}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={
state.error
? i18n._('Your workspace needs attention')
: i18n._('Connecting your workspace')
}
actions={
<Button
disabled={isPending('refresh')}
onClick={() => void command({ type: 'refresh' })}
variant="outline"
size="md"
>
{i18n._('Try again')}
</Button>
}
>
<p>
{i18n._(
'Your calendar and recording integration need to finish loading before setup can continue.',
)}
</p>
</Empty>
);
case 'permissions':
return (
<Permissions
state={state}
command={command}
isPending={isPending}
intentToRecord={
state.permissionSetup?.intent === 'record' &&
state.settings.setupCompleted
}
onContinue={() => {
void command(
state.settings.setupCompleted
? { type: 'cancel-permission-setup' }
: { type: 'complete-setup' },
);
}}
/>
);
}
};
export const Onboarding = (props: ActionProps) => {
return (
<div className="setup-frame">
<span className="setup-logo" role="img" aria-label="Twenty" />
<div className="setup-container">
<OnboardingContent {...props} />
</div>
</div>
);
};

View File

@@ -0,0 +1,10 @@
import { clsx } from 'clsx';
import { type ComponentPropsWithoutRef } from 'react';
import styles from './OnboardingText.module.scss';
export const OnboardingSubtitle = ({
className,
...props
}: ComponentPropsWithoutRef<'p'>) => (
<p className={clsx(styles.subtitle, className)} {...props} />
);

View File

@@ -0,0 +1,17 @@
.title {
color: var(--t-font-color-primary);
font-family: var(--t-font-family);
font-size: var(--t-font-size-xl);
font-weight: var(--t-font-weight-semi-bold);
line-height: 1.2;
letter-spacing: normal;
margin: 0;
}
.subtitle {
color: var(--t-font-color-secondary);
font-family: var(--t-font-family);
font-size: var(--t-font-size-md);
line-height: 1.4;
letter-spacing: normal;
margin: 0;
}

View File

@@ -0,0 +1,2 @@
declare const styles: { readonly title: string; readonly subtitle: string };
export default styles;

View File

@@ -0,0 +1,13 @@
import { clsx } from 'clsx';
import { type ComponentPropsWithoutRef } from 'react';
import styles from './OnboardingText.module.scss';
export const OnboardingTitle = ({
className,
children,
...props
}: ComponentPropsWithoutRef<'h1'>) => (
<h1 className={clsx(styles.title, className)} {...props}>
{children}
</h1>
);

View File

@@ -0,0 +1,122 @@
import { i18n } from '@lingui/core';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { Card } from '@ui/primitives/surfaces/Card/Card';
import { MainButton } from '@ui/components/MainButton/MainButton';
import {
IconMicrophone,
IconHeadphones,
IconUsers,
IconArrowUpRight,
IconCircleDot,
} from 'twenty-ui/icon';
import { Button } from '@ui/primitives/input/Button/Button';
import { type ActionProps } from '../types/ActionProps';
import { SetupHeading } from './SetupHeading';
import { Status } from '@ui/primitives/data-display/Status/Status';
const PermissionChecklist = ({ state, isPending, command }: ActionProps) => (
<Card
className="permission-list"
backgroundColor="var(--t-background-secondary)"
>
{(
[
{
permission: 'microphone',
label: i18n._('Record your voice'),
button: i18n._('Enable microphone'),
Icon: IconMicrophone,
},
{
permission: 'system-audio',
label: i18n._('Record call audio'),
button: i18n._('Enable system audio'),
Icon: IconHeadphones,
},
{
permission: 'accessibility',
label: i18n._('Detect calls and speakers'),
button: i18n._('Enable accessibility'),
Icon: IconUsers,
},
] as const
).map(({ permission, label, button, Icon }) => {
const status = state.permissions[permission];
const granted = status === 'granted';
const denied = status === 'denied';
return (
<div className="permission-item" key={permission}>
<span className="permission-icon">
<Icon
size={THEME_COMMON.icon.size.lg}
stroke={THEME_COMMON.icon.stroke.sm}
/>
</span>
<strong className="grow">{label}</strong>
{granted ? (
<span role="status">
<Status color="blue">{i18n._('Enabled')}</Status>
</span>
) : (
<Button
variant="outline"
disabled={isPending('permission') || !state.updatedAt}
onClick={() =>
void command({
type: 'permission',
permission,
})
}
size="md"
startIcon={denied ? <IconArrowUpRight /> : undefined}
>
{denied ? i18n._('Open settings') : button}
</Button>
)}
</div>
);
})}
</Card>
);
export const Permissions = ({
state,
isPending,
command,
onContinue,
intentToRecord,
}: ActionProps & { onContinue: () => void; intentToRecord: boolean }) => {
const granted = Object.values(state.permissions).every(
(status) => status === 'granted',
);
return (
<section className="setup-card permissions-setup">
<SetupHeading
title={i18n._('Set up recording')}
description={i18n._(
'Enable audio access to transcribe your conversations and save them in Twenty.',
)}
/>
<PermissionChecklist
state={state}
isPending={isPending}
command={command}
/>
<MainButton
fullWidth
disabled={isPending('record', 'complete-setup') || !granted}
onClick={() =>
intentToRecord
? void command({
type: 'record',
windowId: state.permissionSetup?.windowId,
})
: onContinue()
}
startIcon={intentToRecord ? <IconCircleDot /> : undefined}
>
{intentToRecord ? i18n._('Start recording') : i18n._('Finish')}
</MainButton>
</section>
);
};

View File

@@ -0,0 +1,17 @@
import { Avatar } from '@ui/primitives/data-display/Avatar/Avatar';
import { type Recording } from '../../shared/types/Recording';
export const RecordingAvatar = ({ recording }: { recording: Recording }) => {
const participant = recording.participants?.[0];
return (
<span aria-hidden="true">
<Avatar
src={participant?.avatarUrl}
name={participant?.name ?? '?'}
colorSeed={participant?.id}
size="lg"
shape="square"
/>
</span>
);
};

View File

@@ -0,0 +1,83 @@
import { Section } from '@ui/components/Section/Section';
import { Avatar } from '@ui/primitives/data-display/Avatar/Avatar';
import { Card } from '@ui/primitives/surfaces/Card/Card';
import { i18n } from '@lingui/core';
import { Button } from '@ui/primitives/input/Button/Button';
import {
IconPlayerPause,
IconPlayerPlay,
IconPlayerStop,
} from 'twenty-ui/icon';
import { RecordingTimer } from './RecordingTimer';
import { type ActionProps } from '../types/ActionProps';
export const RecordingControls = ({
state,
isPending,
command,
}: ActionProps) => {
const active = state.activeRecording;
if (!active) return null;
const paused = active.status === 'paused';
const transitional = !['recording', 'paused'].includes(active.status);
const label = {
starting: i18n._('Starting recording…'),
recording: i18n._('Recording'),
pausing: i18n._('Pausing…'),
paused: i18n._('Recording paused'),
resuming: i18n._('Resuming…'),
stopping: i18n._('Finishing recording…'),
}[active.status];
return (
<Card
className="live-card"
aria-label={i18n._('Current recording')}
backgroundColor="var(--t-background-secondary)"
>
<div className="live-heading">
<Avatar
name={active.title}
colorSeed={active.id}
size="xl"
shape="rounded-square"
/>
<Section.Header
className="grow live-section-header"
title={active.title}
level={2}
size="lg"
description={
<div className="small muted" role="status">
{label} · <RecordingTimer recording={active} />
</div>
}
/>
</div>
<div className="live-actions">
<Button
disabled={
isPending('pause', 'resume', 'stop', 'record') || transitional
}
onClick={() => void command({ type: paused ? 'resume' : 'pause' })}
variant="outline"
size="md"
startIcon={paused ? <IconPlayerPlay /> : <IconPlayerPause />}
>
{paused ? i18n._('Resume') : i18n._('Pause')}
</Button>
<Button
disabled={
isPending('pause', 'resume', 'stop', 'record') || transitional
}
onClick={() => void command({ type: 'stop' })}
variant="outline"
color="danger"
size="md"
startIcon={<IconPlayerStop />}
>
{i18n._('Finish recording')}
</Button>
</div>
</Card>
);
};

View File

@@ -0,0 +1,74 @@
import { i18n } from '@lingui/core';
import { Card } from '@ui/primitives/surfaces/Card/Card';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { IconCircleDot } from 'twenty-ui/icon';
import { type ActionProps } from '../types/ActionProps';
import { Empty } from './Empty';
import { Status } from './Status';
import { RecordingAvatar } from './RecordingAvatar';
import { formatDate } from '../utils/formatDate';
export const RecordingList = ({ state, isPending, command }: ActionProps) => {
const recordings = state.recordings.slice(0, 5);
return recordings.length ? (
<Card
className="recording-list"
backgroundColor="var(--t-background-secondary)"
>
{recordings.map((recording) => (
<button
className="recording-row"
key={recording.id}
disabled={isPending('open-recording')}
title={i18n._('Open in Twenty')}
onClick={() =>
void command({ type: 'open-recording', recordingId: recording.id })
}
>
<RecordingAvatar recording={recording} />
<div className="grow">
<strong>{recording.title}</strong>
<span
className="small muted"
title={recording.participants
?.map((participant) => participant.name)
.join(', ')}
>
{recording.participants?.[0]?.name ||
i18n._('Unknown participant')}
{(recording.participants?.length ?? 0) > 1 &&
` +${(recording.participants?.length ?? 1) - 1}`}
</span>
</div>
<div className="recording-metadata">
<span className="small muted">
{recording.startedAt
? formatDate(recording.startedAt)
: i18n._('Recent conversation')}
</span>
{recording.status !== 'COMPLETED' && (
<Status status={recording.status} />
)}
</div>
</button>
))}
</Card>
) : (
<Empty
icon={
<IconCircleDot
size={THEME_COMMON.icon.size.xl}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
animatedPlaceholderType="noCallRecording"
title={i18n._('A place for every conversation')}
>
<p>
{i18n._(
'Your recorded conversations will appear here and in your Twenty workspace.',
)}
</p>
</Empty>
);
};

View File

@@ -0,0 +1,12 @@
import { type ActiveRecording } from '../../shared/types/ActiveRecording';
import { elapsedRecording } from '../utils/elapsedRecording';
import { useNow } from '../hooks/useNow';
export const RecordingTimer = ({
recording,
}: {
recording: ActiveRecording;
}) => {
const now = useNow(recording.pausedAt ? null : 1000);
return <time>{elapsedRecording(recording, now)}</time>;
};

View File

@@ -0,0 +1,283 @@
import { Section } from '@ui/components/Section/Section';
import { i18n } from '@lingui/core';
import { SettingsCardContent } from './SettingsCardContent';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { Card } from '@ui/primitives/surfaces/Card/Card';
import { type ReactNode } from 'react';
import { Switch } from '@ui/primitives/input/Switch/Switch';
import { Status } from '@ui/primitives/data-display/Status/Status';
import { SegmentedControl } from '@ui/primitives/input/SegmentedControl/SegmentedControl';
import {
IconCalendarEvent,
IconCircleDot,
IconArrowUpRight,
IconChevronRight,
IconLogin2,
IconUsers,
IconMicrophone,
IconBell,
IconClock,
IconSettings,
} from 'twenty-ui/icon';
import { Button } from '@ui/primitives/input/Button/Button';
import { type ActionProps } from '../types/ActionProps';
import { ShortcutSetting } from './ShortcutSetting';
import { WorkspaceIcon } from './WorkspaceIcon';
const SettingsToggle = ({
state,
isPending,
command,
setting,
icon,
label,
description,
}: ActionProps & {
setting:
| 'autoJoin'
| 'autoRecord'
| 'launchAtLogin'
| 'notifyOnDetectedCall'
| 'showMeetingCountdown';
icon: ReactNode;
label: string;
description: string;
}) => (
<label className="toggle-row">
<SettingsCardContent icon={icon} title={label} description={description}>
<Switch
aria-label={label}
checked={state.settings[setting]}
disabled={isPending('settings')}
onCheckedChange={(value) =>
void command({
type: 'settings',
settings: { [setting]: value },
})
}
/>
</SettingsCardContent>
</label>
);
export const Settings = ({ state, isPending, command }: ActionProps) => (
<section className="settings-page">
<Section.Header title={i18n._('General')} />
<Card
className="settings-group"
backgroundColor="var(--t-background-secondary)"
>
<ShortcutSetting state={state} isPending={isPending} command={command} />
<SettingsCardContent
icon={
<IconSettings
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Appearance')}
description={i18n._('Choose how Twenty looks on this computer.')}
>
<SegmentedControl
itemWidth="content"
ariaLabel={i18n._('Appearance')}
value={state.settings.appearance}
options={(
[
{ value: 'system', label: i18n._('System') },
{ value: 'light', label: i18n._('Light') },
{ value: 'dark', label: i18n._('Dark') },
] as const
).map((option) => ({ ...option, disabled: isPending('settings') }))}
onChange={(appearance) =>
void command({ type: 'settings', settings: { appearance } })
}
/>
</SettingsCardContent>
<SettingsToggle
state={state}
isPending={isPending}
command={command}
setting="launchAtLogin"
icon={
<IconLogin2
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
label={i18n._('Launch at login')}
description={i18n._(
'Keep Twenty ready in the background when you sign in.',
)}
/>
<SettingsToggle
state={state}
isPending={isPending}
command={command}
setting="showMeetingCountdown"
icon={
<IconClock
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
label={i18n._('Meeting countdown')}
description={i18n._(
'Show time until your next meeting beside the tray icon.',
)}
/>
</Card>
<Section.Header title={i18n._('Meetings')} className="section" />
<Card
className="settings-group"
backgroundColor="var(--t-background-secondary)"
>
{(
[
{
key: 'autoJoin',
Icon: IconCalendarEvent,
label: i18n._('Auto-join meetings'),
description: i18n._(
'Open the meeting link when your calendar event starts.',
),
},
{
key: 'autoRecord',
Icon: IconCircleDot,
label: i18n._('Record unscheduled calls automatically'),
description: i18n._(
'Start recording detected calls outside your calendar.',
),
},
{
key: 'notifyOnDetectedCall',
Icon: IconBell,
label: i18n._('Call detection notifications'),
description: i18n._(
'Notify you when a call is detected and needs manual recording.',
),
},
] as const
).map(({ key, Icon, label, description }) => (
<SettingsToggle
key={key}
state={state}
isPending={isPending}
command={command}
setting={key}
icon={
<Icon
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
label={label}
description={description}
/>
))}
<SettingsCardContent
icon={
<IconUsers
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Speaker tags')}
description={i18n._('Identify who is speaking in supported calls.')}
>
{state.permissions.accessibility === 'granted' ? (
<span role="status">
<Status color="blue">{i18n._('Enabled')}</Status>
</span>
) : (
<Button
disabled={
isPending('permission') ||
!!state.activeRecording ||
!state.updatedAt
}
onClick={() =>
void command({
type: 'permission',
permission: 'accessibility',
})
}
variant="outline"
size="md"
>
{state.permissions.accessibility === 'denied'
? i18n._('Open settings')
: i18n._('Enable')}
</Button>
)}
</SettingsCardContent>
<button
className="settings-link"
disabled={
isPending('begin-permission-setup') || !!state.activeRecording
}
onClick={() => void command({ type: 'begin-permission-setup' })}
>
<SettingsCardContent
icon={
<IconMicrophone
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Recording permissions')}
description={i18n._(
'Manage microphone, system audio, and call detection access.',
)}
>
<IconChevronRight
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
</SettingsCardContent>
</button>
</Card>
<Section.Header title={i18n._('Workspace')} className="section" />
<Card
className="settings-group"
backgroundColor="var(--t-background-secondary)"
>
<button
className="settings-link"
disabled={isPending('open-calendar-settings')}
onClick={() => void command({ type: 'open-calendar-settings' })}
>
<SettingsCardContent
icon={
<IconCalendarEvent
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Calendar settings')}
description={i18n._('Manage your connected calendars in Twenty.')}
>
<IconArrowUpRight
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
</SettingsCardContent>
</button>
<SettingsCardContent
icon={<WorkspaceIcon workspace={state.workspace} />}
title={state.workspace?.name}
description={<span className="selectable">{state.serverUrl}</span>}
>
<Button
disabled={isPending('disconnect') || !!state.activeRecording}
onClick={() => void command({ type: 'disconnect' })}
variant="outline"
size="md"
>
{i18n._('Disconnect')}
</Button>
</SettingsCardContent>
</Card>
</section>
);

View File

@@ -0,0 +1,38 @@
.content {
align-items: center;
display: flex;
gap: var(--t-spacing-3);
padding: var(--t-spacing-4);
}
.icon {
align-items: center;
background-color: var(--t-background-primary);
border: 2px solid var(--t-border-color-light);
border-radius: var(--t-border-radius-sm);
box-sizing: border-box;
display: flex;
height: var(--t-spacing-8);
justify-content: center;
min-width: var(--t-spacing-8);
width: var(--t-spacing-8);
}
.text {
flex: 1;
min-width: 0;
}
.title {
color: var(--t-font-color-primary);
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-medium);
margin-bottom: var(--t-spacing-1);
}
.description {
color: var(--t-font-color-secondary);
font-size: var(--t-font-size-sm);
line-height: var(--t-text-line-height-lg);
overflow: hidden;
}
.actions {
flex-shrink: 0;
margin-left: auto;
}

View File

@@ -0,0 +1,9 @@
declare const styles: {
readonly content: string;
readonly icon: string;
readonly text: string;
readonly title: string;
readonly description: string;
readonly actions: string;
};
export default styles;

View File

@@ -0,0 +1,27 @@
import { type ReactNode } from 'react';
import styles from './SettingsCardContent.module.scss';
type SettingsCardContentProps = {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
};
export const SettingsCardContent = ({
icon,
title,
description,
children,
}: SettingsCardContentProps) => (
<div className={styles.content}>
{icon != null && <div className={styles.icon}>{icon}</div>}
<div className={styles.text}>
<div className={styles.title}>{title}</div>
{description != null && (
<div className={styles.description}>{description}</div>
)}
</div>
{children != null && <div className={styles.actions}>{children}</div>}
</div>
);

View File

@@ -0,0 +1,29 @@
import { OnboardingTitle } from './OnboardingTitle';
import { OnboardingSubtitle } from './OnboardingSubtitle';
type SetupHeadingProps = {
title: string;
continuation?: string;
description?: string;
};
export const SetupHeading = ({
title,
continuation,
description,
}: SetupHeadingProps) => (
<header className="setup-heading">
<OnboardingTitle>
{continuation ? (
<>
<span>{title}</span> <span>{continuation}</span>
</>
) : (
title
)}
</OnboardingTitle>
{description != null && (
<OnboardingSubtitle>{description}</OnboardingSubtitle>
)}
</header>
);

View File

@@ -0,0 +1,86 @@
import { DEFAULT_SETTINGS } from '../../shared/constants/DEFAULT_SETTINGS';
import { shortcutFromKeyboardEvent } from '../utils/shortcutFromKeyboardEvent';
import { formatShortcut } from '../utils/formatShortcut';
import { i18n } from '@lingui/core';
import { SettingsCardContent } from './SettingsCardContent';
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
import { IconButton } from '@ui/components/IconButton/IconButton';
import { useState } from 'react';
import { IconCommand, IconRestore } from 'twenty-ui/icon';
import { Button } from '@ui/primitives/input/Button/Button';
import { type ActionProps } from '../types/ActionProps';
export const ShortcutSetting = ({ state, isPending, command }: ActionProps) => {
const [capturing, setCapturing] = useState(false);
return (
<SettingsCardContent
icon={
<IconCommand
size={THEME_COMMON.icon.size.md}
stroke={THEME_COMMON.icon.stroke.sm}
/>
}
title={i18n._('Open companion shortcut')}
description={
capturing
? i18n._(
'Press Control, Option / Alt, or Command with a key. Escape cancels.',
)
: i18n._('Open Twenty from anywhere on your computer.')
}
>
<div className="button-group shortcut-actions">
{state.settings.openShortcut !== DEFAULT_SETTINGS.openShortcut && (
<IconButton
variant="ghost"
aria-label={i18n._('Reset shortcut')}
disabled={isPending('settings')}
onClick={() =>
void command({
type: 'settings',
settings: { openShortcut: DEFAULT_SETTINGS.openShortcut },
})
}
size="md"
>
<IconRestore />
</IconButton>
)}
<div
onBlur={() => setCapturing(false)}
onKeyDown={(event) => {
if (!capturing) return;
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setCapturing(false);
return;
}
const shortcut = shortcutFromKeyboardEvent(event);
if (!shortcut) return;
setCapturing(false);
void command({
type: 'settings',
settings: { openShortcut: shortcut },
});
}}
>
<Button
aria-label={i18n._('Change open companion shortcut')}
disabled={isPending('settings')}
onClick={() => setCapturing(true)}
variant="outline"
size="md"
>
{capturing
? i18n._('Press shortcut…')
: formatShortcut(
state.settings.openShortcut,
navigator.platform.startsWith('Mac'),
)}
</Button>
</div>
</div>
</SettingsCardContent>
);
};

View File

@@ -0,0 +1,16 @@
import { Tag as StatusChip } from '@ui/primitives/data-display/Tag/Tag';
import { recordingLabel } from '../utils/recordingLabel';
export const Status = ({ status }: { status: string }) => (
<StatusChip
color={
status.toUpperCase() === 'FAILED'
? 'red'
: status.toUpperCase() === 'PROCESSING'
? 'blue'
: 'gray'
}
>
{recordingLabel(status)}
</StatusChip>
);

View File

@@ -0,0 +1,17 @@
import { Avatar } from '@ui/primitives/data-display/Avatar/Avatar';
import { type Agenda } from '../../shared/types/Agenda';
export const WorkspaceIcon = ({
workspace,
}: {
workspace: Agenda['workspace'] | null;
}) => (
<span aria-hidden="true" className="workspace-avatar">
<Avatar
src={workspace?.logoUrl || './twenty.svg'}
name={workspace?.name ?? 'Twenty'}
size="lg"
shape="square"
/>
</span>
);

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { i18n } from '@lingui/core';
import { Onboarding } from '../Onboarding';
import { ThemeProvider } from '@ui/theme-constants/ThemeProvider';
import { createInitialState } from '../../../shared/utils/createInitialState';
import { type CompanionState } from '../../../shared/types/CompanionState';
import { messages } from '../../locales/en';
const command = vi.fn().mockResolvedValue(undefined);
const page = (state: CompanionState) => (
<ThemeProvider colorScheme="light">
<Onboarding state={state} command={command} isPending={() => false} />
</ThemeProvider>
);
const connectedState = (): CompanionState => ({
...createInitialState(),
connection: 'connected',
updatedAt: new Date().toISOString(),
permissions: {
microphone: 'granted',
'system-audio': 'granted',
accessibility: 'granted',
},
});
beforeEach(() => {
command.mockClear();
i18n.load('en', messages);
i18n.activate('en');
});
afterEach(cleanup);
it('shows connection before permissions and waits for the workspace to load', () => {
const { rerender } = render(page(createInitialState()));
expect(
screen.getByRole('heading', { name: 'Connect your workspace' }),
).toBeDefined();
rerender(page({ ...connectedState(), updatedAt: null }));
expect(
screen.getByRole('heading', { name: 'Connecting your workspace' }),
).toBeDefined();
expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull();
});
it('opens the app immediately after first-time setup without starting a recording', async () => {
render(page(connectedState()));
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Finish' }));
expect(command).toHaveBeenCalledExactlyOnceWith({ type: 'complete-setup' });
expect(
screen.queryByRole('button', { name: 'Open my workspace' }),
).toBeNull();
expect(command.mock.calls.some(([value]) => value.type === 'record')).toBe(
false,
);
});
it('returns from settings permission review without showing first-time setup', async () => {
const state = connectedState();
state.settings.setupCompleted = true;
state.permissionSetup = {};
render(page(state));
await userEvent.setup().click(screen.getByRole('button', { name: 'Finish' }));
expect(command).toHaveBeenCalledExactlyOnceWith({
type: 'cancel-permission-setup',
});
expect(
screen.queryByRole('button', { name: 'Open my workspace' }),
).toBeNull();
});
it('keeps required permissions enforced and preserves an explicit recording intent', async () => {
const state = connectedState();
state.settings.setupCompleted = true;
state.permissionSetup = { intent: 'record', windowId: 'call-window' };
const { rerender } = render(
page({
...state,
permissions: { ...state.permissions, microphone: 'denied' },
}),
);
expect(
(
screen.getByRole('button', {
name: 'Start recording',
}) as HTMLButtonElement
).disabled,
).toBe(true);
rerender(page(state));
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Start recording' }));
expect(command).toHaveBeenCalledExactlyOnceWith({
type: 'record',
windowId: 'call-window',
});
});

View File

@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { Profiler } from 'react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { RecordingTimer } from '../RecordingTimer';
import { useCompanion } from '../../hooks/useCompanion';
import { createInitialState } from '../../../shared/utils/createInitialState';
import { type ActiveRecording } from '../../../shared/types/ActiveRecording';
const START = Date.parse('2026-09-08T12:00:00Z');
const recording: ActiveRecording = {
id: 'recording',
windowId: 'window',
title: 'Call',
startedAt: new Date(START).toISOString(),
status: 'recording',
};
afterEach(() => {
cleanup();
vi.useRealTimers();
delete window.companion;
});
it('updates elapsed time without rerendering navigation', async () => {
vi.useFakeTimers();
vi.setSystemTime(START);
window.companion = {
getState: async () => createInitialState(),
command: vi.fn(),
onState: () => () => undefined,
onNavigate: () => () => undefined,
};
const navigationRendered = vi.fn();
const Layout = () => {
const { page } = useCompanion();
return (
<>
<Profiler id="navigation" onRender={navigationRendered}>
<nav>{page}</nav>
</Profiler>
<RecordingTimer recording={recording} />
</>
);
};
await act(async () => {
render(<Layout />);
});
navigationRendered.mockClear();
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByText('00:05')).toBeDefined();
expect(navigationRendered).not.toHaveBeenCalled();
});
it('freezes while paused and resumes without counting the pause', async () => {
vi.useFakeTimers();
vi.setSystemTime(START + 5000);
const { rerender, unmount } = render(
<RecordingTimer recording={recording} />,
);
rerender(
<RecordingTimer
recording={{
...recording,
status: 'paused',
pausedAt: new Date(START + 5000).toISOString(),
}}
/>,
);
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(screen.getByText('00:05')).toBeDefined();
rerender(
<RecordingTimer recording={{ ...recording, pausedMilliseconds: 60_000 }} />,
);
expect(screen.getByText('00:05')).toBeDefined();
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByText('00:06')).toBeDefined();
unmount();
expect(vi.getTimerCount()).toBe(0);
});

View File

@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, it } from 'vitest';
import { IconSettings } from 'twenty-ui/icon';
import { IconButton } from '@ui/components/IconButton/IconButton';
import { Button } from '@ui/primitives/input/Button/Button';
afterEach(cleanup);
it('keeps a native accessible name on composed icon buttons', () => {
render(
<IconButton aria-label="Open settings">
<IconSettings />
</IconButton>,
);
expect(screen.getByRole('button', { name: 'Open settings' })).toBeDefined();
});
it('places the disclosure state on the actual button', () => {
render(<Button aria-expanded={false}>See more</Button>);
expect(
screen
.getByRole('button', { name: /See more/ })
.getAttribute('aria-expanded'),
).toBe('false');
});

View File

@@ -0,0 +1,4 @@
export const IS_PREVIEW =
import.meta.env.DEV &&
!window.companion &&
new URLSearchParams(window.location.search).has('preview');

View File

@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { act } from 'react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { i18n } from '@lingui/core';
import { messages } from '../../locales/en';
import { createInitialState } from '../../../shared/utils/createInitialState';
import { type CompanionCommand } from '../../../shared/types/CompanionCommand';
import { createDeferred } from '../../../main/utils/createDeferred';
import { useCompanion } from '../useCompanion';
const Controls = () => {
const { command, isPending, state } = useCompanion();
return (
<>
<button
disabled={isPending('settings')}
onClick={() =>
void command({ type: 'settings', settings: { autoJoin: false } })
}
>
Save settings
</button>
<button
disabled={isPending('stop')}
onClick={() => void command({ type: 'stop' })}
>
Finish recording
</button>
<button onClick={() => void command({ type: 'dismiss-error' })}>
Dismiss
</button>
<button
onClick={() =>
void command({ type: 'connect', serverUrl: 'https://twenty.com' })
}
>
Connect
</button>
<button onClick={() => void command({ type: 'cancel-connect' })}>
Cancel connection
</button>
{state.error && <p role="alert">{state.error.message}</p>}
</>
);
};
beforeEach(() => {
i18n.load('en', messages);
i18n.activate('en');
window.companion = {
getState: async () => createInitialState(),
command: vi.fn().mockResolvedValue(undefined),
onState: () => () => undefined,
onNavigate: () => () => undefined,
};
});
afterEach(() => {
cleanup();
delete window.companion;
});
it('keeps recording controls and dismissal available during a settings save', async () => {
const pending = createDeferred<void>();
const command = vi
.mocked(window.companion!.command)
.mockImplementation(async (command: CompanionCommand) => {
if (command.type === 'settings') await pending.promise;
});
render(<Controls />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(
(screen.getByRole('button', { name: 'Save settings' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await user.click(screen.getByRole('button', { name: 'Finish recording' }));
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(command).toHaveBeenCalledWith({ type: 'stop' });
expect(command).toHaveBeenCalledWith({ type: 'dismiss-error' });
await act(async () => pending.resolve());
await waitFor(() =>
expect(
(
screen.getByRole('button', {
name: 'Save settings',
}) as HTMLButtonElement
).disabled,
).toBe(false),
);
});
it('delivers cancellation while sign-in is still pending', async () => {
const pending = createDeferred<void>();
const command = vi
.mocked(window.companion!.command)
.mockImplementation(async (command) => {
if (command.type === 'connect') await pending.promise;
if (command.type === 'cancel-connect') pending.resolve();
});
render(<Controls />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Connect' }));
await user.click(screen.getByRole('button', { name: 'Cancel connection' }));
expect(command).toHaveBeenCalledWith({ type: 'cancel-connect' });
});
it('releases a failed action so it can be retried', async () => {
vi.mocked(window.companion!.command).mockRejectedValueOnce(
new Error('IPC disconnected'),
);
render(<Controls />);
await userEvent
.setup()
.click(screen.getByRole('button', { name: 'Save settings' }));
expect((await screen.findByRole('alert')).textContent).toContain(
'Please try again',
);
expect(
(screen.getByRole('button', { name: 'Save settings' }) as HTMLButtonElement)
.disabled,
).toBe(false);
});

View File

@@ -0,0 +1,96 @@
import { i18n } from '@lingui/core';
import { useEffect, useState } from 'react';
import { type CompanionCommand } from '../../shared/types/CompanionCommand';
import { type CompanionPage } from '../../shared/types/CompanionPage';
import { type CompanionState } from '../../shared/types/CompanionState';
import { createInitialState } from '../../shared/utils/createInitialState';
import { previewState } from '../utils/previewState';
import { previewCommand } from '../utils/previewCommand';
import { IS_PREVIEW } from '../constants/IS_PREVIEW';
export const useCompanion = () => {
const [state, setState] = useState(() =>
IS_PREVIEW ? previewState(createInitialState()) : createInitialState(),
);
const [page, setPage] = useState<CompanionPage>('agenda');
const [pending, setPending] = useState<
Partial<Record<CompanionCommand['type'], number>>
>({});
const [loaded, setLoaded] = useState(!window.companion);
useEffect(() => {
const bridge = window.companion;
if (!bridge) return;
let receivedUpdate = false;
let disposed = false;
const acceptState = (value: CompanionState) => {
if (disposed) return;
setState(value);
setLoaded(true);
};
const unsubscribe = bridge.onState((value) => {
receivedUpdate = true;
acceptState(value);
});
const unsubscribeNavigation = bridge.onNavigate(setPage);
void bridge
.getState()
.then((value) => {
if (!receivedUpdate) acceptState(value);
})
.catch(() => {
if (!disposed) {
setState((value) => ({
...value,
error: {
message: i18n._('Could not connect to the app. Reopen Twenty.'),
},
}));
setLoaded(true);
}
});
return () => {
disposed = true;
unsubscribe();
unsubscribeNavigation();
};
}, []);
const command = async (value: CompanionCommand) => {
if (value.type === 'open-app' && !window.companion) {
setPage(value.page ?? 'agenda');
return;
}
setPending((previous) => ({
...previous,
[value.type]: (previous[value.type] ?? 0) + 1,
}));
try {
if (window.companion) await window.companion.command(value);
else if (IS_PREVIEW) {
setState((previous) => previewCommand(previous, value));
}
} catch {
setState((previous) => ({
...previous,
error: {
message: i18n._(
'The app could not complete this action. Please try again.',
),
},
}));
} finally {
setPending((previous) => ({
...previous,
[value.type]: (previous[value.type] ?? 1) - 1,
}));
}
};
return {
state,
page,
navigate: setPage,
isPending: (...types: CompanionCommand['type'][]) =>
types.some((type) => (pending[type] ?? 0) > 0),
loaded,
command,
};
};

View File

@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
export const useNow = (intervalMilliseconds: number | null) => {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (intervalMilliseconds === null) return;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), intervalMilliseconds);
return () => clearInterval(timer);
}, [intervalMilliseconds]);
return now;
};

Some files were not shown because too many files have changed in this diff Show More