diff --git a/packages/frontend/apps/electron-renderer/src/app/app.tsx b/packages/frontend/apps/electron-renderer/src/app/app.tsx index 25059deb08..4f020fb8f4 100644 --- a/packages/frontend/apps/electron-renderer/src/app/app.tsx +++ b/packages/frontend/apps/electron-renderer/src/app/app.tsx @@ -6,10 +6,10 @@ import { I18nProvider } from '@affine/core/modules/i18n'; import createEmotionCache from '@affine/core/utils/create-emotion-cache'; import { CacheProvider } from '@emotion/react'; import { FrameworkRoot, getCurrentStore } from '@toeverything/infra'; -import { Suspense } from 'react'; +import { Suspense, useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; -import { setupEffects } from './effects'; +import { setupEffects, useIsOnBattery } from './effects'; import { DesktopLanguageSync } from './language-sync'; import { DesktopThemeSync } from './theme-sync'; @@ -40,6 +40,15 @@ const future = { } as const; export function App() { + const isOnBattery = useIsOnBattery(); + + useEffect(() => { + document.body.classList.toggle('on-battery', isOnBattery); + return () => { + document.body.classList.remove('on-battery'); + }; + }, [isOnBattery]); + return ( diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/index.ts b/packages/frontend/apps/electron-renderer/src/app/effects/index.ts index 8e8017329a..105895d585 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/index.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/index.ts @@ -1,10 +1,14 @@ import { setupEvents } from './events'; import { setupModules } from './modules'; +import { setupPowerSourceStore } from './power'; import { setupStoreManager } from './store-manager'; export function setupEffects() { const { framework, frameworkProvider } = setupModules(); setupStoreManager(framework); setupEvents(frameworkProvider); + setupPowerSourceStore(); return { framework, frameworkProvider }; } + +export { useIsOnBattery } from './power'; diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/power.ts b/packages/frontend/apps/electron-renderer/src/app/effects/power.ts new file mode 100644 index 0000000000..8c43365453 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/app/effects/power.ts @@ -0,0 +1,54 @@ +import { useSyncExternalStore } from 'react'; + +type Listener = () => void; + +let snapshot = false; +let teardown: (() => void) | null = null; +const listeners = new Set(); + +function emitChange() { + listeners.forEach(listener => listener()); +} + +function handlePowerSourceChange(isOnBattery: boolean) { + if (snapshot === isOnBattery) return; + snapshot = isOnBattery; + emitChange(); +} + +function ensureSubscribed() { + if (teardown) return; + if (typeof window === 'undefined') return; + + const subscribePowerSource = window.__apis?.events?.power?.['power-source']; + if (typeof subscribePowerSource !== 'function') return; + + const unsubscribe = subscribePowerSource(handlePowerSourceChange); + teardown = typeof unsubscribe === 'function' ? unsubscribe : null; +} + +function subscribe(listener: Listener) { + listeners.add(listener); + if (listeners.size === 1) { + ensureSubscribed(); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + teardown?.(); + teardown = null; + } + }; +} + +export function setupPowerSourceStore() { + ensureSubscribed(); +} + +export function getIsOnBatterySnapshot() { + return snapshot; +} + +export function useIsOnBattery() { + return useSyncExternalStore(subscribe, getIsOnBatterySnapshot, () => false); +} diff --git a/packages/frontend/apps/electron-renderer/src/app/global.css b/packages/frontend/apps/electron-renderer/src/app/global.css index a338d5c565..a66ae17c4e 100644 --- a/packages/frontend/apps/electron-renderer/src/app/global.css +++ b/packages/frontend/apps/electron-renderer/src/app/global.css @@ -23,3 +23,12 @@ html[data-active='true']:has([data-translucent='true']) { opacity: 1; transition: opacity 0.2s; } + +/** + * Disable animations and transitions when on battery. + * We use :not(.playwright-test) to ensure tests (which rely on animations) don't break. + */ +body.on-battery:not(.playwright-test) * { + animation-duration: 0ms !important; + transition-duration: 0ms !important; +} diff --git a/packages/frontend/apps/electron-renderer/src/global.d.ts b/packages/frontend/apps/electron-renderer/src/global.d.ts new file mode 100644 index 0000000000..7cae035db5 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/global.d.ts @@ -0,0 +1,16 @@ +import type { apis, events } from '@affine/electron-api'; + +/** + * Extends the global Window interface to include AFFiNE's + * Electron bridge APIs and event emitters. + */ +declare global { + interface Window { + __apis?: { + apis: typeof apis; + events: typeof events; + }; + } +} + +export {}; diff --git a/packages/frontend/apps/electron/src/main/events.ts b/packages/frontend/apps/electron/src/main/events.ts index f75fcbe3a8..41a746eb67 100644 --- a/packages/frontend/apps/electron/src/main/events.ts +++ b/packages/frontend/apps/electron/src/main/events.ts @@ -1,4 +1,4 @@ -import { ipcMain, webContents } from 'electron'; +import { ipcMain, powerMonitor, webContents } from 'electron'; import { AFFINE_EVENT_CHANNEL_NAME, @@ -7,6 +7,7 @@ import { import { applicationMenuEvents } from './application-menu'; import { beforeAppQuit } from './cleanup'; import { logger } from './logger'; +import { powerEvents } from './power'; import { recordingEvents } from './recording'; import { sharedStorageEvents } from './shared-storage'; import { uiEvents } from './ui/events'; @@ -20,6 +21,7 @@ export const allEvents = { sharedStorage: sharedStorageEvents, recording: recordingEvents, popup: popupEvents, + power: powerEvents, }; const subscriptions = new Map>(); @@ -71,6 +73,13 @@ export function registerEvents() { if (typeof channel !== 'string') return; if (action === 'subscribe') { addSubscription(event.sender, channel); + if (channel === 'power:power-source') { + event.sender.send( + AFFINE_EVENT_CHANNEL_NAME, + channel, + powerMonitor.isOnBatteryPower() + ); + } } else { removeSubscription(event.sender, channel); } diff --git a/packages/frontend/apps/electron/src/main/power/index.ts b/packages/frontend/apps/electron/src/main/power/index.ts new file mode 100644 index 0000000000..93da8fddd7 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/power/index.ts @@ -0,0 +1,29 @@ +import { powerMonitor } from 'electron'; + +/** + * Power-related event handlers for the Electron main process. + */ +export const powerEvents = { + /** + * Subscribes to system power source changes. + * Emits the initial state immediately upon subscription. + * @param emit - Callback function to send power state to the renderer. + * @returns A cleanup function to remove listeners from powerMonitor. + */ + 'power-source': (emit: (isOnBattery: boolean) => void) => { + // emit initial state + emit(powerMonitor.isOnBatteryPower()); + + const onBattery = () => emit(true); + const onAC = () => emit(false); + + powerMonitor.on('on-battery', onBattery); + powerMonitor.on('on-ac', onAC); + + // cleanup + return () => { + powerMonitor.off('on-battery', onBattery); + powerMonitor.off('on-ac', onAC); + }; + }, +}; diff --git a/packages/frontend/native/index.d.ts b/packages/frontend/native/index.d.ts index e85784fa3b..99ebd4f4c5 100644 --- a/packages/frontend/native/index.d.ts +++ b/packages/frontend/native/index.d.ts @@ -19,10 +19,10 @@ export declare class ApplicationStateChangedSubscriber { } export declare class AudioCaptureSession { - stop(): void get sampleRate(): number get channels(): number get actualSampleRate(): number + stop(): void } export declare class ShareableContent { @@ -31,9 +31,9 @@ export declare class ShareableContent { constructor() static applications(): Array static applicationWithProcessId(processId: number): ApplicationInfo | null - static isUsingMicrophone(processId: number): boolean static tapAudio(processId: number, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession static tapGlobalAudio(excludedProcesses: Array | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioCaptureSession + static isUsingMicrophone(processId: number): boolean } export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null, signal?: AbortSignal | undefined | null): Promise diff --git a/tests/kit/src/playwright.ts b/tests/kit/src/playwright.ts index b9b120b62e..d4dfc9d53c 100644 --- a/tests/kit/src/playwright.ts +++ b/tests/kit/src/playwright.ts @@ -71,6 +71,14 @@ export const test = baseTest.extend<{ await use(page); }, context: async ({ context }, use) => { + // Force-mark the body so global.css knows we are in a test. + // This keeps animations ON (0.1s) for tests, but OFF (0ms) for battery users. + await context.addInitScript(() => { + window.addEventListener('DOMContentLoaded', () => { + document.body.classList.add('playwright-test'); + }); + }); + // workaround for skipping onboarding redirect on the web await skipOnboarding(context);