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 2s

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

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;
};

View File

@@ -0,0 +1,607 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2026-09-08 15:51+0200\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: en\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:75
msgid "Meeting options"
msgstr "Meeting options"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:101
msgid "Restore auto-join"
msgstr "Restore auto-join"
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:9
msgid "Opening {title}. Complete any meeting provider confirmation to enter."
msgstr "Opening {title}. Complete any meeting provider confirmation to enter."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:14
msgid "Recording ended. Twenty is processing your conversation."
msgstr "Recording ended. Twenty is processing your conversation."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:16
msgid "Network connection lost. Stopping this recording. Reconnect before starting a new recording."
msgstr "Network connection lost. Stopping this recording. Reconnect before starting a new recording."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:20
msgid "Connection restored. Finish the interrupted recording before starting another."
msgstr "Connection restored. Finish the interrupted recording before starting another."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:24
msgid "Connection restored. Start a new recording to continue capturing."
msgstr "Connection restored. Start a new recording to continue capturing."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:28
msgid "Audio capture was interrupted. Check your microphone and speaker settings."
msgstr "Audio capture was interrupted. Check your microphone and speaker settings."
#. js-lingui-explicit-id
#: src/renderer/notice-message.ts:32
msgid "Recording stopped after the connection was lost. Reconnect to check its processing status."
msgstr "Recording stopped after the connection was lost. Reconnect to check its processing status."
#. js-lingui-explicit-id
#: src/renderer/format.ts:13
msgid "Saved to Twenty"
msgstr "Saved to Twenty"
#. js-lingui-explicit-id
#: src/renderer/format.ts:14
msgid "Processing"
msgstr "Processing"
#. js-lingui-explicit-id
#: src/renderer/format.ts:15
msgid "Recording failed"
msgstr "Recording failed"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:145
#: src/renderer/format.ts:16
msgid "Recording"
msgstr "Recording"
#. js-lingui-explicit-id
#: src/renderer/format.ts:17
#: src/renderer/format.ts:18
msgid "Preparing"
msgstr "Preparing"
#. js-lingui-explicit-id
#: src/renderer/useCompanion.ts:59
msgid "Could not connect to the app. Reopen Twenty."
msgstr "Could not connect to the app. Reopen Twenty."
#. js-lingui-explicit-id
#: src/renderer/useCompanion.ts:89
msgid "The app could not complete this action. Please try again."
msgstr "The app could not complete this action. Please try again."
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:16
msgid "Open companion shortcut"
msgstr "Open companion shortcut"
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:19
msgid "Press Control, Option / Alt, or Command with a key. Escape cancels."
msgstr "Press Control, Option / Alt, or Command with a key. Escape cancels."
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:22
msgid "Open Twenty from anywhere on your computer."
msgstr "Open Twenty from anywhere on your computer."
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:29
msgid "Reset shortcut"
msgstr "Reset shortcut"
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:61
msgid "Change open companion shortcut"
msgstr "Change open companion shortcut"
#. js-lingui-explicit-id
#: src/renderer/ShortcutSetting.tsx:68
msgid "Press shortcut…"
msgstr "Press shortcut…"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:32
msgid "Open in Twenty"
msgstr "Open in Twenty"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:47
msgid "Unknown participant"
msgstr "Unknown participant"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:56
msgid "Recent conversation"
msgstr "Recent conversation"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:71
msgid "No matching recordings"
msgstr "No matching recordings"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:72
msgid "A place for every conversation"
msgstr "A place for every conversation"
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:77
msgid "Try another meeting title."
msgstr "Try another meeting title."
#. js-lingui-explicit-id
#: src/renderer/Recordings.tsx:78
msgid "Your recorded conversations will appear here and in your Twenty workspace."
msgstr "Your recorded conversations will appear here and in your Twenty workspace."
#. js-lingui-explicit-id
#: src/renderer/components.tsx:64
#: src/renderer/components.tsx:88
msgid "Dismiss message"
msgstr "Dismiss message"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:77
msgid "Install Desktop Recorder"
msgstr "Install Desktop Recorder"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:144
msgid "Starting recording…"
msgstr "Starting recording…"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:146
msgid "Pausing…"
msgstr "Pausing…"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:147
msgid "Recording paused"
msgstr "Recording paused"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:148
msgid "Resuming…"
msgstr "Resuming…"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:149
msgid "Finishing recording…"
msgstr "Finishing recording…"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:154
msgid "Current recording"
msgstr "Current recording"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:184
msgid "Resume"
msgstr "Resume"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:184
msgid "Pause"
msgstr "Pause"
#. js-lingui-explicit-id
#: src/renderer/components.tsx:195
msgid "Finish recording"
msgstr "Finish recording"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:44
#: src/renderer/MeetingRow.tsx:58
msgid "Auto-join skipped"
msgstr "Auto-join skipped"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:46
msgid "Calendar bot recording"
msgstr "Calendar bot recording"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:48
msgid "Desktop recording available"
msgstr "Desktop recording available"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:49
msgid "Recording is off"
msgstr "Recording is off"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:60
msgid "Calendar bot"
msgstr "Calendar bot"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:61
msgid "Recording off"
msgstr "Recording off"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:102
msgid "Skip auto-join"
msgstr "Skip auto-join"
#. js-lingui-explicit-id
#: src/renderer/MeetingRow.tsx:122
msgid "Join"
msgstr "Join"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:24
msgid "Record your voice"
msgstr "Record your voice"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:25
msgid "Enable microphone"
msgstr "Enable microphone"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:30
msgid "Record call audio"
msgstr "Record call audio"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:31
msgid "Enable system audio"
msgstr "Enable system audio"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:36
msgid "Detect calls and speakers"
msgstr "Detect calls and speakers"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:37
msgid "Enable accessibility"
msgstr "Enable accessibility"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:57
#: src/renderer/Settings.tsx:166
msgid "Enabled"
msgstr "Enabled"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:73
#: src/renderer/Settings.tsx:188
msgid "Open settings"
msgstr "Open settings"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:96
msgid "Set up recording"
msgstr "Set up recording"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:97
msgid "Enable audio access to transcribe your conversations and save them in Twenty."
msgstr "Enable audio access to transcribe your conversations and save them in Twenty."
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:119
msgid "Start recording"
msgstr "Start recording"
#. js-lingui-explicit-id
#: src/renderer/Permissions.tsx:119
msgid "Finish"
msgstr "Finish"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:90
msgid "Show less"
msgstr "Show less"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:90
msgid "See more"
msgstr "See more"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:105
msgid "Your calendar is clear"
msgstr "Your calendar is clear"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:106
msgid "Bring your calendar along"
msgstr "Bring your calendar along"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:110
msgid "Start a recording for an unscheduled conversation."
msgstr "Start a recording for an unscheduled conversation."
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:111
msgid "Connect a calendar in Twenty to see your upcoming meetings."
msgstr "Connect a calendar in Twenty to see your upcoming meetings."
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:121
msgid "Connect calendar"
msgstr "Connect calendar"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:139
#: src/renderer/Home.tsx:142
msgid "Recordings"
msgstr "Recordings"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:146
msgid "Search recordings"
msgstr "Search recordings"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:147
msgid "Search recordings…"
msgstr "Search recordings…"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:65
msgid "General"
msgstr "General"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:73
#: src/renderer/Settings.tsx:78
msgid "Appearance"
msgstr "Appearance"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:74
msgid "Choose how Twenty looks on this computer."
msgstr "Choose how Twenty looks on this computer."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:82
msgid "System"
msgstr "System"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:83
msgid "Light"
msgstr "Light"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:84
msgid "Dark"
msgstr "Dark"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:98
msgid "Launch at login"
msgstr "Launch at login"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:99
msgid "Keep Twenty ready in the background when you sign in."
msgstr "Keep Twenty ready in the background when you sign in."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:109
msgid "Meeting countdown"
msgstr "Meeting countdown"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:110
msgid "Show time until your next meeting beside the tray icon."
msgstr "Show time until your next meeting beside the tray icon."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:115
msgid "Meetings"
msgstr "Meetings"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:125
msgid "Auto-join meetings"
msgstr "Auto-join meetings"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:126
msgid "Open the meeting link when your calendar event starts."
msgstr "Open the meeting link when your calendar event starts."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:133
msgid "Record unscheduled calls automatically"
msgstr "Record unscheduled calls automatically"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:134
msgid "Start recording detected calls outside your calendar."
msgstr "Start recording detected calls outside your calendar."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:141
msgid "Call detection notifications"
msgstr "Call detection notifications"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:142
msgid "Notify you when a call is detected and needs manual recording."
msgstr "Notify you when a call is detected and needs manual recording."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:161
msgid "Speaker tags"
msgstr "Speaker tags"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:162
msgid "Identify who is speaking in supported calls."
msgstr "Identify who is speaking in supported calls."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:189
msgid "Enable"
msgstr "Enable"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:203
msgid "Recording permissions"
msgstr "Recording permissions"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:204
msgid "Manage microphone, system audio, and call detection access."
msgstr "Manage microphone, system audio, and call detection access."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:212
msgid "Workspace"
msgstr "Workspace"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:226
msgid "Calendar settings"
msgstr "Calendar settings"
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:227
msgid "Manage your connected calendars in Twenty."
msgstr "Manage your connected calendars in Twenty."
#. js-lingui-explicit-id
#: src/renderer/Settings.tsx:242
msgid "Disconnect"
msgstr "Disconnect"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:17
msgid "Connect your workspace"
msgstr "Connect your workspace"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:18
msgid "Enter your workspace URL to sign in through your browser."
msgstr "Enter your workspace URL to sign in through your browser."
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:29
#: src/renderer/Onboarding.tsx:32
msgid "Workspace URL"
msgstr "Workspace URL"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:55
msgid "Waiting for your browser…"
msgstr "Waiting for your browser…"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:56
msgid "Connect to Twenty"
msgstr "Connect to Twenty"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:64
msgid "Set up Desktop Recorder in Twenty"
msgstr "Set up Desktop Recorder in Twenty"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:75
msgid "You are ready"
msgstr "You are ready"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:76
msgid "for your next conversation"
msgstr "for your next conversation"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:83
msgid "Open my workspace"
msgstr "Open my workspace"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:118
msgid "Your workspace needs attention"
msgstr "Your workspace needs attention"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:119
msgid "Connecting your workspace"
msgstr "Connecting your workspace"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:127
msgid "Try again"
msgstr "Try again"
#. js-lingui-explicit-id
#: src/renderer/Onboarding.tsx:132
msgid "Your calendar and recording integration need to finish loading before setup can continue."
msgstr "Your calendar and recording integration need to finish loading before setup can continue."
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:41
msgid "Navigation modes"
msgstr "Navigation modes"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:47
msgid "Home"
msgstr "Home"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:53
msgid "Settings"
msgstr "Settings"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:74
msgid "New recording"
msgstr "New recording"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:81
msgid "Interactive preview · sample data · no audio is captured"
msgstr "Interactive preview · sample data · no audio is captured"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:93
msgid "Opening your workspace"
msgstr "Opening your workspace"
#. js-lingui-explicit-id
#: src/renderer/CompanionApp.tsx:95
msgid "Loading your calendar and conversations…"
msgstr "Loading your calendar and conversations…"
#. js-lingui-explicit-id
#: src/renderer/Home.tsx:43
#: src/renderer/Home.tsx:44

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
import { i18n } from '@lingui/core';
import { messages } from './locales/en';
import { createRoot } from 'react-dom/client';
import { CompanionApp } from './components/CompanionApp';
import './style.css';
i18n.load('en', messages);
i18n.activate('en');
createRoot(document.getElementById('root')!).render(<CompanionApp />);

