Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:preload": "tsc -p tsconfig.preload.json && node scripts/buildPreloads.mjs",
"start": "electron .",
"screenshots:onboarding": "node scripts/capture-onboarding-screenshots.cjs",
"test": "tsc -p tsconfig.test.json && node dist-test/test/utils.test.js && node --test dist-test/test/eventBus.test.js dist-test/test/featureAvailability.test.js dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/presence.test.js dist-test/test/gameShare.test.js dist-test/test/rolePermissions.test.js dist-test/test/networkClient.test.js dist-test/test/serverShutdownNotice.test.js dist-test/test/voiceConnection.test.js dist-test/test/voiceModeReconnect.test.js dist-test/test/webRtcManager.test.js dist-test/test/screenSharing.test.js dist-test/test/screenShareControls.test.js dist-test/test/screenShareFailures.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/webScreenPublisher.test.js dist-test/test/videoDiagnostics.test.js dist-test/test/videoTelemetry.test.js dist-test/test/performanceDiagnostics.test.js dist-test/test/responsivenessMonitor.test.js dist-test/test/shortcuts.test.js dist-test/test/releaseNotes.test.js dist-test/test/audioPreferences.test.js dist-test/test/serverSettings.test.js dist-test/test/voiceRooms.test.js dist-test/test/favorites.test.js dist-test/test/cameraEffects.test.js dist-test/test/screenColorPicker.test.js dist-test/test/serverSettingsStore.test.js dist-test/test/hostedServerLifecycle.test.js dist-test/test/messageEditing.test.js dist-test/test/messageSelection.test.js dist-test/test/markdownFormatting.test.js dist-test/test/serverEventCalendar.test.js dist-test/test/autoEntry.test.js dist-test/test/hostedServers.test.js dist-test/test/serverAddress.test.js dist-test/test/backgroundRouting.test.js dist-test/test/nicknames.test.js dist-test/test/livePulse.test.js dist-test/test/botLocalePreferencesStore.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/sessionBinding.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/mediaFolders.test.js dist-test/test/attachmentDownload.test.js dist-test/test/localPreparationView.test.js test/serverManagerLifecycle.test.cjs test/globalInputHookProcess.test.cjs test/developmentProfile.test.cjs test/updaterCompatibility.test.cjs test/crashRecovery.test.cjs test/serverEventCalendarIpc.test.cjs test/hostingSkills.test.cjs test/fileBatchDownloadIpc.test.cjs test/identityExport.test.cjs test/identityService.test.cjs test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/steamPresence.test.cjs test/taskbarCallControls.test.cjs test/screenSharingUi.test.cjs test/soundboardLimiter.test.cjs test/trayManager.test.cjs test/onboardingShots.test.cjs test/ipcSenderPolicy.test.cjs test/windowSandbox.test.cjs test/recentSoundSaveIpc.test.cjs test/webDeployment.test.cjs test/webMiniapps.test.cjs test/zipFormat.test.cjs dist-test/test/settingsPersistence.test.js test/startupLoading.test.cjs dist-test/test/pointerReorder.test.js dist-test/test/rovingFocus.test.js && node --test dist-test/test/authHardening.test.js dist-test/test/hardeningAndOverlay.test.js dist-test/test/dmService.test.js dist-test/test/dmTransport.test.js dist-test/test/dmRelayPacer.test.js dist-test/test/dmIncomingBurst.test.js dist-test/test/callScope.test.js dist-test/test/cameraPublication.test.js dist-test/test/remoteMediaSink.test.js dist-test/test/voiceActivity.test.js dist-test/test/dmWire.test.js dist-test/test/dmFriends.test.js test/ipcContract.test.cjs test/ipcRegistry.test.cjs test/appIpc.test.cjs test/dmIpc.test.cjs dist-test/test/rewatchPolicy.test.js dist-test/test/stageTiles.test.js test/motionTokens.test.cjs dist-test/test/nativeFormAnswers.test.js dist-test/test/shellEvents.test.js dist-test/test/initial.test.js",
"test": "tsc -p tsconfig.test.json && node dist-test/test/utils.test.js && node --test dist-test/test/eventBus.test.js dist-test/test/featureAvailability.test.js dist-test/test/botInteractions.test.js dist-test/test/botScreens.test.js dist-test/test/commandUi.test.js dist-test/test/commandAutocomplete.test.js dist-test/test/soundDownloads.test.js dist-test/test/soundDownloadConfirmation.test.js dist-test/test/presence.test.js dist-test/test/gameShare.test.js dist-test/test/rolePermissions.test.js dist-test/test/networkClient.test.js dist-test/test/serverShutdownNotice.test.js dist-test/test/voiceConnection.test.js dist-test/test/voiceModeReconnect.test.js dist-test/test/webRtcManager.test.js dist-test/test/screenSharing.test.js dist-test/test/screenShareControls.test.js dist-test/test/screenShareFailures.test.js dist-test/test/screenSubscriptions.test.js dist-test/test/webScreenPublisher.test.js dist-test/test/videoDiagnostics.test.js dist-test/test/videoTelemetry.test.js dist-test/test/performanceDiagnostics.test.js dist-test/test/responsivenessMonitor.test.js dist-test/test/shortcuts.test.js dist-test/test/releaseNotes.test.js dist-test/test/audioPreferences.test.js dist-test/test/serverSettings.test.js dist-test/test/voiceRooms.test.js dist-test/test/favorites.test.js dist-test/test/cameraEffects.test.js dist-test/test/screenColorPicker.test.js dist-test/test/serverSettingsStore.test.js dist-test/test/hostedServerLifecycle.test.js dist-test/test/messageEditing.test.js dist-test/test/messageSelection.test.js dist-test/test/markdownFormatting.test.js dist-test/test/serverEventCalendar.test.js dist-test/test/autoEntry.test.js dist-test/test/hostedServers.test.js dist-test/test/serverAddress.test.js dist-test/test/idleCallback.test.js dist-test/test/backgroundRouting.test.js dist-test/test/nicknames.test.js dist-test/test/livePulse.test.js dist-test/test/botLocalePreferencesStore.test.js dist-test/test/serverMonitor.test.js dist-test/test/serverAudit.test.js dist-test/test/sessionBinding.test.js dist-test/test/localPermissions.test.js dist-test/test/botDeviceStorage.test.js dist-test/test/localExecutionService.test.js dist-test/test/localTools.test.js dist-test/test/localExecutionRenderer.test.js dist-test/test/localExecutionTasks.test.js dist-test/test/localAudioSender.test.js dist-test/test/worker.test.js dist-test/test/workerClient.test.js dist-test/test/workerProtocol.test.js dist-test/test/mediaFolders.test.js dist-test/test/attachmentDownload.test.js dist-test/test/localPreparationView.test.js test/serverManagerLifecycle.test.cjs test/globalInputHookProcess.test.cjs test/developmentProfile.test.cjs test/updaterCompatibility.test.cjs test/crashRecovery.test.cjs test/serverEventCalendarIpc.test.cjs test/hostingSkills.test.cjs test/fileBatchDownloadIpc.test.cjs test/identityExport.test.cjs test/identityService.test.cjs test/localExecutionIpc.test.cjs test/localToolsTab.test.cjs test/steamPresence.test.cjs test/taskbarCallControls.test.cjs test/screenSharingUi.test.cjs test/soundboardLimiter.test.cjs test/trayManager.test.cjs test/onboardingShots.test.cjs test/ipcSenderPolicy.test.cjs test/windowSandbox.test.cjs test/recentSoundSaveIpc.test.cjs test/webDeployment.test.cjs test/webMiniapps.test.cjs test/zipFormat.test.cjs dist-test/test/settingsPersistence.test.js test/startupLoading.test.cjs dist-test/test/pointerReorder.test.js dist-test/test/rovingFocus.test.js && node --test dist-test/test/authHardening.test.js dist-test/test/hardeningAndOverlay.test.js dist-test/test/dmService.test.js dist-test/test/dmTransport.test.js dist-test/test/dmRelayPacer.test.js dist-test/test/dmIncomingBurst.test.js dist-test/test/callScope.test.js dist-test/test/cameraPublication.test.js dist-test/test/remoteMediaSink.test.js dist-test/test/voiceActivity.test.js dist-test/test/dmWire.test.js dist-test/test/dmFriends.test.js test/ipcContract.test.cjs test/ipcRegistry.test.cjs test/appIpc.test.cjs test/dmIpc.test.cjs dist-test/test/rewatchPolicy.test.js dist-test/test/stageTiles.test.js test/motionTokens.test.cjs dist-test/test/nativeFormAnswers.test.js dist-test/test/shellEvents.test.js dist-test/test/initial.test.js",
"test:development": "node test/developmentProfileSmoke.cjs",
"test:categories": "node --test test/channelCategoriesDom.test.cjs",
"test:calendar": "tsc -p tsconfig.test.json && node --test dist-test/test/serverEventCalendar.test.js dist-test/test/calendarDate.test.js test/serverEventCalendarIpc.test.cjs test/recentSoundSaveIpc.test.cjs",
Expand Down
18 changes: 17 additions & 1 deletion apps/client/src/renderer/core/NetworkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,24 @@ import { t } from '../i18n';
import { translateProtocolError } from '../i18n/protocolErrors';
import { settingsStore } from '../stores/settingsStore';
import { connectionStore } from '../stores/connectionStore';
import { isLoopbackHost, isSecureServer, normalizeHost, toWsUrl } from '../utils/serverAddress';
import { isIpLiteralHost, isLoopbackHost, isSecureServer, normalizeHost, toWsUrl } from '../utils/serverAddress';
import { platformSupports } from '../platform/capabilities';

