From 8e86891d8e22909768b6bb07681a1559cefc0c90 Mon Sep 17 00:00:00 2001 From: vhqtvn Date: Wed, 22 Apr 2026 03:17:34 +0700 Subject: [PATCH] feat(pwa): add install orientation setting (#900) --- .../sections/openchamber/OpenChamberPage.tsx | 1 + .../openchamber/OpenChamberVisualSettings.tsx | 111 +++++++++++++++++- packages/ui/src/lib/desktop.ts | 1 + packages/web/index.html | 55 ++++++++- packages/web/public/site.webmanifest | 1 - packages/web/server/index.js | 2 + .../lib/opencode/pwa-manifest-routes.js | 20 +++- .../server/lib/opencode/settings-helpers.js | 18 +++ .../lib/opencode/static-routes-runtime.js | 2 + 9 files changed, 197 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 12ae829e..861cbcba 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -116,6 +116,7 @@ const VisualSectionContent: React.FC = () => { return [] = [ ]; const DEFAULT_PWA_INSTALL_NAME = 'OpenChamber - AI Coding Assistant'; +const PWA_ORIENTATION_OPTIONS: Option<'system' | 'portrait' | 'landscape'>[] = [ + { + id: 'system', + label: 'Follow system', + description: 'Respect the device rotation setting.', + }, + { + id: 'portrait', + label: 'Portrait lock', + description: 'Install the app locked to portrait.', + }, + { + id: 'landscape', + label: 'Landscape lock', + description: 'Install the app locked to landscape.', + }, +]; type PwaInstallNameWindow = Window & { __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; + __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; __OPENCHAMBER_UPDATE_PWA_MANIFEST__?: () => void; }; +const normalizePwaOrientation = (value: unknown): 'system' | 'portrait' | 'landscape' => { + return value === 'portrait' || value === 'landscape' ? value : 'system'; +}; + const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [ { id: 'markdown', @@ -196,7 +218,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -419,7 +441,7 @@ export const OpenChamberVisualSettings: React.FC }; const isVSCode = isVSCodeRuntime(); - const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('timeFormat') || shouldShow('weekStart')) && !isVSCode; + const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')) && !isVSCode; const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset'); const hasNavigationSettings = shouldShow('terminalQuickKeys') && !isMobile; const hasBehaviorSettings = shouldShow('mermaidRendering') @@ -439,7 +461,9 @@ export const OpenChamberVisualSettings: React.FC || (!isMobile && shouldShow('inputSpellcheck')); const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode; + const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode; const [pwaInstallName, setPwaInstallName] = React.useState(''); + const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const applyPwaInstallName = React.useCallback(async (value: string) => { if (typeof window === 'undefined') { @@ -462,8 +486,28 @@ export const OpenChamberVisualSettings: React.FC win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.(); }, []); + const applyPwaOrientation = React.useCallback(async (value: 'system' | 'portrait' | 'landscape') => { + if (typeof window === 'undefined') { + return; + } + + const win = window as PwaInstallNameWindow; + const normalized = normalizePwaOrientation(value); + + await updateDesktopSettings({ pwaOrientation: normalized }); + + if (typeof win.__OPENCHAMBER_SET_PWA_ORIENTATION__ === 'function') { + const resolved = win.__OPENCHAMBER_SET_PWA_ORIENTATION__(normalized); + setPwaOrientation(resolved); + return; + } + + setPwaOrientation(normalized); + win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.(); + }, []); + React.useEffect(() => { - if (typeof window === 'undefined' || !showPwaInstallNameSetting) { + if (typeof window === 'undefined' || (!showPwaInstallNameSetting && !showPwaOrientationSetting)) { return; } @@ -487,13 +531,24 @@ export const OpenChamberVisualSettings: React.FC const settings = await response.json().catch(() => ({})); const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : ''; const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64); + const orientation = normalizePwaOrientation(settings?.pwaOrientation); if (!cancelled) { - setPwaInstallName(normalized || DEFAULT_PWA_INSTALL_NAME); + if (showPwaInstallNameSetting) { + setPwaInstallName(normalized || DEFAULT_PWA_INSTALL_NAME); + } + if (showPwaOrientationSetting) { + setPwaOrientation(orientation); + } } } catch { if (!cancelled) { - setPwaInstallName(DEFAULT_PWA_INSTALL_NAME); + if (showPwaInstallNameSetting) { + setPwaInstallName(DEFAULT_PWA_INSTALL_NAME); + } + if (showPwaOrientationSetting) { + setPwaOrientation('system'); + } } } }; @@ -503,7 +558,7 @@ export const OpenChamberVisualSettings: React.FC return () => { cancelled = true; }; - }, [showPwaInstallNameSetting]); + }, [showPwaInstallNameSetting, showPwaOrientationSetting]); return (
@@ -689,6 +744,50 @@ export const OpenChamberVisualSettings: React.FC
)} + + {showPwaOrientationSetting && ( +
+
+ Install Orientation + Used by the installed web app. Reinstall the PWA after changing this. +
+
+ + +
+
+ )} )} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 69ee555e..d3a74de1 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -119,6 +119,7 @@ export type DesktopSettings = { gitProviderId?: string; gitModelId?: string; pwaAppName?: string; + pwaOrientation?: 'system' | 'portrait' | 'landscape'; inputSpellcheckEnabled?: boolean; showToolFileIcons?: boolean; showExpandedBashTools?: boolean; diff --git a/packages/web/index.html b/packages/web/index.html index 79d23e01..ecac66f9 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -27,6 +27,7 @@ const defaultAppName = 'OpenChamber - AI Coding Assistant'; const defaultShortName = 'OpenChamber'; const pwaNameStorageKey = 'openchamber.pwaName'; + const pwaOrientationStorageKey = 'openchamber.pwaOrientation'; const pwaRecentSessionsStorageKey = 'openchamber.pwaRecentSessions'; const normalizePwaName = (value, fallback) => { @@ -47,6 +48,13 @@ return value.length > maxLength ? value.slice(0, maxLength) : value; }; + const normalizePwaOrientation = (value, fallback = 'system') => { + if (value === 'portrait' || value === 'landscape' || value === 'system') { + return value; + } + return fallback; + }; + const getStoredInstallName = () => { try { const storedName = localStorage.getItem(pwaNameStorageKey); @@ -70,6 +78,25 @@ return normalizedName || defaultAppName; }; + const getStoredOrientation = () => { + try { + const storedOrientation = localStorage.getItem(pwaOrientationStorageKey); + return normalizePwaOrientation(storedOrientation, 'system'); + } catch { + return 'system'; + } + }; + + const setStoredOrientation = (value) => { + const normalizedOrientation = normalizePwaOrientation(value, 'system'); + try { + localStorage.setItem(pwaOrientationStorageKey, normalizedOrientation); + } catch { + return 'system'; + } + return normalizedOrientation; + }; + const getQueryInstallNameOverride = () => { try { const params = new URLSearchParams(location.search); @@ -158,8 +185,13 @@ return shortcuts; }; - const buildManifest = (appName, recentSessions) => { + const buildManifest = (appName, recentSessions, orientationPreference) => { const shortName = appName === defaultAppName ? defaultShortName : truncate(appName, 30); + const manifestOrientation = orientationPreference === 'portrait' + ? 'portrait-primary' + : orientationPreference === 'landscape' + ? 'landscape-primary' + : null; return { name: appName, short_name: shortName, @@ -168,7 +200,7 @@ start_url: `${baseUrl}/`, scope: `${baseUrl}/`, display: 'standalone', - orientation: 'portrait-primary', + ...(manifestOrientation ? { orientation: manifestOrientation } : {}), background_color: '#151313', theme_color: '#edb449', icons: [ @@ -187,11 +219,14 @@ }; }; - const buildManifestEndpointUrl = (installNameOverride = null) => { + const buildManifestEndpointUrl = (installNameOverride = null, orientationOverride = null) => { const params = new URLSearchParams(); if (typeof installNameOverride === 'string') { params.set('appName', installNameOverride); } + if (typeof orientationOverride === 'string') { + params.set('orientation', normalizePwaOrientation(orientationOverride, 'system')); + } const search = params.toString(); return `${baseUrl}/manifest.webmanifest${search ? `?${search}` : ''}`; }; @@ -257,11 +292,14 @@ } }; - const updateManifest = async (installNameOverride = null) => { + const updateManifest = async (installNameOverride = null, orientationOverride = null) => { const resolvedFallbackName = typeof installNameOverride === 'string' ? installNameOverride : getStoredInstallName(); + const resolvedOrientation = typeof orientationOverride === 'string' + ? normalizePwaOrientation(orientationOverride, 'system') + : getStoredOrientation(); const recentSessions = parseRecentSessionShortcuts(); - const manifest = buildManifest(resolvedFallbackName, recentSessions); - const manifestUrl = buildManifestEndpointUrl(installNameOverride); + const manifest = buildManifest(resolvedFallbackName, recentSessions, resolvedOrientation); + const manifestUrl = buildManifestEndpointUrl(installNameOverride, resolvedOrientation); const requestVersion = ++manifestRequestVersion; const useEndpoint = await canUseManifestEndpoint(manifestUrl, requestVersion); @@ -290,6 +328,11 @@ void updateManifest(resolvedName); return resolvedName; }; + window.__OPENCHAMBER_SET_PWA_ORIENTATION__ = (value) => { + const resolvedOrientation = setStoredOrientation(value); + void updateManifest(null, resolvedOrientation); + return resolvedOrientation; + }; window.__OPENCHAMBER_UPDATE_PWA_MANIFEST__ = () => { refreshManifestFromStorage(); }; diff --git a/packages/web/public/site.webmanifest b/packages/web/public/site.webmanifest index cdf312cf..81afd4b5 100644 --- a/packages/web/public/site.webmanifest +++ b/packages/web/public/site.webmanifest @@ -6,7 +6,6 @@ "display": "standalone", "background_color": "#151313", "theme_color": "#edb449", - "orientation": "portrait-primary", "icons": [ { "src": "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 2e513fc0..7a403232 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -279,6 +279,7 @@ const settingsHelpers = createSettingsHelpers({ }); const normalizePwaAppName = (...args) => settingsHelpers.normalizePwaAppName(...args); +const normalizePwaOrientation = (...args) => settingsHelpers.normalizePwaOrientation(...args); const sanitizeSettingsUpdate = (...args) => settingsHelpers.sanitizeSettingsUpdate(...args); const mergePersistedSettings = (...args) => settingsHelpers.mergePersistedSettings(...args); const formatSettingsResponse = (...args) => settingsHelpers.formatSettingsResponse(...args); @@ -759,6 +760,7 @@ const staticRoutesRuntime = createStaticRoutesRuntime({ getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, normalizePwaAppName, + normalizePwaOrientation, }); const featureRoutesRuntime = createFeatureRoutesRuntime({ clientReloadDelayMs: CLIENT_RELOAD_DELAY_MS, diff --git a/packages/web/server/lib/opencode/pwa-manifest-routes.js b/packages/web/server/lib/opencode/pwa-manifest-routes.js index fd99e49b..5cc08e2c 100644 --- a/packages/web/server/lib/opencode/pwa-manifest-routes.js +++ b/packages/web/server/lib/opencode/pwa-manifest-routes.js @@ -1,4 +1,13 @@ const DEFAULT_PWA_APP_NAME = 'OpenChamber - AI Coding Assistant'; +const mapPwaOrientationToManifest = (value) => { + if (value === 'portrait') { + return 'portrait-primary'; + } + if (value === 'landscape') { + return 'landscape-primary'; + } + return undefined; +}; export const registerPwaManifestRoute = (app, dependencies) => { const { @@ -8,6 +17,7 @@ export const registerPwaManifestRoute = (app, dependencies) => { getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, normalizePwaAppName, + normalizePwaOrientation, } = dependencies; const recentPwaSessionsCache = new Map(); @@ -180,18 +190,26 @@ export const registerPwaManifestRoute = (app, dependencies) => { } const queryOverrideName = normalizePwaAppName(queryValueRaw, ''); + const hasOrientationOverride = typeof req.query?.orientation === 'string'; + const queryOverrideOrientation = normalizePwaOrientation(req.query?.orientation, 'system'); let storedName = ''; + let storedOrientation = 'system'; try { const settings = await readSettingsFromDiskMigrated(); storedName = normalizePwaAppName(settings?.pwaAppName, ''); + storedOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system'); } catch { storedName = ''; + storedOrientation = 'system'; } const appName = hasQueryOverride ? (queryOverrideName || DEFAULT_PWA_APP_NAME) : (storedName || DEFAULT_PWA_APP_NAME); + const manifestOrientation = mapPwaOrientationToManifest( + hasOrientationOverride ? queryOverrideOrientation : storedOrientation + ); const shortName = appName.length > 30 ? appName.slice(0, 30) : appName; const recentSessionShortcuts = await getRecentPwaSessionShortcuts(req); @@ -206,7 +224,7 @@ export const registerPwaManifestRoute = (app, dependencies) => { display: 'standalone', background_color: '#151313', theme_color: '#edb449', - orientation: 'any', + ...(manifestOrientation ? { orientation: manifestOrientation } : {}), icons: [ { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 5661ba17..5714dcf9 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -18,6 +18,7 @@ export const createSettingsHelpers = (dependencies) => { } = dependencies; const PWA_APP_NAME_MAX_LENGTH = 64; + const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']); const normalizePwaAppName = (value, fallback = '') => { if (typeof value !== 'string') { @@ -30,6 +31,17 @@ export const createSettingsHelpers = (dependencies) => { return normalized.slice(0, PWA_APP_NAME_MAX_LENGTH); }; + const normalizePwaOrientation = (value, fallback = 'system') => { + if (typeof value !== 'string') { + return fallback; + } + const normalized = value.trim(); + if (PWA_ORIENTATION_VALUES.has(normalized)) { + return normalized; + } + return fallback; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -292,6 +304,9 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.pwaAppName === 'string') { result.pwaAppName = normalizePwaAppName(candidate.pwaAppName, undefined); } + if (typeof candidate.pwaOrientation === 'string') { + result.pwaOrientation = normalizePwaOrientation(candidate.pwaOrientation, undefined); + } if (typeof candidate.toolCallExpansion === 'string') { const mode = candidate.toolCallExpansion.trim(); if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { @@ -602,11 +617,13 @@ export const createSettingsHelpers = (dependencies) => { const bookmarks = normalizeStringArray(settings.securityScopedBookmarks); const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); + const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system'); return { ...sanitized, hasManagedRemoteTunnelToken, ...(pwaAppName ? { pwaAppName } : {}), + pwaOrientation, approvedDirectories: approved, securityScopedBookmarks: bookmarks, pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), @@ -622,6 +639,7 @@ export const createSettingsHelpers = (dependencies) => { return { normalizePwaAppName, + normalizePwaOrientation, sanitizeSettingsUpdate, mergePersistedSettings, formatSettingsResponse, diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js index 1f229239..220bf212 100644 --- a/packages/web/server/lib/opencode/static-routes-runtime.js +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -12,6 +12,7 @@ export const createStaticRoutesRuntime = (dependencies) => { getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, normalizePwaAppName, + normalizePwaOrientation, } = dependencies; const resolveDistPath = () => { @@ -43,6 +44,7 @@ export const createStaticRoutesRuntime = (dependencies) => { getOpenCodeAuthHeaders, readSettingsFromDiskMigrated, normalizePwaAppName, + normalizePwaOrientation, }); app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {