diff --git a/apps/client/package.json b/apps/client/package.json index ca6cb7d7..e38a82ce 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/renderer/core/NetworkClient.ts b/apps/client/src/renderer/core/NetworkClient.ts index 08c727de..561398ac 100644 --- a/apps/client/src/renderer/core/NetworkClient.ts +++ b/apps/client/src/renderer/core/NetworkClient.ts @@ -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'; /** @@ -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), diff --git a/apps/client/src/renderer/core/webrtc/RemoteMediaRouter.ts b/apps/client/src/renderer/core/webrtc/RemoteMediaRouter.ts index 40a1b478..5016847b 100644 --- a/apps/client/src/renderer/core/webrtc/RemoteMediaRouter.ts +++ b/apps/client/src/renderer/core/webrtc/RemoteMediaRouter.ts @@ -67,7 +67,8 @@ export class RemoteMediaRouter { private async routeAudioOutput(category: 'voice' | 'screen'): Promise { 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(); } diff --git a/apps/client/src/renderer/utils/idleCallback.ts b/apps/client/src/renderer/utils/idleCallback.ts new file mode 100644 index 00000000..e9866135 --- /dev/null +++ b/apps/client/src/renderer/utils/idleCallback.ts @@ -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); +} diff --git a/apps/client/src/renderer/utils/serverAddress.ts b/apps/client/src/renderer/utils/serverAddress.ts index 5b94f120..42fa6089 100644 --- a/apps/client/src/renderer/utils/serverAddress.ts +++ b/apps/client/src/renderer/utils/serverAddress.ts @@ -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. diff --git a/apps/client/src/renderer/views/EmojiPicker.ts b/apps/client/src/renderer/views/EmojiPicker.ts index 0262232c..043e0df3 100644 --- a/apps/client/src/renderer/views/EmojiPicker.ts +++ b/apps/client/src/renderer/views/EmojiPicker.ts @@ -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'; @@ -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). @@ -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; @@ -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 = []; } diff --git a/apps/client/test/idleCallback.test.ts b/apps/client/test/idleCallback.test.ts new file mode 100644 index 00000000..e84eb03e --- /dev/null +++ b/apps/client/test/idleCallback.test.ts @@ -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, +): Promise { + 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]); + }); +}); diff --git a/apps/client/test/networkClient.test.ts b/apps/client/test/networkClient.test.ts index 59894edd..32d90307 100644 --- a/apps/client/test/networkClient.test.ts +++ b/apps/client/test/networkClient.test.ts @@ -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'); + }); +} diff --git a/apps/client/test/serverAddress.test.ts b/apps/client/test/serverAddress.test.ts index 37470d09..efb9e7d3 100644 --- a/apps/client/test/serverAddress.test.ts +++ b/apps/client/test/serverAddress.test.ts @@ -7,7 +7,7 @@ import { settingsStore } from '../src/renderer/stores/settingsStore'; import { toAbsoluteServerIconUrl } from '../src/renderer/utils/avatar'; import { captureHostedServerLeaveState } from '../src/renderer/utils/hostedServer'; import { - isLoopbackHost, isSecureServer, normalizeHost, parseServerHostInput, parseServerUrl, sameServerAddress, serverAddressKey, + isIpLiteralHost, isLoopbackHost, isSecureServer, normalizeHost, parseServerHostInput, parseServerUrl, sameServerAddress, serverAddressKey, setServerTransport, toHttpBase, toWsUrl, } from '../src/renderer/utils/serverAddress'; @@ -57,6 +57,15 @@ test('the loopback list is one and accepts IPv6 with or without brackets', () => for (const host of ['example.test', '192.168.0.2', '[::2]', '']) assert.equal(isLoopbackHost(host), false, host); }); +test('IP literals are told apart from names resolved by DNS (#883)', () => { + for (const host of ['147.15.118.243', ' 10.0.0.1 ', '::ffff:93.184.216.34', '[2001:db8::1]', 'wss://[::1]']) { + assert.equal(isIpLiteralHost(host), true, host); + } + for (const host of ['147-15-118-243.sslip.io', 'chat.example', 'localhost', '1.2.3', '']) { + assert.equal(isIpLiteralHost(host), false, host); + } +}); + test('URLs put IPv6 between brackets, drop schemes and keep the host as typed', () => { assert.equal(toWsUrl({ host: '::1', port: 3000 }), 'ws://[::1]:3000'); assert.equal(toWsUrl({ host: '[::1]', port: 3000 }), 'ws://[::1]:3000'); diff --git a/apps/client/test/settingsNavigationSmoke.cjs b/apps/client/test/settingsNavigationSmoke.cjs index 2bbc2f72..a1b73392 100644 --- a/apps/client/test/settingsNavigationSmoke.cjs +++ b/apps/client/test/settingsNavigationSmoke.cjs @@ -994,6 +994,7 @@ async function runSettingsNavigationSmoke() { const pickerOptions = { container: document.body, anchor, emojiOnly: true, floating: true, onSelectEmoji: emoji => selected.push(emoji) }; const picker = new EmojiPicker(pickerOptions); let laterPicker = null; + let safariPicker = null; try { setReduced(false); await picker.open(); @@ -1056,9 +1057,26 @@ async function runSettingsNavigationSmoke() { const laterDrawn = catalogCells().filter(drawn).length; check(laterDrawn > 0 && laterDrawn < 300 && visibleCells().length > 0 && visibleCells().every(drawn), `Later pickers draw only the visible glyphs, within the opening click (${laterDrawn} drawn)`); + laterPicker.close(); + const idleApis = { request: window.requestIdleCallback, cancel: window.cancelIdleCallback }; + window.requestIdleCallback = undefined; + window.cancelIdleCallback = undefined; + try { + safariPicker = new EmojiPicker(pickerOptions); + await safariPicker.open(); + pickerBody = document.querySelector('.emoji-picker:not([data-ui-closing]) .emoji-picker-body'); + for (let attempt = 0; attempt < 400 && !catalogCells().every(drawn); attempt++) await wait(50); + check(catalogCells().length === catalogSize && catalogCells().every(drawn), + 'Without requestIdleCallback (Safari) the picker still lists and draws every emoji'); + safariPicker.close(); + } finally { + window.requestIdleCallback = idleApis.request; + window.cancelIdleCallback = idleApis.cancel; + } } finally { picker.destroy(); laterPicker?.destroy(); + safariPicker?.destroy(); anchor.remove(); } diff --git a/apps/client/test/voiceConnection.test.ts b/apps/client/test/voiceConnection.test.ts index fb16dfe4..5374359d 100644 --- a/apps/client/test/voiceConnection.test.ts +++ b/apps/client/test/voiceConnection.test.ts @@ -1098,6 +1098,63 @@ test('remote WebRTC output changes keep default voice separate from screen speak assert.equal(closingRouter.getVoiceAudioLevel('closing'), null, 'A retired graph has no activity sample'); }); +test('without AudioContext.setSinkId (Safari) remote voice still plays on the default output (#883)', async (t) => { + let plays = 0; + class SafariAudioElement extends AudioElement { + sinkId = ''; + async setSinkId(id: string) { this.sinkId = id; } + override async play() { plays++; } + } + // WebKit accepts `{ sinkId }` in the constructor, ignores it and has no setSinkId. + class SafariContext { + state = 'suspended'; + destination = {}; + createMediaStreamSource() { return { connect() {}, disconnect() {} }; } + createGain() { return { gain: { value: 1 }, connect() {}, disconnect() {} }; } + createAnalyser() { + return { context: this, fftSize: 1024, connect() {}, disconnect() {}, getFloatTimeDomainData(values: Float32Array) { values.fill(0); } }; + } + async resume() { this.state = 'running'; } + async close() { this.state = 'closed'; } + } + const restoreMediaGlobals: Array<() => void> = []; + for (const [key, value] of Object.entries({ + document: { createElement: () => new SafariAudioElement(), body: { appendChild() {} } }, + MediaStream: Stream, AudioContext: SafariContext, + })) { + const previous = Object.getOwnPropertyDescriptor(globalThis, key); + Object.defineProperty(globalThis, key, { configurable: true, value }); + restoreMediaGlobals.push(() => { if (previous) Object.defineProperty(globalThis, key, previous); else Reflect.deleteProperty(globalThis, key); }); + } + const previousSettings = { + selectedSpeakerId: settingsStore.selectedSpeakerId, + advancedAudioOutputs: settingsStore.advancedAudioOutputs, + userVolumes: settingsStore.userVolumes, + }; + const warnings = t.mock.method(console, 'warn', () => {}); + const router = new RemoteMediaRouter(() => new ParticipantManager()); + t.after(() => { + try { + router.closeAllMedia(); + Object.assign(settingsStore, previousSettings); + } finally { + for (const restore of restoreMediaGlobals.reverse()) restore(); + } + }); + settingsStore.selectedSpeakerId = ''; + settingsStore.advancedAudioOutputs = false; + settingsStore.userVolumes = {}; + const microphone = { id: 'microphone', kind: 'audio', stop() {} } as MediaStreamTrack; + router.ensureVoiceAudioElement('peer', new MediaStream([microphone])); + await new Promise(resolve => setImmediate(resolve)); + + assert.equal(warnings.mock.callCount(), 0, String(warnings.mock.calls[0]?.arguments)); + assert.equal(plays, 1); + assert.equal(router['audioContexts'].get('voice')?.state, 'running'); + await assert.rejects(router.setOutputDeviceIds('headphones', 'headphones'), /unavailable/, + 'a specific speaker is refused, so the selection rolls back instead of going silent'); +}); + test('screen audio opt-in and 0–200% volume stay independent of voice deafen in the shared P2P/SFU router', (t) => { const gains: Array<{ gain: { value: number }; connect: () => void; disconnect: () => void }> = []; class Context { diff --git a/release-notes/883-safari-web.json b/release-notes/883-safari-web.json new file mode 100644 index 00000000..71b2e56f --- /dev/null +++ b/release-notes/883-safari-web.json @@ -0,0 +1,5 @@ +{ + "group": "correcoes", + "pt-BR": "No Safari, a versão web agora entra em servidores acessados por endereço IP, sem ficar presa em \"conectando\". As vozes da chamada saem no alto-falante e o seletor de emojis deixou de abrir vazio. Boas-vindas, fãs do Safari!", + "en": "On Safari, the web version now joins servers reached by an IP address instead of hanging on \"connecting\". Voices in calls play through your speakers, and the emoji picker no longer opens empty. Welcome aboard, Safari fans!" +}