/**
* WebKit 26 (Safari and every iOS browser) leaves a WebSocket to an IP address
* stuck in CONNECTING without ever sending a TCP SYN, so the web build could
* not enter servers addressed by IP (#883). A second socket to the same URL,
* closed right away, releases the first one; being closed before connecting,
* it sends nothing to the server. Names resolved by DNS and loopback work.
* https://bugs.webkit.org/show_bug.cgi?id=308073
* https://developer.apple.com/forums/thread/817697
*/
function needsWebKitIpSocketNudge(host: string): boolean {
if (typeof navigator === 'undefined' || !isIpLiteralHost(host) || isLoopbackHost(host)) return false;
const userAgent = navigator.userAgent;
return /AppleWebKit\//.test(userAgent) && !/(Chrome|Chromium|Edg|OPR)\//.test(userAgent);
}

export type ConnectionStatus = 'DISCONNECTED' | 'CONNECTING' | 'CONNECTED' | 'RECONNECTING';

/**
Expand Down Expand Up @@ -407,6 +422,7 @@ export class NetworkClient {
return;
}
this.ws = new WebSocket(this.currentServerUrl);
if (needsWebKitIpSocketNudge(host)) new WebSocket(this.currentServerUrl).close();
} catch (error) {
fail(new Error(t('network.addressError', {
url: this.currentServerUrl, error: error instanceof Error ? error.message : String(error),
Expand Down
3 changes: 2 additions & 1 deletion apps/client/src/renderer/core/webrtc/RemoteMediaRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export class RemoteMediaRouter {
private async routeAudioOutput(category: 'voice' | 'screen'): Promise<void> {
const context = this.getOrCreateAudioContext(category);
const sinkId = this.speakerDeviceIds[category] ?? effectiveAudioOutput(category);
if (typeof context.setSinkId !== 'function') throw new Error('Audio output selection unavailable');
// Without AudioContext.setSinkId (Safari, #883) the graph already plays on the default output;
// only a specific speaker is out of reach, and setAudioOutputSink refuses that one.
await setAudioOutputSink(context, sinkId);
if (context.state === 'suspended') await context.resume();
}
Expand Down
18 changes: 18 additions & 0 deletions apps/client/src/renderer/utils/idleCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Work slice granted per fallback callback; short enough to keep a 60 Hz frame. */
const FALLBACK_BUDGET_MS = 8;

