diff --git a/src/defaultSettings.ts b/src/defaultSettings.ts index 9240c211..054b7a68 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -719,6 +719,9 @@ export const DEFAULT_SETTINGS: Settings = { enableVoiceMode: false, enableVoiceConciseOutput: true, enableChannelsMode: false, + enableReactiveTheme: false, + reactiveThemeDarkId: 'dark', + reactiveThemeLightId: 'light', }, toolsets: [], defaultToolset: null, diff --git a/src/patches/index.ts b/src/patches/index.ts index a1c69861..73e511ed 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -73,6 +73,9 @@ import { writeWorktreeMode } from './worktreeMode'; import { writeAllowCustomAgentModels } from './allowCustomAgentModels'; import { writeVoiceMode } from './voiceMode'; import { writeChannelsMode } from './channelsMode'; +import { writeReactiveTheme } from './reactiveTheme'; +import { writeThemeDetection } from './themeDetection'; +import { REACTIVE_THEME_WATCHER_JS } from '../reactiveThemeWatcher'; import { restoreNativeBinaryFromBackup, restoreClijsFromBackup, @@ -174,6 +177,13 @@ const PATCH_DEFINITIONS = [ group: PatchGroup.ALWAYS_APPLIED, description: `Statusline updates will be properly throttled instead of queued (or debounced)`, }, + { + id: 'fix-theme-detection', + name: 'Fix theme detection', + group: PatchGroup.ALWAYS_APPLIED, + description: + 'Cross-platform "auto" theme detection (macOS defaults, Linux gdbus, Windows registry) instead of COLORFGBG', + }, // Misc Configurable { id: 'model-customizations', @@ -430,6 +440,13 @@ const PATCH_DEFINITIONS = [ description: 'Enable MCP channel notifications (--channels without allowlist or dev flag)', }, + { + id: 'reactive-theme', + name: 'Reactive theme switching', + group: PatchGroup.FEATURES, + description: + 'Auto-switch theme when OS dark/light mode changes (macOS, Linux, Windows, SSH)', + }, ] as const; /** Union type of all valid patch IDs */ @@ -663,6 +680,9 @@ export const applyCustomization = async ( ), condition: config.settings.misc?.statuslineThrottleMs != null, }, + 'fix-theme-detection': { + fn: c => writeThemeDetection(c), + }, // Misc Configurable 'patches-applied-indication': { fn: c => @@ -888,6 +908,14 @@ export const applyCustomization = async ( fn: c => writeChannelsMode(c), condition: !!config.settings.misc?.enableChannelsMode, }, + 'reactive-theme': { + fn: c => + writeReactiveTheme(c, { + darkThemeId: config.settings.misc?.reactiveThemeDarkId ?? 'dark', + lightThemeId: config.settings.misc?.reactiveThemeLightId ?? 'light', + }), + condition: !!config.settings.misc?.enableReactiveTheme, + }, }; // ========================================================================== @@ -898,6 +926,22 @@ export const applyCustomization = async ( content = patchedContent; allResults.push(...patchResults); + // ========================================================================== + // Install external files for patches that need them + // ========================================================================== + const twJsPath = path.join(CONFIG_DIR, 'tw.js'); + if (config.settings.misc?.enableReactiveTheme) { + fsSync.writeFileSync(twJsPath, REACTIVE_THEME_WATCHER_JS, 'utf8'); + debug(`Installed reactive theme watcher: ${twJsPath}`); + } else { + try { + fsSync.unlinkSync(twJsPath); + debug(`Removed reactive theme watcher: ${twJsPath}`); + } catch { + // File doesn't exist — nothing to clean up + } + } + // ========================================================================== // Write the modified content back // ========================================================================== diff --git a/src/patches/reactiveTheme.test.ts b/src/patches/reactiveTheme.test.ts new file mode 100644 index 00000000..501d7fd6 --- /dev/null +++ b/src/patches/reactiveTheme.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { writeReactiveTheme } from './reactiveTheme'; +import { clearCaches } from './helpers'; + +// Synthetic ThemeProvider snippets derived from real CC minified code. +// Each version uses different variable names but the same structure. + +// Prefix: module loader + React import for getRequireFuncName / getReactVar to work +const BUN_PREFIX = + 'var j=(H,$,A)=>{A=H!=null?H:$};' + + 'var n7L=($)=>{var W=Symbol.for("react.transitional.element")};' + + 'var fH=j((AtM,r7L)=>{r7L.exports=n7L()});'; + +// v2.1.89 ThemeProvider (simplified but structurally accurate) +function buildV289(): string { + return ( + BUN_PREFIX + + 'function rUH(){if(Xv6===void 0)Xv6=uG4()??"dark";return Xv6}' + + 'function Ad(H){if(H==="auto")return rUH();return H}' + + 'function uG4(){let H=process.env.COLORFGBG;if(!H)return;' + + 'let _=H.split(";"),q=_[_.length-1];' + + 'if(q===void 0||q==="")return;' + + 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + + 'return K<=6||K===8?"dark":"light"}' + + 'var Xv6;' + + 'function pG_({children:H,initialState:_,onThemeSave:q=gG4}){' + + 'let[K,$]=UG.useState(_??pG4),[O,T]=UG.useState(null),' + + '[z,A]=UG.useState(()=>(_??K)==="auto"?rUH():"dark"),' + + 'w=O??K,{internal_querier:f}=W6H();' + + 'UG.useEffect(()=>{},[w,f]);' + + 'let Y=w==="auto"?z:w,' + + 'j=UG.useMemo(()=>({themeSetting:K,' + + 'setThemeSetting:(D)=>{if($(D),T(null),D==="auto")A(rUH());q?.(D)},' + + 'setPreviewTheme:(D)=>{if(T(D),D==="auto")A(rUH())},' + + 'savePreview:()=>{if(O!==null)$(O),T(null),q?.(O)},' + + 'cancelPreview:()=>{if(O!==null)T(null)},' + + 'currentTheme:Y}),[K,O,Y,q]);' + + 'return UG.default.createElement(mG_.Provider,{value:j},H)}' + ); +} + +// v2.1.87 ThemeProvider (different variable names) +function buildV287(): string { + return ( + BUN_PREFIX + + 'function IFH(){if(OR4===void 0)OR4=MR4()??"dark";return OR4}' + + 'function Ad(H){if(H==="auto")return IFH();return H}' + + 'function MR4(){let H=process.env.COLORFGBG;if(!H)return;' + + 'let _=H.split(";"),q=_[_.length-1];' + + 'if(q===void 0||q==="")return;' + + 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + + 'return K<=6||K===8?"dark":"light"}' + + 'var OR4;' + + 'function E0_({children:H,initialState:_,onThemeSave:q=JR4}){' + + 'let[$,K]=KG.useState(_??JR4),[O,T]=KG.useState(null),' + + '[z,A]=KG.useState(()=>(_??$)==="auto"?IFH():"dark"),' + + 'f=O??$,{internal_querier:w}=E_H();' + + 'KG.useEffect(()=>{},[f,w]);' + + 'let Y=f==="auto"?z:f,' + + 'D=KG.useMemo(()=>({themeSetting:$,' + + 'setThemeSetting:(j)=>{if(K(j),T(null),j==="auto")A(IFH());q?.(j)},' + + 'setPreviewTheme:(j)=>{if(T(j),j==="auto")A(IFH())},' + + 'savePreview:()=>{if(O!==null)K(O),T(null),q?.(O)},' + + 'cancelPreview:()=>{if(O!==null)T(null)},' + + 'currentTheme:Y}),[K,O,Y,q]);' + + 'return KG.default.createElement(E0_.Provider,{value:D},H)}' + ); +} + +// v2.1.86 ThemeProvider (single dep in older versions had two deps too) +function buildV286(): string { + return ( + BUN_PREFIX + + 'function Cd8(){if(qR6===void 0)qR6=_k5()??"dark";return qR6}' + + 'function Ad(H){if(H==="auto")return Cd8();return H}' + + 'function _k5(){let H=process.env.COLORFGBG;if(!H)return;' + + 'let _=H.split(";"),q=_[_.length-1];' + + 'if(q===void 0||q==="")return;' + + 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + + 'return K<=6||K===8?"dark":"light"}' + + 'var qR6;' + + 'function EGq({children:H,initialState:_,onThemeSave:q=Kk5}){' + + 'let[O,z]=zA.useState(_??Kk5),[Y,H2]=zA.useState(null),' + + '[$,w]=zA.useState(()=>(_??O)==="auto"?Cd8():"dark"),' + + 'j=Y??O,{internal_querier:J}=yq8();' + + 'zA.useEffect(()=>{},[j,J]);' + + 'let T=j==="auto"?$:j,' + + 'X=zA.useMemo(()=>({themeSetting:O,' + + 'setThemeSetting:(D)=>{if(z(D),H2(null),D==="auto")w(Cd8());q?.(D)},' + + 'setPreviewTheme:(D)=>{if(H2(D),D==="auto")w(Cd8())},' + + 'savePreview:()=>{if(Y!==null)z(Y),H2(null),q?.(Y)},' + + 'cancelPreview:()=>{if(Y!==null)H2(null)},' + + 'currentTheme:T}),[O,Y,T,q]);' + + 'return zA.default.createElement(EGq.Provider,{value:X},H)}' + ); +} + +const DEFAULT_CONFIG = { darkThemeId: 'dark', lightThemeId: 'light' }; + +describe('reactiveTheme', () => { + beforeEach(() => { + clearCaches(); + }); + + // ==================================================================== + // useEffect patching + // ==================================================================== + + it('patches the empty useEffect in v2.1.89 style code', () => { + const result = writeReactiveTheme(buildV289(), DEFAULT_CONFIG); + + expect(result).not.toBeNull(); + expect(result).toContain('/tw.js"'); + expect(result).toContain('(A,f,"dark","light",'); + expect(result).not.toContain('.useEffect(()=>{}'); + }); + + it('patches the empty useEffect in v2.1.87 style code', () => { + const result = writeReactiveTheme(buildV287(), DEFAULT_CONFIG); + + expect(result).not.toBeNull(); + expect(result).toContain('/tw.js"'); + expect(result).toContain('(A,w,"dark","light",'); + expect(result).not.toContain('.useEffect(()=>{}'); + }); + + it('patches the empty useEffect in v2.1.86 style code', () => { + const result = writeReactiveTheme(buildV286(), DEFAULT_CONFIG); + + expect(result).not.toBeNull(); + expect(result).toContain('/tw.js"'); + expect(result).toContain('(w,J,"dark","light",'); + expect(result).not.toContain('.useEffect(()=>{}'); + }); + + it('uses custom theme IDs from config', () => { + const result = writeReactiveTheme(buildV289(), { + darkThemeId: 'dark-ansi', + lightThemeId: 'light-ansi', + }); + + expect(result).not.toBeNull(); + expect(result).toContain('"dark-ansi","light-ansi"'); + }); + + // ==================================================================== + // Idempotency + // ==================================================================== + + it('returns content unchanged when already patched', () => { + const first = writeReactiveTheme(buildV289(), DEFAULT_CONFIG); + expect(first).not.toBeNull(); + + clearCaches(); + const second = writeReactiveTheme(first!, DEFAULT_CONFIG); + expect(second).toBe(first); + }); + + // ==================================================================== + // Failure cases + // ==================================================================== + + it('returns null when ThemeProvider is not found', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + try { + const result = writeReactiveTheme( + BUN_PREFIX + 'const x = 1;', + DEFAULT_CONFIG + ); + expect(result).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + 'patch: reactiveTheme: failed to find ThemeProvider function' + ); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/src/patches/reactiveTheme.ts b/src/patches/reactiveTheme.ts new file mode 100644 index 00000000..e9068eea --- /dev/null +++ b/src/patches/reactiveTheme.ts @@ -0,0 +1,110 @@ +import { escapeIdent, showDiff } from './index'; +import { CONFIG_DIR } from '../config'; + +export interface ReactiveThemeConfig { + darkThemeId: string; + lightThemeId: string; +} + +function findThemeProviderRegion(content: string): { + start: number; + end: number; +} | null { + const marker = /\{themeSetting:[$\w]+,[\s\S]*?currentTheme:[$\w]+\}/; + const match = content.match(marker); + if (!match || match.index == null) return null; + + const searchStart = Math.max(0, match.index - 3000); + const before = content.slice(searchStart, match.index); + const funcMatch = before.match( + /function [$\w]+\(\{children:[$\w]+,initialState:[$\w]+[^}]*\}\)\{/g + ); + if (!funcMatch) return null; + + const lastFunc = funcMatch[funcMatch.length - 1]; + const funcStart = before.lastIndexOf(lastFunc); + if (funcStart === -1) return null; + + const start = searchStart + funcStart; + + const after = content.slice(match.index, match.index + 2000); + const returnMatch = after.match( + /return [$\w]+\.default\.createElement\([$\w]+\.Provider,\{value:[$\w]+\},[$\w]+\)\}/ + ); + if (!returnMatch || returnMatch.index == null) return null; + + const end = match.index + returnMatch.index + returnMatch[0].length; + return { start, end }; +} + +function patchThemeProviderUseEffect( + content: string, + config: ReactiveThemeConfig +): string | null { + const region = findThemeProviderRegion(content); + if (!region) { + console.error( + 'patch: reactiveTheme: failed to find ThemeProvider function' + ); + return null; + } + + const regionContent = content.slice(region.start, region.end); + + if (regionContent.includes('/tw.js")')) { + return content; + } + + const useEffectPattern = + /([$\w]+)\.useEffect\(\(\)=>\{\},\[([$\w]+)(?:,([$\w]+))?\]\)/; + const useEffectMatch = regionContent.match(useEffectPattern); + if (!useEffectMatch || useEffectMatch.index == null) { + console.error( + 'patch: reactiveTheme: failed to find empty useEffect in ThemeProvider' + ); + return null; + } + + const reactVar = useEffectMatch[1]; + const dep1 = useEffectMatch[2]; + const dep2 = useEffectMatch[3]; + const deps = dep2 ? `${dep1},${dep2}` : dep1; + + const setStatePattern = new RegExp( + `\\[([$\\w]+),([$\\w]+)\\]=${escapeIdent(reactVar)}\\.useState\\(\\(\\)=>\\([^)]+\\)==="?auto"?\\?[$\\w]+\\(\\):"dark"\\)` + ); + const setStateMatch = regionContent.match(setStatePattern); + if (!setStateMatch) { + console.error( + 'patch: reactiveTheme: failed to find setState for resolved theme' + ); + return null; + } + + const setStateVar = setStateMatch[2]; + + const querierArg = dep2 ? `,${dep2}` : ',void 0'; + const twJsPath = CONFIG_DIR + '/tw.js'; + const replacement = + `${reactVar}.useEffect(()=>{try{if(${dep1}=="auto"){` + + `return require(${JSON.stringify(twJsPath)})` + + `(${setStateVar}${querierArg},${JSON.stringify(config.darkThemeId)},${JSON.stringify(config.lightThemeId)},${JSON.stringify(CONFIG_DIR)})` + + `}}catch{}},[${deps}])`; + + const absStart = region.start + useEffectMatch.index; + const absEnd = absStart + useEffectMatch[0].length; + + const newContent = + content.slice(0, absStart) + replacement + content.slice(absEnd); + + showDiff(content, newContent, replacement, absStart, absEnd); + + return newContent; +} + +export const writeReactiveTheme = ( + oldFile: string, + config: ReactiveThemeConfig +): string | null => { + return patchThemeProviderUseEffect(oldFile, config); +}; diff --git a/src/patches/themeDetection.test.ts b/src/patches/themeDetection.test.ts new file mode 100644 index 00000000..21a3234e --- /dev/null +++ b/src/patches/themeDetection.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { writeThemeDetection } from './themeDetection'; +import { clearCaches } from './helpers'; + +const DETECT_V289 = + 'function uG4(){let H=process.env.COLORFGBG;if(!H)return;' + + 'let _=H.split(";"),q=_[_.length-1];' + + 'if(q===void 0||q==="")return;' + + 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + + 'return K<=6||K===8?"dark":"light"}'; + +const BUN_PREFIX = + 'var j=(H,$,A)=>{A=H!=null?H:$};' + + 'var n7L=($)=>{var W=Symbol.for("react.transitional.element")};'; + +function buildInput(detectFunc: string = DETECT_V289): string { + return BUN_PREFIX + 'var Xv6;' + detectFunc + 'var other=1;'; +} + +describe('themeDetection', () => { + beforeEach(() => { + clearCaches(); + }); + + it('replaces COLORFGBG detection with cross-platform detection', () => { + const result = writeThemeDetection(buildInput()); + + expect(result).not.toBeNull(); + expect(result).toContain('defaults read -g AppleInterfaceStyle'); + expect(result).toContain('org.freedesktop.appearance color-scheme'); + expect(result).toContain('AppsUseLightTheme'); + expect(result).toContain('process.env.COLORFGBG'); + }); + + it('preserves the original function name', () => { + const result = writeThemeDetection(buildInput()); + + expect(result).not.toBeNull(); + expect(result).toContain('function uG4(){try{'); + }); + + it('handles different function names (v2.1.87)', () => { + const detect87 = + 'function MR4(){let H=process.env.COLORFGBG;if(!H)return;' + + 'let _=H.split(";"),q=_[_.length-1];' + + 'if(q===void 0||q==="")return;' + + 'let K=Number(q);if(!Number.isInteger(K)||K<0||K>15)return;' + + 'return K<=6||K===8?"dark":"light"}'; + + const result = writeThemeDetection(buildInput(detect87)); + + expect(result).not.toBeNull(); + expect(result).toContain('function MR4(){try{'); + }); + + it('returns content unchanged when already patched', () => { + const first = writeThemeDetection(buildInput()); + expect(first).not.toBeNull(); + + clearCaches(); + const second = writeThemeDetection(first!); + expect(second).toBe(first); + }); + + it('returns null when COLORFGBG detect function is not found', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + try { + const result = writeThemeDetection(BUN_PREFIX + 'const x = 1;'); + expect(result).toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + 'patch: themeDetection: failed to find COLORFGBG detect function' + ); + } finally { + consoleError.mockRestore(); + } + }); +}); diff --git a/src/patches/themeDetection.ts b/src/patches/themeDetection.ts new file mode 100644 index 00000000..31070afd --- /dev/null +++ b/src/patches/themeDetection.ts @@ -0,0 +1,53 @@ +import { showDiff } from './index'; +import { getRequireFuncName } from './helpers'; + +export const writeThemeDetection = (content: string): string | null => { + if (content.includes('defaults read -g AppleInterfaceStyle')) { + return content; + } + + const detectPattern = + /function ([$\w]+)\(\)\{let [$\w]+=process\.env\.COLORFGBG;if\(![$\w]+\)return;[\s\S]*?return [$\w]+<=6\|\|[$\w]+===8\?"dark":"light"\}/; + const match = content.match(detectPattern); + + if (!match || match.index == null) { + console.error( + 'patch: themeDetection: failed to find COLORFGBG detect function' + ); + return null; + } + + const funcName = match[1]; + + const requireFunc = getRequireFuncName(content); + + const replacement = + `function ${funcName}(){try{` + + `var _cp=${requireFunc}("child_process");` + + `if(process.platform==="darwin"){` + + `try{_cp.execSync("defaults read -g AppleInterfaceStyle",{stdio:"pipe"});return"dark"}catch{return"light"}}` + + `if(process.platform==="linux"){try{` + + `var _o=(""+_cp.execSync("gdbus call --session --dest org.freedesktop.portal.Desktop ` + + `--object-path /org/freedesktop/portal/desktop ` + + `--method org.freedesktop.portal.Settings.Read ` + + `org.freedesktop.appearance color-scheme",{stdio:"pipe",timeout:3e3}));` + + `var _m=_o.match(/uint32\\s+(\\d)/);` + + `if(_m)return _m[1]==="1"?"dark":"light"}catch{}}` + + `if(process.platform==="win32"){try{` + + `var _w=(""+_cp.execSync('reg query "HKCU\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize" /v AppsUseLightTheme',{stdio:"pipe",timeout:3e3}));` + + `return _w.includes("0x0")?"dark":"light"}catch{}}` + + `var _e=process.env.COLORFGBG;if(_e){` + + `var _p=_e.split(";"),_v=_p[_p.length-1];` + + `if(_v){var _n=Number(_v);if(Number.isInteger(_n)&&_n>=0&&_n<=15)return _n<=6||_n===8?"dark":"light"}}` + + `return"dark"}catch{return"dark"}}`; + + const startIndex = match.index; + const endIndex = startIndex + match[0].length; + + const newContent = + content.slice(0, startIndex) + replacement + content.slice(endIndex); + + showDiff(content, newContent, replacement, startIndex, endIndex); + + return newContent; +}; diff --git a/src/reactiveThemeWatcher.ts b/src/reactiveThemeWatcher.ts new file mode 100644 index 00000000..fe63cddc --- /dev/null +++ b/src/reactiveThemeWatcher.ts @@ -0,0 +1,260 @@ +// tw.js source — written to ~/.tweakcc/tw.js at patch-apply time. +// Called at runtime by the patched ThemeProvider useEffect: +// require("~/.tweakcc/tw.js")(setState, querier, darkId, lightId) +// +// Must return a cleanup function (called on useEffect unmount). + +export const REACTIVE_THEME_WATCHER_JS = `'use strict'; + +var _cp = require('child_process'); +var _fs = require('fs'); +var _path = require('path'); +var _os = require('os'); + +var HOME = _os.homedir(); + +function themeName(isDark, darkId, lightId) { + return isDark ? darkId : lightId; +} + +// macOS ----------------------------------------------------------------------- + +function macOSDetect() { + try { + _cp.execSync('defaults read -g AppleInterfaceStyle', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function watchDarwin(setState, darkId, lightId) { + // Check if a Swift watcher daemon is running (writes ~/.claude/.current-theme) + var swiftFile = _path.join(HOME, '.claude', '.current-theme'); + try { + var content = _fs.readFileSync(swiftFile, 'utf8').trim(); + if (content) { + setState(content); + var w = _fs.watch(swiftFile, function() { + try { + var t = _fs.readFileSync(swiftFile, 'utf8').trim(); + if (t) setState(t); + } catch {} + }); + return function() { w.close(); }; + } + } catch {} + + // Plist watch fallback + setState(themeName(macOSDetect(), darkId, lightId)); + var plist = _path.join(HOME, 'Library', 'Preferences', '.GlobalPreferences.plist'); + var prev = macOSDetect(); + var debounce = null; + + var watcher = _fs.watch(plist, function() { + if (debounce) return; + debounce = setTimeout(function() { + debounce = null; + var isDark = macOSDetect(); + if (isDark !== prev) { + prev = isDark; + setState(themeName(isDark, darkId, lightId)); + } + }, 100); + }); + + return function() { + if (debounce) clearTimeout(debounce); + watcher.close(); + }; +} + +// Linux ----------------------------------------------------------------------- + +function linuxDetect() { + try { + var out = _cp.execSync( + 'gdbus call --session --dest org.freedesktop.portal.Desktop ' + + '--object-path /org/freedesktop/portal/desktop ' + + '--method org.freedesktop.portal.Settings.Read ' + + 'org.freedesktop.appearance color-scheme', + { stdio: 'pipe', timeout: 3000 } + ).toString(); + var m = out.match(/uint32\\s+(\\d)/); + return m ? m[1] === '1' : false; + } catch { + return false; + } +} + +function watchLinux(setState, darkId, lightId) { + try { + setState(themeName(linuxDetect(), darkId, lightId)); + } catch { + setState(darkId); + return function() {}; + } + + var proc = _cp.spawn('gdbus', [ + 'monitor', '--session', + '--dest', 'org.freedesktop.portal.Desktop', + '--object-path', '/org/freedesktop/portal/desktop' + ], { stdio: ['ignore', 'pipe', 'ignore'] }); + + var buf = ''; + proc.stdout.on('data', function(data) { + buf += data.toString(); + var lines = buf.split('\\n'); + buf = lines.pop() || ''; + for (var i = 0; i < lines.length; i++) { + var m = lines[i].match( + /SettingChanged\\s*\\(\\s*'org\\.freedesktop\\.appearance',\\s*'color-scheme',\\s*/ + ); + if (m) { + setState(themeName(m[1] === '1', darkId, lightId)); + } + } + }); + + proc.on('error', function() {}); + return function() { proc.kill(); }; +} + +// Windows --------------------------------------------------------------------- + +function windowsDetect() { + try { + var out = _cp.execSync( + 'reg query "HKCU\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize" /v AppsUseLightTheme', + { stdio: 'pipe', timeout: 3000 } + ).toString(); + return out.includes('0x0'); + } catch { + return false; + } +} + +function watchWindows(setState, darkId, lightId, configDir) { + setState(themeName(windowsDetect(), darkId, lightId)); + + var psScript = _path.join(configDir, 'theme-watcher.ps1'); + try { + _fs.writeFileSync(psScript, [ + 'Add-Type @"', + 'using System;', + 'using System.Runtime.InteropServices;', + 'public class RegMon {', + ' [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]', + ' public static extern int RegOpenKeyEx(IntPtr hKey, string subKey, int options, int sam, out IntPtr result);', + ' [DllImport("advapi32.dll", SetLastError=true)]', + ' public static extern int RegNotifyChangeKeyValue(IntPtr hKey, bool watchSubtree, int filter, IntPtr hEvent, bool async);', + ' [DllImport("advapi32.dll", SetLastError=true)]', + ' public static extern int RegCloseKey(IntPtr hKey);', + ' public static readonly IntPtr HKCU = new IntPtr(unchecked((int)0x80000001));', + '}', + '"@', + '$subKey = "SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize"', + '$prev = ""', + 'while ($true) {', + ' $hKey = [IntPtr]::Zero', + ' $r = [RegMon]::RegOpenKeyEx([RegMon]::HKCU, $subKey, 0, 0x20019 -bor 0x0010, [ref]$hKey)', + ' if ($r -ne 0) { Start-Sleep -Seconds 5; continue }', + ' [RegMon]::RegNotifyChangeKeyValue($hKey, $false, 4, [IntPtr]::Zero, $false) | Out-Null', + ' [RegMon]::RegCloseKey($hKey) | Out-Null', + ' $v = (Get-ItemPropertyValue "HKCU:\\\\$subKey" -Name AppsUseLightTheme -ErrorAction SilentlyContinue)', + ' $t = if ($v -eq 0) { "dark" } else { "light" }', + ' if ($t -ne $prev) { $prev = $t; Write-Output "theme:$t"; [Console]::Out.Flush() }', + '}' + ].join('\\n')); + } catch { + return function() {}; + } + + var proc = _cp.spawn('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', psScript + ], { stdio: ['ignore', 'pipe', 'ignore'] }); + + var buf = ''; + proc.stdout.on('data', function(data) { + buf += data.toString(); + var lines = buf.split('\\n'); + buf = lines.pop() || ''; + for (var i = 0; i < lines.length; i++) { + var trimmed = lines[i].trim(); + if (trimmed === 'theme:dark') setState(themeName(true, darkId, lightId)); + else if (trimmed === 'theme:light') setState(themeName(false, darkId, lightId)); + } + }); + + proc.on('error', function() {}); + return function() { proc.kill(); }; +} + +// OSC 11 via internal querier ------------------------------------------------- + +var OSC_BG_QUERY = { + request: '\\x1b]11;?\\x07', + match: function(r) { return r.type === 'osc' && r.code === 11; } +}; + +function parseOscLuminance(data) { + var r, g, b; + var rgbMatch = data.match(/^rgba?:([0-9a-f]{1,4})\\/([0-9a-f]{1,4})\\/([0-9a-f]{1,4})/i); + if (rgbMatch) { + var norm = function(hex) { return parseInt(hex, 16) / (Math.pow(16, hex.length) - 1); }; + r = norm(rgbMatch[1]); g = norm(rgbMatch[2]); b = norm(rgbMatch[3]); + } else { + var hexMatch = data.match(/^#([0-9a-f]+)$/i); + if (!hexMatch) return undefined; + var h = hexMatch[1]; + var len = h.length / 3; + var norm2 = function(s) { return parseInt(s, 16) / (Math.pow(16, s.length) - 1); }; + r = norm2(h.slice(0, len)); g = norm2(h.slice(len, len*2)); b = norm2(h.slice(len*2)); + } + var lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return lum > 0.5 ? 'light' : 'dark'; +} + +function watchQuerier(setState, querier, darkId, lightId) { + var prev = null; + var timer = null; + + function poll() { + Promise.race([ + querier.send(OSC_BG_QUERY).then(function(r) { + return querier.flush().then(function() { return r; }); + }), + new Promise(function(_, reject) { + setTimeout(function() { reject('timeout'); }, 3000); + }) + ]).then(function(resp) { + if (resp && resp.data) { + var mode = parseOscLuminance(resp.data); + if (mode) { + var theme = themeName(mode === 'dark', darkId, lightId); + if (theme !== prev) { prev = theme; setState(theme); } + } + } + }).catch(function() {}); + } + + poll(); + timer = setInterval(poll, 500); + return function() { clearInterval(timer); }; +} + +// Entry point ----------------------------------------------------------------- + +module.exports = function(setState, querier, darkId, lightId, configDir) { + darkId = darkId || 'dark'; + lightId = lightId || 'light'; + configDir = configDir || _path.join(HOME, '.tweakcc'); + var platform = process.platform; + if (platform === 'darwin') return watchDarwin(setState, darkId, lightId); + if (platform === 'linux') return watchLinux(setState, darkId, lightId); + if (platform === 'win32') return watchWindows(setState, darkId, lightId, configDir); + if (querier) return watchQuerier(setState, querier, darkId, lightId); + setState(darkId); + return function() {}; +}; +`; diff --git a/src/types.ts b/src/types.ts index 0aa23747..ecc46585 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,6 +137,9 @@ export interface MiscConfig { enableVoiceMode: boolean; enableVoiceConciseOutput: boolean; enableChannelsMode: boolean; + enableReactiveTheme: boolean; + reactiveThemeDarkId: string; + reactiveThemeLightId: string; } export interface InputPatternHighlighter { diff --git a/src/ui/components/MiscView.tsx b/src/ui/components/MiscView.tsx index e0d9e3bf..5e4e4d2b 100644 --- a/src/ui/components/MiscView.tsx +++ b/src/ui/components/MiscView.tsx @@ -85,6 +85,9 @@ export function MiscView({ onSubmit }: MiscViewProps) { enableVoiceMode: false, enableVoiceConciseOutput: true, enableChannelsMode: false, + enableReactiveTheme: false, + reactiveThemeDarkId: 'dark', + reactiveThemeLightId: 'light', }; const ensureMisc = () => {