fix(stage-tamagotchi): fix window visibility and ozone flags on wayland (#2288)

This commit is contained in:
이윤진(Lee Yunjin)
2026-08-27 17:25:06 +08:00
committed by GitHub
parent c86894ccdc
commit 62b4fd21ff
17 changed files with 330 additions and 45 deletions
+2
View File
@@ -51,6 +51,8 @@ coverage/
# Build
out/
bundle/
flatpak/
flatpak-repo/
.flatpak-builder/
.flatpak-repo/
*.flatpak
@@ -10,7 +10,9 @@ finish-args:
# GUI environment
- --share=network
- --socket=x11
- --socket=fallback-x11
- --socket=wayland
- --device=dri
- --device=all
- --socket=pulseaudio
- --socket=system-bus
@@ -94,4 +96,8 @@ modules:
commands:
- ulimit -c 0
- export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
- |
if [ -n "$WAYLAND_DISPLAY" ]; then
export ELECTRON_OZONE_PLATFORM_HINT=auto
fi
- exec zypak-wrapper /app/lib/airi/airi "$@"
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { resolveIsWayland } from './ozone'
describe('resolveIsWayland', () => {
it('resolves to true when --ozone-platform is explicitly wayland', () => {
expect(resolveIsWayland({
explicitOzonePlatform: 'wayland',
env: {},
})).toBe(true)
})
it('resolves to false when --ozone-platform is explicitly x11 even in Wayland environment', () => {
expect(resolveIsWayland({
explicitOzonePlatform: 'x11',
env: {
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
},
})).toBe(false)
})
it('treats --ozone-platform=auto as unresolved and falls back to environment', () => {
expect(resolveIsWayland({
explicitOzonePlatform: 'auto',
env: {
WAYLAND_DISPLAY: 'wayland-0',
},
})).toBe(true)
expect(resolveIsWayland({
explicitOzonePlatform: 'auto',
env: {
XDG_SESSION_TYPE: 'x11',
},
})).toBe(false)
})
it('resolves based on --ozone-platform-hint when not auto', () => {
expect(resolveIsWayland({
ozonePlatformHint: 'wayland',
env: {},
})).toBe(true)
expect(resolveIsWayland({
ozonePlatformHint: 'x11',
env: {
WAYLAND_DISPLAY: 'wayland-0',
},
})).toBe(false)
})
it('treats --ozone-platform-hint=auto as unresolved and falls back to environment', () => {
expect(resolveIsWayland({
ozonePlatformHint: 'auto',
env: {
WAYLAND_DISPLAY: 'wayland-0',
},
})).toBe(true)
expect(resolveIsWayland({
ozonePlatformHint: 'auto',
env: {},
})).toBe(false)
})
it('falls back to environment variables when no flags are present', () => {
expect(resolveIsWayland({
env: {
WAYLAND_DISPLAY: 'wayland-0',
},
})).toBe(true)
expect(resolveIsWayland({
env: {
XDG_SESSION_TYPE: 'wayland',
},
})).toBe(true)
expect(resolveIsWayland({
env: {
XDG_SESSION_TYPE: 'x11',
},
})).toBe(false)
expect(resolveIsWayland({
env: {},
})).toBe(false)
})
})
@@ -0,0 +1,21 @@
/**
* Resolves whether the application is running under the Wayland Ozone backend.
*
* Checks explicit command-line switches before falling back to session environment variables.
* Treats 'auto' as an unresolved platform selection and resolves it using session environment variables.
*/
export function resolveIsWayland(params: {
explicitOzonePlatform?: string
ozonePlatformHint?: string
env?: Record<string, string | undefined>
}): boolean {
if (params.explicitOzonePlatform && params.explicitOzonePlatform !== 'auto') {
return params.explicitOzonePlatform === 'wayland'
}
if (params.ozonePlatformHint && params.ozonePlatformHint !== 'auto') {
return params.ozonePlatformHint === 'wayland'
}
return Boolean(params.env?.WAYLAND_DISPLAY || params.env?.XDG_SESSION_TYPE === 'wayland')
}
+44 -11
View File
@@ -22,6 +22,7 @@ import icon from '../../resources/icon.png?asset'
import { openDebugger, setupDebugger } from './app/debugger'
import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger'
import { resolveIsWayland } from './app/ozone'
import { installSingleInstanceGuard } from './app/single-instance'
import { createArtistryConfig } from './configs/artistry'
import { createGlobalAppConfig } from './configs/global'
@@ -79,21 +80,53 @@ if (appUserDataPath) {
// https://github.com/electron/electron/issues/41763#issuecomment-2051725363
// https://github.com/electron/electron/issues/41763#issuecomment-3143338995
if (isLinux) {
app.commandLine.appendSwitch('enable-features', 'SharedArrayBuffer')
// NOTICE:
// All enabled features must be joined into a single comma-separated string
// instead of calling appendSwitch('enable-features', ...) once per feature.
// Root cause: Chromium's commandLine stores switches by key, so each
// appendSwitch('enable-features', ...) call overwrites the previous value and
// only the last feature survives.
// Source: Chromium base::CommandLine behavior; see
// https://github.com/electron/electron/issues/41763 for the WebGPU setup this supports.
// Removal condition: never for the join itself; this block can be deleted once
// WebGPU works on Linux Electron without manual feature switches.
const enabledFeatures = [
'SharedArrayBuffer',
]
app.commandLine.appendSwitch('enable-unsafe-webgpu')
app.commandLine.appendSwitch('enable-features', 'Vulkan')
// NOTICE: we need UseOzonePlatform, WaylandWindowDecorations for working on Wayland.
// Partially related to https://github.com/electron/electron/issues/41551, since X11 is deprecating now,
// we can safely remove the feature flags for Electron once they made it default supported.
// Fixes: https://github.com/moeru-ai/airi/issues/757
// Ref: https://github.com/mmaura/poe2linuxcompanion/blob/90664607a147ea5ccea28df6139bd95fb0ebab0e/electron/main/index.ts#L28-L46
if (env.XDG_SESSION_TYPE === 'wayland') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal')
// Check explicit command-line switches before falling back to session environment variables.
// When running with XWayland (e.g. '--ozone-platform=x11'), session variables like WAYLAND_DISPLAY
// are still inherited from the Wayland desktop, but Chromium uses the explicitly specified Ozone backend.
// Treat explicit 'auto' as an unresolved platform selection and resolve using session environment variables.
const isWayland = resolveIsWayland({
explicitOzonePlatform: app.commandLine.getSwitchValue('ozone-platform'),
ozonePlatformHint: app.commandLine.getSwitchValue('ozone-platform-hint'),
env,
})
app.commandLine.appendSwitch('enable-features', 'UseOzonePlatform')
app.commandLine.appendSwitch('enable-features', 'WaylandWindowDecorations')
if (isWayland) {
enabledFeatures.push('GlobalShortcutsPortal', 'UseOzonePlatform', 'WaylandWindowDecorations')
if (!app.commandLine.hasSwitch('ozone-platform-hint')) {
app.commandLine.appendSwitch('ozone-platform-hint', 'auto')
}
}
else {
// NOTICE:
// Vulkan must only be enabled on non-Wayland sessions, otherwise GPU
// initialization fails or rendering glitches appear.
// Root cause: Vulkan is incompatible with '--ozone-platform=wayland' in
// Chromium's surface factory; the Wayland Ozone backend cannot present
// Vulkan surfaces.
// Source: Chromium Ozone/Wayland surface factory; workaround tracked via
// https://github.com/electron/electron/issues/41763 (WebGPU on Linux).
// Removal condition: when Chromium/Electron supports Vulkan with the Wayland
// Ozone backend, drop the isWayland guard and always push 'Vulkan'.
enabledFeatures.push('Vulkan')
}
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','))
}
app.dock?.setIcon(icon)
@@ -16,7 +16,7 @@ import {
electronWindowSetAlwaysOnTop,
} from '../../../shared/eventa'
import { onAppBeforeQuit, onAppWindowAllClosed } from '../../libs/bootkit/lifecycle'
import { resizeWindowByDelta } from '../../windows/shared/window'
import { resizeWindowByDelta, setWindowAlwaysOnTop } from '../../windows/shared/window'
export function createWindowService(params: { context: ReturnType<typeof createContext>['context'], window: BrowserWindow }) {
function getWindowLifecycleState(reason: ElectronWindowLifecycleState['reason']): ElectronWindowLifecycleState {
@@ -82,12 +82,7 @@ export function createWindowService(params: { context: ReturnType<typeof createC
defineInvokeHandler(params.context, electronWindowSetAlwaysOnTop, (flag, options) => {
if (params.window.webContents.id === options?.raw.ipcMainEvent.sender.id) {
if (flag) {
params.window.setAlwaysOnTop(true, 'screen-saver', 1)
}
else {
params.window.setAlwaysOnTop(false)
}
setWindowAlwaysOnTop(params.window, Boolean(flag))
}
})
@@ -22,7 +22,7 @@ import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs
import { createConfig } from '../../libs/electron/persistence'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display'
import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, transparentWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, setupBaseWindowElectronInvokes, setWindowAlwaysOnTop, transparentWindowConfig } from '../shared/window'
const captionConfigSchema = object({
isFollowing: boolean(),
@@ -118,7 +118,7 @@ function createCaptionWindow(options?: BrowserWindowConstructorOptions) {
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
type: 'panel',
type: isMacOS ? 'panel' : undefined,
...transparentWindowConfig(),
...options,
})
@@ -129,12 +129,12 @@ function createCaptionWindow(options?: BrowserWindowConstructorOptions) {
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
window.setAlwaysOnTop(true, 'screen-saver', 2)
window.setFullScreenable(false)
window.setVisibleOnAllWorkspaces(true)
if (isMacOS) {
window.setFullScreenable(false)
window.setWindowButtonVisibility(false)
}
setWindowAlwaysOnTop(window, true, 2)
window.on('ready-to-show', () => window.show())
protectPrivilegedWindowNavigation(window)
@@ -31,7 +31,7 @@ import { electronStartDraggingWindow } from '../../../shared/eventa'
import { onAppBeforeQuit } from '../../libs/bootkit/lifecycle'
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { protectPrivilegedWindowNavigation, transparentWindowConfig } from '../shared'
import { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, transparentWindowConfig } from '../shared'
import { setupMainWindowElectronInvokes } from './rpc/index.electron'
const appConfigSchema = object({
@@ -91,7 +91,7 @@ export async function setupMainWindow(params: {
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
type: 'panel',
type: isMacOS ? 'panel' : undefined,
...transparentWindowConfig(),
})
@@ -161,12 +161,12 @@ export async function setupMainWindow(params: {
//
// https://github.com/electron/electron/issues/10078#issuecomment-3410164802
// https://stackoverflow.com/questions/39835282/set-browserwindow-always-on-top-even-other-app-is-in-fullscreen-electron-mac
window.setAlwaysOnTop(true, 'screen-saver', 1)
window.setFullScreenable(false)
window.setVisibleOnAllWorkspaces(true)
if (isMacOS) {
window.setFullScreenable(false)
window.setWindowButtonVisibility(false)
}
setWindowAlwaysOnTop(window, true)
window.on('ready-to-show', () => window!.show())
protectPrivilegedWindowNavigation(window)
@@ -41,6 +41,22 @@ describe('mapForBreakpoints', () => {
const val2 = mapForBreakpoints(2000, { 'sm': 100, 'md': 200, '2xl': 500 }) // expected to be lg
expect(val2).toBe(500)
})
it('should fall back to the breakpoint with the smallest minimum width when below all breakpoints', () => {
// ROOT CAUSE:
//
// When `basedOn` is below every supplied breakpoint (e.g. display height 500
// with sm/md/lg sizes), no breakpoint matches and the previous fallback was
// `Object.values(sizes)[0]`, which depends on object key insertion order.
// Sorting the sizes map (e.g. sm/md/lg -> lg/md/sm by a lint rule) changed
// the fallback from the sm formula to the lg formula and moved the inlay on
// small displays.
//
// We fixed this by selecting the breakpoint with the smallest minimum width
// explicitly, so the result is stable regardless of key order.
expect(mapForBreakpoints(500, { sm: 100, md: 200, lg: 300 })).toBe(100)
expect(mapForBreakpoints(500, { lg: 300, md: 200, sm: 100 })).toBe(100)
})
})
describe('widthFrom', () => {
@@ -220,7 +220,7 @@ export function mapForBreakpoints<
basedOn: number,
sizes: { [key in keyof B]?: number } | number,
options?: { breakpoints: B },
) {
): number {
if (typeof sizes === 'number') {
return sizes
}
@@ -244,8 +244,16 @@ export function mapForBreakpoints<
.sort((a, b) => b.min - a.min) // Sort descending by min width
const fallback = sortedSizes.find(s => s.min <= basedOn)
if (fallback?.value != null) {
return fallback.value
}
return fallback?.value ?? Object.values(sizes)?.[0] ?? 0
// `basedOn` is below every supplied breakpoint (e.g. height < 640 with sm/md/lg
// sizes): use the breakpoint with the smallest minimum width. Selecting by min
// instead of `Object.values(sizes)[0]` keeps the result stable when the sizes
// object keys are reordered (e.g. by lint sorting rules).
const smallest = sortedSizes[sortedSizes.length - 1]
return smallest?.value ?? 0
}
/**
@@ -1 +1 @@
export { protectPrivilegedWindowNavigation, toggleWindowShow, transparentWindowConfig } from './window'
export { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, toggleWindowShow, transparentWindowConfig } from './window'
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import { setWindowAlwaysOnTop } from './window'
const mocks = vi.hoisted(() => ({
isMacOS: false,
isWindows: false,
}))
vi.mock('electron', () => ({
shell: {
openExternal: vi.fn(),
},
BrowserWindow: vi.fn(),
}))
vi.mock('std-env', () => ({
get isMacOS() {
return mocks.isMacOS
},
get isWindows() {
return mocks.isWindows
},
}))
vi.mock('../../services/electron', () => ({
createAppService: vi.fn(),
createPowerMonitorService: vi.fn(),
createScreenService: vi.fn(),
createSystemPreferencesService: vi.fn(),
createWindowService: vi.fn(),
}))
describe('setWindowAlwaysOnTop', () => {
it('disables always-on-top when flag is false', () => {
const window = {
setAlwaysOnTop: vi.fn(),
}
setWindowAlwaysOnTop(window, false)
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(false)
})
it('applies standard always-on-top on Linux', () => {
mocks.isMacOS = false
mocks.isWindows = false
const window = {
setAlwaysOnTop: vi.fn(),
}
setWindowAlwaysOnTop(window, true)
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true)
})
it('applies screen-saver level and relative offset on macOS', () => {
mocks.isMacOS = true
mocks.isWindows = false
const window = {
setAlwaysOnTop: vi.fn(),
}
setWindowAlwaysOnTop(window, true, 1)
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver', 1)
})
it('applies screen-saver level and relative offset on Windows', () => {
mocks.isMacOS = false
mocks.isWindows = true
const window = {
setAlwaysOnTop: vi.fn(),
}
setWindowAlwaysOnTop(window, true, 2)
expect(window.setAlwaysOnTop).toHaveBeenCalledWith(true, 'screen-saver', 2)
})
})
@@ -7,7 +7,7 @@ import type { ServerChannel } from '../../services/airi/channel-server'
import { isRendererUnavailable } from '@proj-airi/electron-vueuse/main'
import { shell } from 'electron'
import { isMacOS } from 'std-env'
import { isMacOS, isWindows } from 'std-env'
import { createServerChannelService } from '../../services/airi/channel-server'
import { createI18nService } from '../../services/airi/i18n'
@@ -92,6 +92,30 @@ export function spotlightLikeWindowConfig(): BrowserWindowConstructorOptions {
}
}
/**
* Sets the window always-on-top level according to the host platform.
*
* macOS and Windows support the screen-saver level and relative level layering,
* while Linux (X11/Wayland) works reliably with standard always-on-top.
*/
export function setWindowAlwaysOnTop(
window: Pick<BrowserWindow, 'setAlwaysOnTop'>,
flag: boolean,
relativeLevel = 1,
): void {
if (!flag) {
window.setAlwaysOnTop(false)
return
}
if (isMacOS || isWindows) {
window.setAlwaysOnTop(true, 'screen-saver', relativeLevel)
return
}
window.setAlwaysOnTop(true)
}
export function resizeWindowByDelta(params: {
window: BrowserWindow
deltaX: number
@@ -27,7 +27,7 @@ import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/window
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { createReusableWindow } from '../../libs/electron/window-manager'
import { protectPrivilegedWindowNavigation, spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
import { protectPrivilegedWindowNavigation, setWindowAlwaysOnTop, spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window'
import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator'
import { setupWidgetsWindowInvokes } from './rpc/index.electron'
@@ -231,7 +231,7 @@ function createWidgetsWindow() {
sandbox: false,
},
// Top-level overlay style like other overlay windows
type: 'panel',
type: isMacOS ? 'panel' : undefined,
...transparentWindowConfig(),
...spotlightLikeWindowConfig(),
})
@@ -247,15 +247,6 @@ function createWidgetsWindow() {
return window
}
function applyAlwaysOnTop(window: BrowserWindow, enabled: boolean) {
if (enabled) {
window.setAlwaysOnTop(true, 'screen-saver', 1)
return
}
window.setAlwaysOnTop(false)
}
interface WidgetRecord extends WidgetSnapshot {
timer?: ReturnType<typeof setTimeout>
}
@@ -512,7 +503,7 @@ export function setupWidgetsWindowManager(params: {
const window = await getWindowFromContext(context)
pendingRoute = undefined
applyWindowLayout(window, snapshot)
applyAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false)
setWindowAlwaysOnTop(window, snapshot?.alwaysOnTop ?? false)
if (currentRoute !== route)
await loadWithRoute(window, route)
window.show()
@@ -623,7 +614,7 @@ export function setupWidgetsWindowManager(params: {
const window = context?.window
if (window && !window.isDestroyed()) {
applyWindowLayout(window, nextSnapshot)
applyAlwaysOnTop(window, nextSnapshot.alwaysOnTop)
setWindowAlwaysOnTop(window, nextSnapshot.alwaysOnTop)
}
eventaContext?.emit(widgetsUpdateEvent, {
+2
View File
@@ -21,6 +21,8 @@ export default defineConfig({
'apps/stage-tamagotchi/src/bindings/**',
'apps/stage-tamagotchi-electron/out/**',
'apps/stage-tamagotchi-electron/src/renderer/bindings/**',
'**/flatpak/**',
'**/flatpak-repo/**',
'apps/stage-pocket/ios/**',
'apps/stage-pocket/android/**',
'**/drizzle/**',
@@ -98,6 +98,13 @@ export async function placeBlock(
west: new Vec3(-1, 0, 0),
}
// Fallback search priority when the requested face is unavailable. Kept as an
// explicit array because the first fallback decides which face the bot attaches
// to, which can change the placed block's orientation; relying on
// `Object.values(dirMap)` would silently change this priority if the dirMap
// keys are ever reordered (e.g. by sorting).
const fallbackDirs = [dirMap.top, dirMap.bottom, dirMap.north, dirMap.south, dirMap.east, dirMap.west]
const dirs: Vec3[] = []
if (placeOn === 'side') {
dirs.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west)
@@ -111,7 +118,7 @@ export async function placeBlock(
}
// Add remaining directions
dirs.push(...Object.values(dirMap).filter(d => !dirs.includes(d)))
dirs.push(...fallbackDirs.filter(d => !dirs.includes(d)))
let buildOffBlock: Block | null = null
let faceVec: Vec3 | null = null
+8 -1
View File
@@ -287,6 +287,13 @@ function findPlacementSpot(mineflayer: Mineflayer, targetDest: Vec3, placeOn: Bl
}
function getPlacementDirections(placeOn: BlockFace, dirMap: Record<string, Vec3>): Vec3[] {
// Fallback search priority when the requested face is unavailable. Kept as an
// explicit array because the first fallback decides which face the bot attaches
// to, which can change the placed block's orientation; relying on
// `Object.values(dirMap)` would silently change this priority if the dirMap
// keys are ever reordered (e.g. by sorting).
const fallbackDirs = [dirMap.top, dirMap.bottom, dirMap.north, dirMap.south, dirMap.east, dirMap.west]
const directions: Vec3[] = []
if (placeOn === 'side') {
directions.push(dirMap.north, dirMap.south, dirMap.east, dirMap.west)
@@ -298,7 +305,7 @@ function getPlacementDirections(placeOn: BlockFace, dirMap: Record<string, Vec3>
directions.push(dirMap.bottom)
}
directions.push(...Object.values(dirMap).filter(d => !directions.includes(d)))
directions.push(...fallbackDirs.filter(d => !directions.includes(d)))
return directions
}