/**
* Runs `callback` when the renderer is idle and returns its cancellation.
* Safari ships without requestIdleCallback (#883), so there the work runs in short timer slices.
*/
export function requestIdle(callback: IdleRequestCallback): () => void {
if (typeof globalThis.requestIdleCallback === 'function') {
const handle = globalThis.requestIdleCallback(callback);
return () => globalThis.cancelIdleCallback(handle);
}
const timer = setTimeout(() => {
const started = performance.now();
callback({ didTimeout: false, timeRemaining: () => Math.max(0, FALLBACK_BUDGET_MS - (performance.now() - started)) });
}, 1);
return () => clearTimeout(timer);
}
6 changes: 6 additions & 0 deletions apps/client/src/renderer/utils/serverAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export function isLoopbackHost(host: string): boolean {
return LOOPBACK_HOSTS.has(normalizeHost(host));
}

/** An IPv4 or IPv6 address written out, as opposed to a name resolved by DNS. */
export function isIpLiteralHost(host: string): boolean {
const bare = normalizeHost(host);
return /^\d{1,3}(\.\d{1,3}){3}$/.test(bare) || bare.includes(':');
}

/**
* Host as written in a URL: IPv6 between brackets. The case is kept, because
* URLs already persisted (server icons) were built with it.
Expand Down
13 changes: 7 additions & 6 deletions apps/client/src/renderer/views/EmojiPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { escapeHtml } from '../utils/html';
import { animateEnter, ownSurface, removeWithMotion } from '../utils/surfaceMotion';
import { normalizeSearchString } from '../utils/search';
import { scrollWithin, smoothScrollTo } from '../utils/scroll';
import { requestIdle } from '../utils/idleCallback';
import { platformSupports } from '../platform/capabilities';
import { mediaFolderLabel, mediaSourceButton, picksMediaFolder } from '../platform/mediaFolders';

Expand Down Expand Up @@ -97,7 +98,7 @@ export class EmojiPicker {
private pendingGlyphs: HTMLElement[] = [];
private glyphFrame = 0;
private glyphTimer = 0;
private glyphTask = 0;
private cancelGlyphTask: (() => void) | null = null;

constructor(options: EmojiPickerOptions) {
// Stickers come from a folder on this computer: without one, emojis only (#858).
Expand Down Expand Up @@ -507,9 +508,9 @@ export class EmojiPicker {

/** Draws the remaining glyphs while the renderer is idle, so scrolling later finds them ready. */
private scheduleGlyphs(): void {
if (this.glyphTask || this.pendingGlyphs.length === 0) return;
this.glyphTask = requestIdleCallback((deadline) => {
this.glyphTask = 0;
if (this.cancelGlyphTask || this.pendingGlyphs.length === 0) return;
this.cancelGlyphTask = requestIdle((deadline) => {
this.cancelGlyphTask = null;
const body = this.bodyEl;
if (!body) return;
let spent = 0;
Expand All @@ -531,10 +532,10 @@ export class EmojiPicker {
private stopGlyphs(): void {
cancelAnimationFrame(this.glyphFrame);
clearTimeout(this.glyphTimer);
cancelIdleCallback(this.glyphTask);
this.cancelGlyphTask?.();
this.glyphFrame = 0;
this.glyphTimer = 0;
this.glyphTask = 0;
this.cancelGlyphTask = null;
this.pendingGlyphs = [];
}

Expand Down
51 changes: 51 additions & 0 deletions apps/client/test/idleCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { setTimeout as sleep } from 'node:timers/promises';
import { requestIdle } from '../src/renderer/utils/idleCallback';

function withIdleCallbacks(
request: typeof globalThis.requestIdleCallback | undefined,
cancel: typeof globalThis.cancelIdleCallback | undefined,
run: () => Promise<void>,
): Promise<void> {
const saved = [Object.getOwnPropertyDescriptor(globalThis, 'requestIdleCallback'), Object.getOwnPropertyDescriptor(globalThis, 'cancelIdleCallback')] as const;
const set = (name: 'requestIdleCallback' | 'cancelIdleCallback', value: unknown, original?: PropertyDescriptor) => {
if (value !== undefined) Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
else if (original) Object.defineProperty(globalThis, name, original);
else Reflect.deleteProperty(globalThis, name);
};
set('requestIdleCallback', request);
set('cancelIdleCallback', cancel);
return run().finally(() => {
set('requestIdleCallback', undefined, saved[0]);
set('cancelIdleCallback', undefined, saved[1]);
});
}

test('without requestIdleCallback (Safari) idle work still runs, in short slices (#883)', () => withIdleCallbacks(undefined, undefined, async () => {
assert.equal(typeof globalThis.requestIdleCallback, 'undefined');
const slices: number[] = [];
requestIdle((deadline) => slices.push(deadline.timeRemaining()));
await sleep(20);
assert.equal(slices.length, 1);
assert.ok(slices[0] > 0 && slices[0] <= 8, `slice of ${slices[0]} ms`);

let ran = false;
const cancel = requestIdle(() => { ran = true; });
cancel();
await sleep(20);
assert.equal(ran, false, 'a cancelled task never runs');
}));

test('the native requestIdleCallback is used where it exists', () => {
const requested: IdleRequestCallback[] = [];
const cancelled: number[] = [];
const request = (callback: IdleRequestCallback) => requested.push(callback);
const cancel = (handle: number) => { cancelled.push(handle); };
return withIdleCallbacks(request, cancel, async () => {
const callback: IdleRequestCallback = () => {};
requestIdle(callback)();
assert.deepEqual(requested, [callback]);
assert.deepEqual(cancelled, [1]);
});
});
44 changes: 44 additions & 0 deletions apps/client/test/networkClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,3 +735,47 @@ test('the web version still dials this machine and secure servers, and words a f
f.lastSocket().drop();
await assert.rejects(remote, { message: t('network.webSecureFailed', { host: 'chat.example', port: 443 }) });
});

const SAFARI_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3 Safari/605.1.15';
const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';

function useUserAgent(context: TestContext, userAgent: string): void {
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent } });
context.after(() => {
if (original) Object.defineProperty(globalThis, 'navigator', original);
else Reflect.deleteProperty(globalThis, 'navigator');
});
}

test('WebKit releases a socket to an IP address with a companion closed before it connects (#883)', async context => {
useUserAgent(context, SAFARI_UA);
const f = fixture(context);
const pending = f.client.connect('147.15.118.243', 3000, identity, 'Fixture');
assert.equal(f.sockets.length, 2);
const [socket, companion] = f.sockets;
assert.equal(companion.url, socket.url);
assert.equal(companion.readyState, f.Socket.CLOSED);
assert.equal(companion.sent.length, 0);
socket.open();
socket.auth();
await pending;
assert.equal(f.client.getStatus(), 'CONNECTED');
});

for (const [label, userAgent, host] of [
['Chromium', CHROME_UA, '147.15.118.243'],
['WebKit with a DNS name', SAFARI_UA, 'chat.example'],
['WebKit with loopback', SAFARI_UA, '127.0.0.1'],
] as const) {
test(`${label} opens a single socket (#883)`, async context => {
useUserAgent(context, userAgent);
const f = fixture(context);
const pending = f.client.connect(host, 3000, identity, 'Fixture');
assert.equal(f.sockets.length, 1);
f.lastSocket().open();
f.lastSocket().auth();
await pending;
assert.equal(f.client.getStatus(), 'CONNECTED');
});
}
Loading
Loading