diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js index 0a96b8be94fb4..ac52fec0266f8 100644 --- a/web/.storybook/preview.js +++ b/web/.storybook/preview.js @@ -21,8 +21,11 @@ import { darkTheme, lightTheme } from './../packages/design/src/theme'; import DefaultThemeProvider from '../packages/design/src/ThemeProvider'; import Box from './../packages/design/src/Box'; import '../packages/teleport/src/lib/polyfillRandomUuid'; -import { ThemeProvider as TeletermThemeProvider } from './../packages/teleterm/src/ui/ThemeProvider'; -import { theme as TeletermTheme } from './../packages/teleterm/src/ui/ThemeProvider/theme'; +import { StaticThemeProvider as TeletermThemeProvider } from './../packages/teleterm/src/ui/ThemeProvider'; +import { + darkTheme as teletermDarkTheme, + lightTheme as teletermLightTheme, +} from './../packages/teleterm/src/ui/ThemeProvider/theme'; import { handlersTeleport } from './../packages/teleport/src/mocks/handlers'; // Checks we are running non-node environment (browser) @@ -41,7 +44,10 @@ const ThemeDecorator = (storyFn, meta) => { if (meta.title.startsWith('Teleterm/')) { ThemeProvider = TeletermThemeProvider; - theme = TeletermTheme; + theme = + meta.globals.theme === 'Dark Theme' + ? teletermDarkTheme + : teletermLightTheme; } else { ThemeProvider = DefaultThemeProvider; theme = meta.globals.theme === 'Dark Theme' ? darkTheme : lightTheme; diff --git a/web/packages/teleterm/src/main.ts b/web/packages/teleterm/src/main.ts index 4687b03025a36..a57a9c1d49944 100644 --- a/web/packages/teleterm/src/main.ts +++ b/web/packages/teleterm/src/main.ts @@ -18,7 +18,7 @@ import { spawn } from 'child_process'; import path from 'path'; -import { app, globalShortcut, shell } from 'electron'; +import { app, globalShortcut, shell, nativeTheme } from 'electron'; import MainProcess from 'teleterm/mainProcess'; import { getRuntimeSettings } from 'teleterm/mainProcess/runtimeSettings'; @@ -59,6 +59,8 @@ async function initializeApp(): Promise { jsonSchemaFile: configJsonSchemaFileStorage, platform: settings.platform, }); + + nativeTheme.themeSource = configService.get('theme').value; const windowsManager = new WindowsManager(appStateFileStorage, settings); process.on('uncaughtException', (error, origin) => { diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index 318aec0ad0c0a..f15d25fc30cae 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -68,6 +68,14 @@ export class MockMainProcessClient implements MainProcessClient { async openConfigFile() { return ''; } + + shouldUseDarkColors() { + return true; + } + + subscribeToNativeThemeUpdate() { + return { cleanup: () => undefined }; + } } export const makeRuntimeSettings = ( diff --git a/web/packages/teleterm/src/mainProcess/mainProcess.ts b/web/packages/teleterm/src/mainProcess/mainProcess.ts index ed795bce04694..df922b2e717d0 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcess.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcess.ts @@ -26,6 +26,7 @@ import { ipcMain, Menu, MenuItemConstructorOptions, + nativeTheme, shell, } from 'electron'; import { wait } from 'shared/utils/wait'; @@ -193,6 +194,10 @@ export default class MainProcess { event.returnValue = this.settings; }); + ipcMain.on('main-process-should-use-dark-colors', event => { + event.returnValue = nativeTheme.shouldUseDarkColors; + }); + ipcMain.handle('main-process-get-resolved-child-process-addresses', () => { return this.resolvedChildProcessAddresses; }); diff --git a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts index 6bb35619a05c6..1bb317f1cf880 100644 --- a/web/packages/teleterm/src/mainProcess/mainProcessClient.ts +++ b/web/packages/teleterm/src/mainProcess/mainProcessClient.ts @@ -53,8 +53,20 @@ export default function createMainProcessClient(): MainProcessClient { removeTshSymlinkMacOs() { return ipcRenderer.invoke('main-process-remove-tsh-symlink-macos'); }, - openConfigFile(): Promise { + openConfigFile() { return ipcRenderer.invoke('main-process-open-config-file'); }, + shouldUseDarkColors() { + return ipcRenderer.sendSync('main-process-should-use-dark-colors'); + }, + subscribeToNativeThemeUpdate: listener => { + const onThemeChange = (_, value: { shouldUseDarkColors: boolean }) => + listener(value); + const channel = 'main-process-native-theme-update'; + ipcRenderer.addListener(channel, onThemeChange); + return { + cleanup: () => ipcRenderer.removeListener(channel, onThemeChange), + }; + }, }; } diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 28798a576207b..62e8d51288ada 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -75,6 +75,13 @@ export type MainProcessClient = { /** Opens config file and returns a path to it. */ openConfigFile(): Promise; + shouldUseDarkColors(): boolean; + /** Subscribes to updates of the native theme. Returns a cleanup function. */ + subscribeToNativeThemeUpdate: ( + listener: (value: { shouldUseDarkColors: boolean }) => void + ) => { + cleanup: () => void; + }; }; export type ChildProcessAddresses = { diff --git a/web/packages/teleterm/src/mainProcess/windowsManager.ts b/web/packages/teleterm/src/mainProcess/windowsManager.ts index 934a0530c001f..bc3de2c3f207b 100644 --- a/web/packages/teleterm/src/mainProcess/windowsManager.ts +++ b/web/packages/teleterm/src/mainProcess/windowsManager.ts @@ -16,11 +16,18 @@ import path from 'path'; -import { app, BrowserWindow, Menu, Rectangle, screen } from 'electron'; +import { + app, + BrowserWindow, + Menu, + Rectangle, + screen, + nativeTheme, +} from 'electron'; import { FileStorage } from 'teleterm/services/fileStorage'; import { RuntimeSettings } from 'teleterm/mainProcess/types'; -import theme from 'teleterm/ui/ThemeProvider/theme'; +import { darkTheme, lightTheme } from 'teleterm/ui/ThemeProvider/theme'; type WindowState = Rectangle; @@ -47,13 +54,16 @@ export class WindowsManager { } createWindow(): void { + const activeTheme = nativeTheme.shouldUseDarkColors + ? darkTheme + : lightTheme; const windowState = this.getWindowState(); const window = new BrowserWindow({ x: windowState.x, y: windowState.y, width: windowState.width, height: windowState.height, - backgroundColor: theme.colors.levels.sunken, + backgroundColor: activeTheme.colors.levels.sunken, minWidth: 400, minHeight: 300, show: false, @@ -88,6 +98,12 @@ export class WindowsManager { this.popupUniversalContextMenu(window, props); }); + nativeTheme.on('updated', () => { + window.webContents.send('main-process-native-theme-update', { + shouldUseDarkColors: nativeTheme.shouldUseDarkColors, + }); + }); + window.webContents.session.setPermissionRequestHandler( (webContents, permission, callback) => { // deny all permissions requests, we currently do not require any diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index 62d17451d2d38..2b6e0cc435c68 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -106,6 +106,10 @@ export const createAppConfigSchema = (platform: Platform) => { .max(256) .default(15) .describe('Font size for the terminal.'), + theme: z + .enum(['light', 'dark', 'system']) + .default('system') + .describe('Color theme for the app.'), }); }; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx index 29353a7bf7a02..cfcecd04f1674 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; +import styled, { useTheme } from 'styled-components'; import { Box, Flex } from 'design'; import { debounce } from 'shared/utils/highbar'; import { @@ -54,6 +54,7 @@ export function Terminal(props: TerminalProps) { const [startPtyProcessAttempt, setStartPtyProcessAttempt] = useState< Attempt >(makeEmptyAttempt()); + const theme = useTheme(); useEffect(() => { const removeOnStartErrorListener = props.ptyProcess.onStartError( @@ -69,6 +70,7 @@ export function Terminal(props: TerminalProps) { const ctrl = new XTermCtrl(props.ptyProcess, { el: refElement.current, fontSize: props.fontSize, + theme: theme.colors.terminal, }); // Start the PTY process. @@ -103,6 +105,12 @@ export function Terminal(props: TerminalProps) { refCtrl.current.requestResize(); }, [props.visible]); + useEffect(() => { + if (refCtrl.current) { + refCtrl.current.term.options.theme = theme.colors.terminal; + } + }, [theme]); + return ( ( - - - - - {props.children} - - - -); +import { darkTheme, lightTheme } from './theme'; + +export const ThemeProvider = (props: React.PropsWithChildren) => { + // Listening to Electron's nativeTheme.on('updated') is a workaround. + // The renderer should be able to get the current theme via "prefers-color-scheme" media query. + // Unfortunately, it does not work correctly on Ubuntu where the query from above always returns the old value + // (for example, when the app was launched in a dark mode, it always returns 'dark' + // ignoring that the system theme is now 'light'). + // Related Electron issue: https://github.com/electron/electron/issues/21427#issuecomment-589796481, + // Related Chromium issue: https://bugs.chromium.org/p/chromium/issues/detail?id=998903 + // + // Additional issue is that nativeTheme does not return correct values at all on Fedora: + // https://github.com/electron/electron/issues/33635#issuecomment-1502215450 + const ctx = useAppContext(); + const [activeTheme, setActiveTheme] = useState(() => + ctx.mainProcessClient.shouldUseDarkColors() ? darkTheme : lightTheme + ); + + useEffect(() => { + const { cleanup } = ctx.mainProcessClient.subscribeToNativeThemeUpdate( + ({ shouldUseDarkColors }) => { + setActiveTheme(shouldUseDarkColors ? darkTheme : lightTheme); + } + ); + + return cleanup; + }, [ctx.mainProcessClient]); + + return ( + + {props.children} + + ); +}; + +/** Uses a theme from a prop. Useful in storybook. */ +export const StaticThemeProvider = ( + props: React.PropsWithChildren<{ theme?: unknown }> +) => { + return ( + + + + + {props.children} + + + + ); +}; diff --git a/web/packages/teleterm/src/ui/ThemeProvider/index.ts b/web/packages/teleterm/src/ui/ThemeProvider/index.ts index 8820126cfdff8..186c255fc6085 100644 --- a/web/packages/teleterm/src/ui/ThemeProvider/index.ts +++ b/web/packages/teleterm/src/ui/ThemeProvider/index.ts @@ -14,4 +14,4 @@ See the License for the specific language governing permissions and limitations under the License. */ -export { ThemeProvider } from './ThemeProvider'; +export * from './ThemeProvider'; diff --git a/web/packages/teleterm/src/ui/ThemeProvider/theme.ts b/web/packages/teleterm/src/ui/ThemeProvider/theme.ts index 682ca96a998f2..9e038f24af032 100644 --- a/web/packages/teleterm/src/ui/ThemeProvider/theme.ts +++ b/web/packages/teleterm/src/ui/ThemeProvider/theme.ts @@ -23,7 +23,7 @@ import { lighten } from 'design/theme/utils/colorManipulator'; const sansSerif = 'system-ui'; -const darkTheme = { +export const darkTheme = { ...designDarkTheme, colors: { ...designDarkTheme.colors, @@ -43,8 +43,7 @@ const darkTheme = { }, }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const lightTheme = { +export const lightTheme = { ...designLightTheme, font: sansSerif, fonts: { @@ -52,5 +51,3 @@ const lightTheme = { mono: fonts.mono, }, }; - -export default darkTheme;