View File

@@ -0,0 +1,623 @@
:root {
--onboarding-logo-opacity: 0.5;
--control-height-medium: 32px;
font-family: var(--t-font-family);
/* Twenty UI's rem tokens use the same 13 px baseline as twenty-front. */
font-size: 13px;
-webkit-font-smoothing: antialiased;
line-height: 1.45;
font-synthesis: none;
color-scheme: light dark;
color: var(--t-font-color-primary);
background: var(--t-background-primary);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
}
button,
a,
input,
select {
-webkit-app-region: no-drag;
}
.recording-row:focus-visible,
.settings-link:focus-visible,
.workspace-url-field input:focus-visible {
outline: 2px solid var(--t-color-blue);
outline-offset: 3px;
}
h1,
h2,
h3,
p {
margin: 0;
}
.setup-card h1 {
font-size: var(--t-font-size-xxl);
line-height: 1.3;
letter-spacing: -0.5px;
}
.setup-card h2 {
font-size: var(--t-font-size-xl);
line-height: 1.35;
letter-spacing: -0.3px;
}
strong {
font-weight: var(--t-font-weight-medium);
}
.setup-heading h1,
.empty h2 {
font-family: var(--t-font-family);
font-weight: var(--t-font-weight-medium);
}
p {
line-height: 1.6;
}
.muted {
color: var(--t-font-color-secondary);
}
.small {
font-size: var(--t-font-size-sm);
line-height: 1.5;
}
.grow {
flex: 1;
min-width: 0;
}
.grow > .small {
display: block;
}
.button-group {
display: flex;
align-items: center;
gap: var(--t-spacing-2);
}
.shortcut-actions {
gap: var(--t-spacing-1);
}
.titlebar .button-group > div {
height: var(--header-control-height);
}
.workspace-url-field input {
width: 100%;
min-width: 0;
border: 1px solid var(--t-border-color-medium);
background: var(--t-background-primary);
border-radius: var(--t-border-radius-md);
height: var(--control-height-medium);
padding: 0 var(--t-spacing-2);
}
.app-shell {
--content-gutter: var(--t-spacing-4);
height: 100dvh;
min-height: 480px;
display: flex;
}
.main-area {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--t-background-primary);
}
.titlebar {
--header-control-height: var(--control-height-medium);
height: calc(var(--header-control-height) + 2 * var(--content-gutter));
flex-shrink: 0;
padding: 0 var(--content-gutter);
display: grid;
grid-template-columns: 1fr auto auto;
column-gap: var(--t-spacing-4);
align-items: center;
-webkit-app-region: drag;
}
.setup-titlebar {
position: absolute;
top: 0;
inset-inline: 0;
z-index: 2;
}
.scroll-area {
overflow: auto;
flex: 1;
min-height: 0;
}
.page-content {
display: flex;
flex-direction: column;
width: 100%;
max-width: 760px;
margin-inline: auto;
min-height: 100%;
box-sizing: border-box;
padding: var(--t-spacing-8) var(--content-gutter) var(--t-spacing-12);
}
.notice {
flex-shrink: 0;
}
.notification-container {
position: fixed;
right: var(--t-spacing-3);
bottom: var(--t-spacing-3);
max-width: calc(100% - 2 * var(--t-spacing-3));
z-index: 1000;
}
.notice-text {
flex: 1;
text-align: center;
margin-left: var(--t-spacing-8);
}
.notice .notice-close,
.notice .notice-recovery {
color: var(--t-font-color-inverted);
}
.preview-label {
background: var(--t-background-secondary);
border-bottom: 1px solid var(--t-border-color-medium);
padding: var(--t-spacing-1) var(--t-spacing-5);
color: var(--t-font-color-secondary);
font-size: var(--t-font-size-xs);
text-align: center;
}
.onboarding .main-area {
position: relative;
overflow: hidden;
isolation: isolate;
background: var(--t-background-tertiary);
}
.halftone-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.onboarding .scroll-area {
position: relative;
display: flex;
flex-direction: column;
padding: var(--t-spacing-12) var(--t-spacing-6);
}
.onboarding .notice,
.onboarding .preview-label {
position: relative;
z-index: 2;
margin-top: var(--t-spacing-11);
}
.onboarding .preview-label + .notice {
margin-top: 0;
}
.setup-frame {
width: 100%;
max-width: calc(440px + 2 * var(--t-spacing-8) + 2px);
flex-shrink: 0;
margin: auto;
position: relative;
}
.setup-logo {
position: absolute;
width: 40px;
height: 40px;
left: 50%;
top: min(-28px, calc((100% - 100vh) / 4));
transform: translate(-50%, -50%);
background: var(--t-background-tertiary);
border-radius: var(--t-border-radius-md);
corner-shape: squircle;
}
.setup-logo::before {
content: '';
position: absolute;
inset: 0;
background: var(--t-font-color-primary);
opacity: var(--onboarding-logo-opacity);
mask: url('./assets/twenty-mark.svg') center / contain no-repeat;
}
.light .setup-logo::before {
background: url('./assets/twenty-logo.svg') center / contain no-repeat;
opacity: 1;
mask: none;
}
.setup-container {
width: 100%;
max-width: calc(440px + 2 * var(--t-spacing-8) + 2px);
flex-shrink: 0;
margin: auto;
padding: var(--t-spacing-8);
border-radius: var(--t-border-radius-md);
border: 1px solid var(--t-border-color-medium);
background: var(--t-background-primary);
}
.setup-container .permission-list,
.setup-container .permission-item + .permission-item,
.setup-container input {
border-color: var(--t-border-color-medium);
}
.setup-card {
display: grid;
gap: var(--t-spacing-8);
}
.setup-heading {
text-align: center;
display: grid;
gap: var(--t-spacing-4);
}
.setup-heading h1 > span {
display: block;
}
.setup-heading p {
text-wrap: balance;
}
.permission-list {
overflow: hidden;
}
.permission-item {
display: flex;
align-items: center;
padding: var(--t-spacing-4);
gap: var(--t-spacing-3);
}
.permission-item + .permission-item {
border-top: 1px solid var(--t-border-color-medium);
}
.permission-icon {
color: var(--t-font-color-secondary);
display: flex;
}
.welcome form {
display: grid;
gap: var(--t-spacing-6);
}
.workspace-url-field {
display: grid;
gap: var(--t-spacing-2);
}
.welcome label {
font-size: var(--t-font-size-sm);
font-weight: var(--t-font-weight-medium);
}
.section {
margin-top: var(--t-spacing-9);
}
.agenda-list {
overflow: hidden;
}
.agenda-day {
display: flex;
gap: var(--t-spacing-3);
padding: 0 var(--t-spacing-4);
}
.agenda-day + .agenda-day {
border-top: 1px solid var(--t-border-color-light);
}
.agenda-date {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
align-self: flex-start;
gap: 0;
width: var(--t-spacing-6);
min-height: var(--t-spacing-16);
flex-shrink: 0;
}
.agenda-day-meetings {
min-width: 0;
flex: 1;
}
.agenda-row {
display: flex;
align-items: center;
gap: var(--t-spacing-2);
min-height: var(--t-spacing-16);
padding: var(--t-spacing-3) 0;
}
.agenda-meeting-details {
min-width: 0;
position: relative;
}
.agenda-actions {
display: flex;
align-items: center;
gap: var(--t-spacing-1);
flex-shrink: 0;
}
.meeting-menu {
z-index: 1000;
}
@media (hover: hover) and (pointer: fine) {
.agenda-row:not([data-next]):not(:hover):not(:focus-within) .agenda-actions {
opacity: 0;
pointer-events: none;
}
}
.agenda-meeting-details .agenda-event-indicator {
background: var(--t-border-color-strong);
width: 1px;
}
.agenda-event-indicator {
position: absolute;
left: calc(-1 * var(--t-spacing-2));
top: 0;
bottom: 0;
}
.agenda-meeting-details strong {
display: block;
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-medium);
color: var(--t-font-color-primary);
overflow-wrap: anywhere;
}
.agenda-meeting-details .small {
margin-top: var(--t-spacing-1);
font-size: var(--t-font-size-sm);
font-variant-numeric: tabular-nums;
color: var(--t-font-color-tertiary);
}
.recording-list {
display: grid;
overflow: hidden;
}
.recording-row {
display: flex;
align-items: center;
gap: var(--t-spacing-3);
padding: var(--t-spacing-3) var(--t-spacing-4);
margin: 0;
width: 100%;
text-align: left;
background: transparent;
border: 0;
border-radius: 0;
color: var(--t-font-color-primary);
min-height: var(--t-spacing-16);
}
.recording-row:hover:not(:disabled) {
background: var(--t-background-tertiary);
}
.recording-row .grow {
display: grid;
gap: var(--t-spacing-1);
}
.recording-row strong {
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recording-row + .recording-row {
border-top: 1px solid var(--t-border-color-light);
}
.recording-metadata {
display: grid;
justify-items: end;
align-self: flex-start;
gap: var(--t-spacing-1);
flex-shrink: 0;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--t-spacing-11) var(--t-spacing-6);
gap: var(--t-spacing-6);
}
.empty-copy {
display: grid;
justify-items: center;
gap: var(--t-spacing-3);
}
.empty-actions {
display: flex;
justify-content: center;
gap: var(--t-spacing-2);
}
.empty-icon {
color: var(--t-font-color-tertiary);
display: grid;
place-items: center;
width: 48px;
height: 48px;
background: var(--t-background-secondary);
border-radius: var(--t-border-radius-md);
margin-bottom: var(--t-spacing-1);
}
.empty h2 {
color: var(--t-font-color-primary);
font-size: var(--t-font-size-lg);
font-weight: var(--t-font-weight-semi-bold);
line-height: 1.2;
}
.empty .muted {
text-wrap: balance;
max-width: 400px;
color: var(--t-font-color-tertiary);
font-size: var(--t-font-size-md);
font-weight: var(--t-font-weight-regular);
line-height: 1.4;
}
.live-card {
padding: var(--t-spacing-4) var(--t-spacing-5);
margin-bottom: var(--t-spacing-6);
}
.live-heading {
display: flex;
gap: var(--t-spacing-3);
align-items: center;
}
.live-heading .live-section-header {
margin-bottom: 0;
}
.live-actions {
display: flex;
gap: var(--t-spacing-2);
margin-top: var(--t-spacing-4);
}
.live-card time {
font-variant-numeric: tabular-nums;
}
.settings-group {
overflow: hidden;
}
.toggle-row {
display: block;
cursor: pointer;
}
.settings-group > :not(:last-child) {
border-bottom: 1px solid var(--t-border-color-medium);
}
.settings-link {
padding: 0;
width: 100%;
border: 0;
background: transparent;
text-align: left;
}
.settings-link:not(:disabled):hover {
background: var(--t-background-transparent-light);
}
.settings-link:focus-visible {
outline-offset: -3px;
}
.workspace-avatar {
display: flex;
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
/* Reserve native window controls and the sharing indicator independently of navigation. */
.mac-titlebar {
grid-template-columns: 96px 1fr auto auto;
}
.mac-titlebar::before {
content: '';
grid-column: 1;
grid-row: 1;
}
.titlebar > .header-navigation {
grid-column: -3;
grid-row: 1;
height: var(--header-control-height);
}
.titlebar > .button-group {
grid-column: -2;
grid-row: 1;
height: var(--header-control-height);
padding-left: var(--t-spacing-4);
position: relative;
}
.titlebar > .button-group::before {
content: '';
position: absolute;
left: 0;
height: var(--t-spacing-5);
border-left: 1px solid var(--t-border-color-medium);
}
.titlebar .header-navigation button,
.titlebar .button-group button {
box-sizing: border-box;
height: var(--header-control-height);
min-height: var(--header-control-height);
}
.header-navigation {
display: flex;
align-items: center;
gap: var(--t-spacing-3);
min-width: 0;
}
.see-more {
margin-top: var(--t-spacing-2);
}
.settings-link:disabled,
.recording-row:disabled {
color: var(--t-font-color-extra-light);
cursor: not-allowed;
}
.settings-link:disabled > *,
.settings-link:disabled .muted,
.recording-row:disabled .muted,
.settings-link:disabled > svg {
color: inherit;
}
.recordings-section:has(> .empty) {
display: flex;
flex-direction: column;
flex: 1;
}
.recordings-section > .empty {
flex: 1;
justify-content: center;
}
.light {
--halftone-opacity: 0.1;
--halftone-accent-opacity: 0;
--halftone-accent-center-y: 0.84;
--halftone-accent-radius-x: 0.32;
--halftone-accent-radius-y: 0.25;
--halftone-edge-fade: 1;
}
.dark {
--halftone-opacity: 0.55;
--halftone-accent-opacity: 0.55;
--halftone-accent-center-y: 0.65;
--halftone-accent-radius-x: 0.55;
--halftone-accent-radius-y: 0.75;
--halftone-edge-fade: 0;
}
@media (max-width: 880px) {
.page-content {
padding: var(--t-spacing-7) var(--content-gutter) var(--t-spacing-10);
}
.permission-item {
padding: var(--t-spacing-4) var(--t-spacing-3);
gap: var(--t-spacing-2);
}
.header-navigation {
gap: var(--t-spacing-2);
}
}
@media (max-width: 680px) {
.onboarding .scroll-area {
padding: var(--t-spacing-16) var(--t-spacing-4) var(--t-spacing-6);
}
.setup-container {
padding: var(--t-spacing-7) var(--t-spacing-6);
}
.page-content {
padding: var(--t-spacing-6) var(--content-gutter);
}
.permission-item {
flex-wrap: wrap;
}
.permission-item > .grow {
min-width: 170px;
}
}
.settings-link:disabled {
opacity: 0.5;
}

View File

@@ -0,0 +1,8 @@
import { type CompanionCommand } from '../../shared/types/CompanionCommand';
import { type CompanionState } from '../../shared/types/CompanionState';
export type ActionProps = {
state: CompanionState;
isPending: (...types: CompanionCommand['type'][]) => boolean;
command: (command: CompanionCommand) => Promise<void>;
};

View File

@@ -0,0 +1,7 @@
import { type CompanionBridge } from '../../shared/types/CompanionBridge';
declare global {
interface Window {
companion?: CompanionBridge;
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { elapsedRecording } from '../elapsedRecording';
import { type ActiveRecording } from '../../../shared/types/ActiveRecording';
const RECORDING: ActiveRecording = {
id: 'recording',
windowId: 'window',
title: 'Call',
startedAt: '2026-09-07T09:00:00Z',
status: 'recording',
};
describe('recording duration', () => {
it('freezes the elapsed time during a pause', () => {
const paused = {
...RECORDING,
status: 'paused' as const,
pausedAt: '2026-09-07T09:01:15Z',
};
expect(elapsedRecording(paused, Date.parse('2026-09-07T09:10:00Z'))).toBe(
'01:15',
);
});
it('excludes completed pauses when recording resumes', () => {
expect(
elapsedRecording(
{ ...RECORDING, pausedMilliseconds: 60_000 },
Date.parse('2026-09-07T09:02:15Z'),
),
).toBe('01:15');
});
it('never shows a negative duration after a clock adjustment', () => {
expect(
elapsedRecording(RECORDING, Date.parse('2026-09-07T08:59:50Z')),
).toBe('00:00');
});
});

View File

@@ -0,0 +1,28 @@
import { i18n } from '@lingui/core';
import { afterEach, expect, it } from 'vitest';
import { messages } from '../../locales/en';
import { noticeMessage } from '../noticeMessage';
afterEach(() => {
i18n.load('en', messages);
i18n.activate('en');
});
it('translates recording notifications without relying on their English wording', () => {
i18n.load('fr', {
'Recording ended. Twenty is processing your conversation.':
'Enregistrement terminé.',
});
i18n.activate('fr');
expect(noticeMessage({ type: 'recording-finished' })).toBe(
'Enregistrement terminé.',
);
});
it('interpolates a meeting title using the compiled source catalog', () => {
i18n.load('en', messages);
i18n.activate('en');
expect(
noticeMessage({ type: 'opening-meeting', title: 'Product demo' }),
).toBe(
'Opening Product demo. Complete any meeting provider confirmation to enter.',
);
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { shortcutFromKeyboardEvent } from '../shortcutFromKeyboardEvent';
import { formatShortcut } from '../formatShortcut';
import { DEFAULT_SETTINGS } from '../../../shared/constants/DEFAULT_SETTINGS';
const key = {
key: 'a',
code: 'KeyA',
repeat: false,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
};
describe('shortcut capture', () => {
it.each([
[{ ...key, metaKey: true, shiftKey: true }, 'Command+Shift+A'],
[
{ ...key, ctrlKey: true, altKey: true, code: 'Digit4', key: '4' },
'Control+Alt+4',
],
[{ ...key, altKey: true, code: 'Space', key: ' ' }, 'Alt+Space'],
[{ ...key, ctrlKey: true, code: 'F12', key: 'F12' }, 'Control+F12'],
[
{ ...key, ctrlKey: true, code: 'F12', key: 'Unidentified' },
'Control+F12',
],
[{ ...key, metaKey: true, key: 'q' }, 'Command+A'],
])('creates an Electron accelerator from %o', (event, expected) => {
expect(shortcutFromKeyboardEvent(event)).toBe(expected);
});
it.each([
key,
{ ...key, shiftKey: true },
{ ...key, metaKey: true, repeat: true },
{ ...key, metaKey: true, key: 'Escape', code: 'Escape' },
{ ...key, metaKey: true, key: 'Meta', code: 'MetaLeft' },
])('ignores unsupported or incomplete combinations: %o', (event) => {
expect(shortcutFromKeyboardEvent(event)).toBeNull();
});
});
it('formats the shared default for macOS and other platforms', () => {
expect(formatShortcut(DEFAULT_SETTINGS.openShortcut, true)).toBe(
'⌘ + Shift + Space',
);
expect(formatShortcut(DEFAULT_SETTINGS.openShortcut, false)).toBe(
'Ctrl + Shift + Space',
);
expect(formatShortcut('Control+Alt+7', true)).toBe('Ctrl + ⌥ + 7');
expect(formatShortcut('Control+Alt+7', false)).toBe('Ctrl + Alt + 7');
});

View File

@@ -0,0 +1,302 @@
// Based on the supplied Halftone Studio export. The grid stays
// in CSS pixels so resizing the window never magnifies the dashes.
const EFFECT_SCALE = 1.04;
const TILE_SIZE = 10.91 * (4 / 6) * EFFECT_SCALE;
const POWER = -0.07;
const POWER_AMPLITUDE = 0.28;
const ANIMATION_PERIOD_MS = 8_000;
const FRAME_INTERVAL_MS = 1_000 / 24;
const VERTEX_SHADER = `#version 300 es
in vec2 position;
out vec2 imagePosition;
void main() {
imagePosition = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAGMENT_SHADER = `#version 300 es
precision highp float;
uniform sampler2D sourceImage;
uniform vec2 viewportSize;
uniform float imageAspect;
uniform float effectScale;
uniform float tileSize;
uniform float power;
uniform float time;
uniform float inkOpacity;
uniform float accentOpacity;
uniform vec3 accentRegion;
uniform float edgeFade;
in vec2 imagePosition;
out vec4 fragmentColor;
vec2 coverImage(vec2 position) {
float viewportAspect = viewportSize.x / viewportSize.y;
if (imageAspect > viewportAspect) {
position.x = (position.x - 0.5) * viewportAspect / imageAspect + 0.5;
} else {
position.y = (position.y - 0.5) * imageAspect / viewportAspect + 0.5;
}
return (position - 0.5) / effectScale + 0.5;
}
vec3 linearColor(vec3 color) {
return mix(pow((color + 0.055) / 1.055, vec3(2.4)),
color / 12.92, lessThanEqual(color, vec3(0.04045)));
}
void main() {
vec2 gridPosition = imagePosition * viewportSize / tileSize;
vec2 samplePosition = (floor(gridPosition) + 0.5) * tileSize / viewportSize;
vec2 sampleUv = coverImage(samplePosition);
vec4 sampleColor = texture(sourceImage, clamp(sampleUv, 0.0, 1.0));
float tone = dot(linearColor(sampleColor.rgb), vec3(1.0 / 3.0));
float wave = sin(samplePosition.x * 5.0 + samplePosition.y * 3.0 - time * 0.65);
float radius = clamp(tone + (power + wave * 0.16) * length(vec2(0.5)) / 3.0, 0.0, 1.0) * 0.93;
vec2 cellPosition = fract(gridPosition) - 0.5;
float distanceToSegment = length(vec2(
max(abs(cellPosition.x) - radius, 0.0), cellPosition.y));
float signedDistance = distanceToSegment - 0.46 * radius;
float edge = max(0.02, fwidth(signedDistance));
float alpha = (1.0 - smoothstep(0.0, edge, signedDistance))
* step(0.0001, radius) * sampleColor.a;
float ink = (60.0 + 12.0 * wave) / 255.0;
float blueWeight = 1.0 - smoothstep(0.15, 1.0,
length((imagePosition - vec2(0.5, accentRegion.x)) / accentRegion.yz));
blueWeight *= step(0.0001, accentOpacity);
float edgeVisibility = mix(1.0,
(1.0 - smoothstep(0.2, 0.5, abs(imagePosition.x - 0.5)))
* smoothstep(0.0, 0.55, imagePosition.y), edgeFade);
vec3 brandBlue = vec3(25.0, 97.0, 237.0) / 255.0;
float opacity = mix(inkOpacity, accentOpacity, blueWeight);
vec3 inkColor = mix(vec3(ink) * inkOpacity, brandBlue * accentOpacity, blueWeight);
// One sparse mote per cell avoids a particle loop at every screen pixel.
vec2 pixelPosition = imagePosition * viewportSize;
vec2 particleCell = floor(pixelPosition / 180.0);
float seed = fract(sin(dot(particleCell, vec2(127.1, 311.7))) * 43758.5453);
float phase = fract(time / 18.0 + seed);
vec2 particlePosition = vec2(
45.0 + seed * 90.0 + sin(time * 0.35 + seed * 6.28) * 12.0,
25.0 + phase * 130.0);
float particleDistance = length(mod(pixelPosition, 180.0) - particlePosition);
float particle = (1.0 - smoothstep(0.7, 2.0, particleDistance))
* pow(sin(phase * 3.14159), 2.0) * 0.45 * inkOpacity;
vec3 particleColor = mix(vec3(0.52), vec3(0.38, 0.62, 0.95), blueWeight);
fragmentColor = vec4(particleColor * particle + inkColor * alpha * (1.0 - particle),
particle + alpha * opacity * (1.0 - particle)) * edgeVisibility;
}
`;
const compileShader = (
context: WebGL2RenderingContext,
shaderType: number,
source: string,
) => {
const shader = context.createShader(shaderType);
if (!shader) throw new Error('Could not create the halftone shader.');
context.shaderSource(shader, source);
context.compileShader(shader);
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
const message = context.getShaderInfoLog(shader);
context.deleteShader(shader);
throw new Error(message ?? 'Could not compile the halftone shader.');
}
return shader;
};
export const createHalftone = (
canvas: HTMLCanvasElement,
image: HTMLImageElement,
) => {
const context = canvas.getContext('webgl2', {
alpha: true,
antialias: false,
depth: false,
powerPreference: 'low-power',
});
if (!context) return;
const program = context.createProgram();
const buffer = context.createBuffer();
const texture = context.createTexture();
const shaders: WebGLShader[] = [];
const disposeResources = () => {
shaders.forEach((shader) => context.deleteShader(shader));
context.deleteTexture(texture);
context.deleteBuffer(buffer);
context.deleteProgram(program);
};
try {
if (!program || !buffer || !texture)
throw new Error('Could not allocate the halftone renderer.');
shaders.push(compileShader(context, context.VERTEX_SHADER, VERTEX_SHADER));
shaders.push(
compileShader(context, context.FRAGMENT_SHADER, FRAGMENT_SHADER),
);
shaders.forEach((shader) => context.attachShader(program, shader));
context.linkProgram(program);
if (!context.getProgramParameter(program, context.LINK_STATUS))
throw new Error(
context.getProgramInfoLog(program) ??
'Could not link the halftone shader.',
);
context.useProgram(program);
context.bindBuffer(context.ARRAY_BUFFER, buffer);
context.bufferData(
context.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
context.STATIC_DRAW,
);
const position = context.getAttribLocation(program, 'position');
context.enableVertexAttribArray(position);
context.vertexAttribPointer(position, 2, context.FLOAT, false, 0, 0);
context.bindTexture(context.TEXTURE_2D, texture);
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, true);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MIN_FILTER,
context.LINEAR,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_MAG_FILTER,
context.LINEAR,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_S,
context.CLAMP_TO_EDGE,
);
context.texParameteri(
context.TEXTURE_2D,
context.TEXTURE_WRAP_T,
context.CLAMP_TO_EDGE,
);
context.texImage2D(
context.TEXTURE_2D,
0,
context.RGBA,
context.RGBA,
context.UNSIGNED_BYTE,
image,
);
context.uniform1i(context.getUniformLocation(program, 'sourceImage'), 0);
context.uniform1f(
context.getUniformLocation(program, 'imageAspect'),
image.naturalWidth / image.naturalHeight,
);
context.uniform1f(
context.getUniformLocation(program, 'tileSize'),
TILE_SIZE,
);
context.uniform1f(
context.getUniformLocation(program, 'effectScale'),
EFFECT_SCALE,
);
} catch (error) {
disposeResources();
throw error;
}
const viewportUniform = context.getUniformLocation(program, 'viewportSize');
const powerUniform = context.getUniformLocation(program, 'power');
const timeUniform = context.getUniformLocation(program, 'time');
const inkOpacityUniform = context.getUniformLocation(program, 'inkOpacity');
const accentOpacityUniform = context.getUniformLocation(
program,
'accentOpacity',
);
const accentRegionUniform = context.getUniformLocation(
program,
'accentRegion',
);
const edgeFadeUniform = context.getUniformLocation(program, 'edgeFade');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let animationFrame = 0;
let previousFrame = 0;
let elapsed = 0;
const draw = () => {
const style = getComputedStyle(canvas);
context.uniform1f(
inkOpacityUniform,
Number(style.getPropertyValue('--halftone-opacity')),
);
context.uniform1f(
accentOpacityUniform,
Number(style.getPropertyValue('--halftone-accent-opacity')),
);
context.uniform3f(
accentRegionUniform,
Number(style.getPropertyValue('--halftone-accent-center-y')),
Number(style.getPropertyValue('--halftone-accent-radius-x')),
Number(style.getPropertyValue('--halftone-accent-radius-y')),
);
context.uniform1f(
edgeFadeUniform,
Number(style.getPropertyValue('--halftone-edge-fade')),
);
const power =
POWER +
(reducedMotion.matches
? 0
: POWER_AMPLITUDE *
Math.sin((elapsed * 2 * Math.PI) / ANIMATION_PERIOD_MS));
context.uniform1f(powerUniform, power);
context.uniform1f(timeUniform, reducedMotion.matches ? 0 : elapsed / 1_000);
context.drawArrays(context.TRIANGLES, 0, 3);
};
const animate = (timestamp: number) => {
if (timestamp - previousFrame >= FRAME_INTERVAL_MS) {
elapsed += Math.min(timestamp - previousFrame, 100);
previousFrame = timestamp;
draw();
}
animationFrame = window.requestAnimationFrame(animate);
};
const updateAnimation = () => {
window.cancelAnimationFrame(animationFrame);
if (document.hidden) return;
draw();
previousFrame = performance.now();
if (!reducedMotion.matches)
animationFrame = window.requestAnimationFrame(animate);
};
const resize = () => {
const { width, height } = canvas.getBoundingClientRect();
const pixelRatio = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
context.viewport(0, 0, canvas.width, canvas.height);
context.uniform2f(viewportUniform, Math.max(width, 1), Math.max(height, 1));
updateAnimation();
};
const observer = new ResizeObserver(resize);
observer.observe(canvas);
document.addEventListener('visibilitychange', updateAnimation);
reducedMotion.addEventListener('change', updateAnimation);
const themeObserver = new MutationObserver(updateAnimation);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
resize();
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
themeObserver.disconnect();
document.removeEventListener('visibilitychange', updateAnimation);
reducedMotion.removeEventListener('change', updateAnimation);
disposeResources();
};
};

View File

@@ -0,0 +1,21 @@
import { type ActiveRecording } from '../../shared/types/ActiveRecording';
export const elapsedRecording = (recording: ActiveRecording, now: number) => {
const end = recording.pausedAt ? Date.parse(recording.pausedAt) : now;
const seconds = Math.max(
0,
Math.floor(
(end -
Date.parse(recording.startedAt) -
(recording.pausedMilliseconds ?? 0)) /
1000,
),
);
return (
Math.floor(seconds / 60)
.toString()
.padStart(2, '0') +
':' +
(seconds % 60).toString().padStart(2, '0')
);
};

View File

@@ -0,0 +1,2 @@
export const formatDate = (value: string) =>
new Date(value).toLocaleDateString([], { month: 'short', day: 'numeric' });

View File

@@ -0,0 +1,12 @@
export const formatShortcut = (shortcut: string, isMac: boolean): string => {
const modifiers: Record<string, string> = {
CommandOrControl: isMac ? '⌘' : 'Ctrl',
Command: '⌘',
Control: 'Ctrl',
Alt: isMac ? '⌥' : 'Alt',
};
return shortcut
.split('+')
.map((key) => modifiers[key] ?? key)
.join(' + ');
};

View File

@@ -0,0 +1,5 @@
export const formatTime = (value: string) =>
new Date(value).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});

View File

@@ -0,0 +1,36 @@
import { i18n } from '@lingui/core';
import { type CompanionNotice } from '../../shared/types/CompanionNotice';
export const noticeMessage = (notice: CompanionNotice): string => {
switch (notice.type) {
case 'preview':
return notice.message;
case 'opening-meeting':
return i18n._(
'Opening {title}. Complete any meeting provider confirmation to enter.',
{ title: notice.title },
);
case 'recording-finished':
return i18n._('Recording ended. Twenty is processing your conversation.');
case 'network-lost':
return i18n._(
'Network connection lost. Stopping this recording. Reconnect before starting a new recording.',
);
case 'network-restored-active':
return i18n._(
'Connection restored. Finish the interrupted recording before starting another.',
);
case 'network-restored':
return i18n._(
'Connection restored. Start a new recording to continue capturing.',
);
case 'audio-interrupted':
return i18n._(
'Audio capture was interrupted. Check your microphone and speaker settings.',
);
case 'recording-stopped-offline':
return i18n._(
'Recording stopped after the connection was lost. Reconnect to check its processing status.',
);
}
};

View File

@@ -0,0 +1,144 @@
import { type CompanionCommand } from '../../shared/types/CompanionCommand';
import { type CompanionState } from '../../shared/types/CompanionState';
export const previewCommand = (
state: CompanionState,
command: CompanionCommand,
): CompanionState => {
const next = structuredClone(state);
next.error = null;
switch (command.type) {
case 'connect':
next.connection = 'connected';
next.serverUrl = command.serverUrl;
next.updatedAt = new Date().toISOString();
break;
case 'disconnect':
Object.assign(next, {
connection: 'disconnected',
workspace: null,
meetings: [],
recordings: [],
calendarConnected: false,
updatedAt: null,
detectedCalls: [],
permissionSetup: null,
skippedMeetingIds: [],
notice: null,
});
break;
case 'permission':
next.permissions[command.permission] = 'granted';
break;
case 'begin-permission-setup':
next.permissionSetup = {};
break;
case 'cancel-permission-setup':
next.permissionSetup = null;
break;
case 'complete-setup':
if (
Object.values(next.permissions).every((status) => status === 'granted')
) {
next.settings.setupCompleted = true;
next.permissionSetup = null;
}
break;
case 'record':
if (
!Object.values(next.permissions).every((status) => status === 'granted')
) {
next.permissionSetup = { windowId: command.windowId, intent: 'record' };
break;
}
next.permissionSetup = null;
next.activeRecording = {
id: '00000000-0000-4000-8000-000000000006',
windowId: 'preview-call',
title: 'Unscheduled conversation',
startedAt: new Date().toISOString(),
status: 'recording',
};
break;
case 'pause':
if (next.activeRecording) {
next.activeRecording.status = 'paused';
next.activeRecording.pausedAt = new Date().toISOString();
}
break;
case 'resume':
if (next.activeRecording) {
next.activeRecording.status = 'recording';
next.activeRecording.pausedMilliseconds =
(next.activeRecording.pausedMilliseconds ?? 0) +
(next.activeRecording.pausedAt
? Date.now() - Date.parse(next.activeRecording.pausedAt)
: 0);
next.activeRecording.pausedAt = undefined;
}
break;
case 'stop':
if (next.activeRecording)
next.recordings.unshift({
id: next.activeRecording.id,
title: next.activeRecording.title,
startedAt: next.activeRecording.startedAt,
endedAt: new Date().toISOString(),
status: 'PROCESSING',
});
next.activeRecording = null;
next.notice = {
type: 'preview',
message:
'Preview recording finished. Refresh to simulate completed processing.',
};
break;
case 'settings':
next.settings = { ...next.settings, ...command.settings };
break;
case 'skip':
next.skippedMeetingIds.push(command.meetingId);
break;
case 'unskip':
next.skippedMeetingIds = next.skippedMeetingIds.filter(
(id) => id !== command.meetingId,
);
break;
case 'join':
next.notice = {
type: 'preview',
message:
'Preview only. A live meeting link would open in your browser.',
};
break;
case 'open-recording':
next.notice = {
type: 'preview',
message:
'Preview only. This opens the recording in your Twenty workspace.',
};
break;
case 'open-recordings':
next.notice = {
type: 'preview',
message:
'Preview only. This opens all recordings in your Twenty workspace.',
};
break;
case 'open-calendar-settings':
next.notice = {
type: 'preview',
message: 'Preview only. This opens calendar settings in Twenty.',
};
break;
case 'refresh':
next.recordings.forEach((recording) => {
if (recording.status === 'PROCESSING') recording.status = 'COMPLETED';
});
break;
case 'dismiss-error':
next.notice = null;
break;
}
return next;
};

View File

@@ -0,0 +1,142 @@
import { type CompanionState } from '../../shared/types/CompanionState';
export const previewState = (initial: CompanionState): CompanionState => {
const now = Date.now();
const at = (minutes: number) =>
new Date(now + minutes * 60_000).toISOString();
const scenario = new URLSearchParams(window.location.search).get('preview');
const state: CompanionState = {
...initial,
connection: 'connected',
workspace: { id: 'preview', name: 'Acme' },
calendarConnected: true,
settings: {
...initial.settings,
setupCompleted: !['welcome', 'permissions', 'denied'].includes(
scenario ?? '',
),
},
permissions: ['welcome', 'permissions', 'denied'].includes(scenario ?? '')
? {
microphone: scenario === 'denied' ? 'denied' : 'not_requested',
'system-audio': 'not_requested',
accessibility: 'not_requested',
}
: {
microphone: 'granted',
'system-audio': 'granted',
accessibility: 'granted',
},
updatedAt: at(0),
meetings: [
{
id: '00000000-0000-4000-8000-000000000001',
title: 'Design sync',
startsAt: at(7),
endsAt: at(37),
url: 'https://meet.google.com/example',
recordingEnabled: true,
usesCalendarBot: true,
},
{
id: '00000000-0000-4000-8000-000000000002',
title: 'Customer conversation',
startsAt: at(67),
endsAt: at(97),
url: 'https://meet.google.com/example-two',
recordingEnabled: true,
usesCalendarBot: true,
},
{
id: '00000000-0000-4000-8000-000000000003',
title: 'Weekly team check-in',
startsAt: at(127),
endsAt: at(157),
url: 'https://meet.google.com/example-three',
recordingEnabled: false,
usesCalendarBot: false,
},
{
id: '00000000-0000-4000-8000-000000000007',
title: 'Project planning',
startsAt: at(187),
endsAt: at(217),
url: 'https://meet.google.com/example-four',
recordingEnabled: true,
usesCalendarBot: true,
},
{
id: '00000000-0000-4000-8000-000000000008',
title: 'Customer onboarding',
startsAt: at(247),
endsAt: at(277),
url: 'https://meet.google.com/example-five',
recordingEnabled: true,
usesCalendarBot: true,
},
],
recordings: [
{
id: '00000000-0000-4000-8000-000000000004',
title: 'A new way to organize your day',
status: 'COMPLETED',
startedAt: at(-90),
endedAt: at(-60),
participants: [
{ id: 'email:alex@example.com', name: 'Alex Martin' },
{ id: 'email:sam@example.com', name: 'Sam Lee' },
{ id: 'email:jules@example.com', name: 'Jules Garcia' },
],
},
{
id: '00000000-0000-4000-8000-000000000005',
title: 'Product catch-up',
status: 'PROCESSING',
startedAt: at(-45),
endedAt: at(-10),
participants: [{ id: 'email:morgan@example.com', name: 'Morgan Chen' }],
},
{
id: '00000000-0000-4000-8000-000000000009',
title: 'Unscheduled conversation',
status: 'COMPLETED',
startedAt: at(-180),
endedAt: at(-160),
participants: [],
},
],
};
if (scenario === 'welcome') {
state.connection = 'disconnected';
state.updatedAt = null;
}
if (scenario === 'error') {
state.connection = 'disconnected';
state.error = {
message: 'Could not unlock your Twenty connection. Reconnect to Twenty.',
};
}
if (scenario === 'missing-integration') {
state.error = {
message:
'Desktop Recorder is unavailable in this workspace. Install it to continue.',
recovery: {
type: 'open-desktop-installation',
serverUrl: state.serverUrl,
},
};
}
if (scenario === 'empty') {
state.recordings = [];
state.meetings = [];
}
if (scenario === 'recording')
state.activeRecording = {
id: '00000000-0000-4000-8000-000000000006',
windowId: 'preview-call',
title: 'Customer conversation',
startedAt: at(-4),
status: 'recording',
};
return state;
};

View File

@@ -0,0 +1,10 @@
import { i18n } from '@lingui/core';
export const recordingLabel = (status: string) =>
({
COMPLETED: i18n._('Saved to Twenty'),
PROCESSING: i18n._('Processing'),
FAILED: i18n._('Recording failed'),
RECORDING: i18n._('Recording'),
PENDING: i18n._('Preparing'),
})[status] ?? i18n._('Preparing');

View File

@@ -0,0 +1,26 @@
type ShortcutKeyboardEvent = Pick<
KeyboardEvent,
'key' | 'code' | 'repeat' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'
>;
export const shortcutFromKeyboardEvent = (
event: ShortcutKeyboardEvent,
): string | null => {
if (event.repeat || !(event.metaKey || event.ctrlKey || event.altKey))
return null;
let key: string;
if (event.code === 'Space') key = 'Space';
else if (/^Key[A-Z]$/.test(event.code)) key = event.code.slice(3);
else if (/^Digit[0-9]$/.test(event.code)) key = event.code.slice(5);
else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(event.code)) key = event.code;
else return null;
return [
event.metaKey && 'Command',
event.ctrlKey && 'Control',
event.altKey && 'Alt',
event.shiftKey && 'Shift',
key,
]
.filter(Boolean)
.join('+');
};

View File

@@ -0,0 +1,12 @@
import { type Settings } from '../types/Settings';
export const DEFAULT_SETTINGS: Settings = {
openShortcut: 'CommandOrControl+Shift+Space',
autoJoin: true,
autoRecord: false,
launchAtLogin: false,
appearance: 'system',
notifyOnDetectedCall: true,
showMeetingCountdown: true,
setupCompleted: false,
};

View File

@@ -0,0 +1,15 @@
export type ActiveRecording = {
id: string;
windowId: string;
title: string;
startedAt: string;
status:
| 'starting'
| 'recording'
| 'pausing'
| 'paused'
| 'resuming'
| 'stopping';
pausedAt?: string;
pausedMilliseconds?: number;
};

View File

@@ -0,0 +1,9 @@
import { type Meeting } from './Meeting';
import { type Recording } from './Recording';
export type Agenda = {
workspace: { id: string; name: string; logoUrl?: string };
meetings: Meeting[];
recordings: Recording[];
calendarConnected: boolean;
};

View File

@@ -0,0 +1,10 @@
import { type CompanionState } from './CompanionState';
import { type CompanionCommand } from './CompanionCommand';
import { type CompanionPage } from './CompanionPage';
export type CompanionBridge = {
getState: () => Promise<CompanionState>;
command: (command: CompanionCommand) => Promise<void>;
onState: (listener: (state: CompanionState) => void) => () => void;
onNavigate: (listener: (page: CompanionPage) => void) => () => void;
};

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