Skip to content

Commit 93a11dd

Browse files
feat(desktop): add native Wayland toggle on Linux (#11971)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
1 parent 94feb81 commit 93a11dd

9 files changed

Lines changed: 179 additions & 9 deletions

File tree

packages/app/src/components/settings-general.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { Component, createMemo, type JSX } from "solid-js"
1+
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { Button } from "@opencode-ai/ui/button"
4+
import { Icon } from "@opencode-ai/ui/icon"
45
import { Select } from "@opencode-ai/ui/select"
56
import { Switch } from "@opencode-ai/ui/switch"
7+
import { Tooltip } from "@opencode-ai/ui/tooltip"
68
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
79
import { showToast } from "@opencode-ai/ui/toast"
810
import { useLanguage } from "@/context/language"
@@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => {
4042
checking: false,
4143
})
4244

45+
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
46+
4347
const check = () => {
4448
if (!platform.checkUpdate) return
4549
setStore("checking", true)
@@ -410,13 +414,49 @@ export const SettingsGeneral: Component = () => {
410414
</SettingsRow>
411415
</div>
412416
</div>
417+
418+
<Show when={linux()}>
419+
{(_) => {
420+
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
421+
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
422+
423+
const onChange = (checked: boolean) =>
424+
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
425+
426+
return (
427+
<div class="flex flex-col gap-1">
428+
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
429+
430+
<div class="bg-surface-raised-base px-4 rounded-lg">
431+
<SettingsRow
432+
title={
433+
<div class="flex items-center gap-2">
434+
<span>{language.t("settings.general.row.wayland.title")}</span>
435+
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
436+
<span class="text-text-weak">
437+
<Icon name="help" size="small" />
438+
</span>
439+
</Tooltip>
440+
</div>
441+
}
442+
description={language.t("settings.general.row.wayland.description")}
443+
>
444+
<div data-action="settings-wayland">
445+
<Switch checked={value() === "wayland"} onChange={onChange} />
446+
</div>
447+
</SettingsRow>
448+
</div>
449+
</div>
450+
)
451+
}}
452+
</Show>
413453
</div>
414454
</div>
415455
)
416456
}
417457

418458
interface SettingsRowProps {
419-
title: string
459+
title: string | JSX.Element
420460
description: string | JSX.Element
421461
children: JSX.Element
422462
}

packages/app/src/context/platform.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export type Platform = {
5757
/** Set the default server URL to use on app startup (platform-specific) */
5858
setDefaultServerUrl?(url: string | null): Promise<void> | void
5959

60+
/** Get the preferred display backend (desktop only) */
61+
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
62+
63+
/** Set the preferred display backend (desktop only) */
64+
setDisplayBackend?(backend: DisplayBackend): Promise<void>
65+
6066
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
6167
parseMarkdown?(markdown: string): Promise<string>
6268

@@ -70,6 +76,8 @@ export type Platform = {
7076
readClipboardImage?(): Promise<File | null>
7177
}
7278

79+
export type DisplayBackend = "auto" | "wayland"
80+
7381
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
7482
name: "Platform",
7583
init: (props: { value: Platform }) => {

packages/app/src/i18n/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ export const dict = {
588588
"settings.general.section.notifications": "System notifications",
589589
"settings.general.section.updates": "Updates",
590590
"settings.general.section.sounds": "Sound effects",
591+
"settings.general.section.display": "Display",
591592

592593
"settings.general.row.language.title": "Language",
593594
"settings.general.row.language.description": "Change the display language for OpenCode",
@@ -598,6 +599,11 @@ export const dict = {
598599
"settings.general.row.font.title": "Font",
599600
"settings.general.row.font.description": "Customise the mono font used in code blocks",
600601

602+
"settings.general.row.wayland.title": "Use native Wayland",
603+
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
604+
"settings.general.row.wayland.tooltip":
605+
"On Linux with mixed refresh-rate monitors, native Wayland can be more stable.",
606+
601607
"settings.general.row.releaseNotes.title": "Release notes",
602608
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",
603609

packages/app/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { PlatformProvider, type Platform } from "./context/platform"
1+
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
22
export { AppBaseProviders, AppInterface } from "./app"
33
export { useCommand } from "./context/command"

packages/desktop/src-tauri/src/lib.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ mod cli;
22
mod constants;
33
#[cfg(windows)]
44
mod job_object;
5+
#[cfg(target_os = "linux")]
6+
mod linux_display;
57
mod markdown;
68
mod server;
79
mod window_customizer;
@@ -194,6 +196,43 @@ fn check_macos_app(app_name: &str) -> bool {
194196
.unwrap_or(false)
195197
}
196198

199+
#[derive(serde::Serialize, serde::Deserialize, specta::Type)]
200+
#[serde(rename_all = "camelCase")]
201+
pub enum LinuxDisplayBackend {
202+
Wayland,
203+
Auto,
204+
}
205+
206+
#[tauri::command]
207+
#[specta::specta]
208+
fn get_display_backend() -> Option<LinuxDisplayBackend> {
209+
#[cfg(target_os = "linux")]
210+
{
211+
let prefer = linux_display::read_wayland().unwrap_or(false);
212+
return Some(if prefer {
213+
LinuxDisplayBackend::Wayland
214+
} else {
215+
LinuxDisplayBackend::Auto
216+
});
217+
}
218+
219+
#[cfg(not(target_os = "linux"))]
220+
None
221+
}
222+
223+
#[tauri::command]
224+
#[specta::specta]
225+
fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
226+
#[cfg(target_os = "linux")]
227+
{
228+
let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
229+
return linux_display::write_wayland(&_app, prefer);
230+
}
231+
232+
#[cfg(not(target_os = "linux"))]
233+
Ok(())
234+
}
235+
197236
#[cfg(target_os = "linux")]
198237
fn check_linux_app(app_name: &str) -> bool {
199238
return true;
@@ -209,6 +248,8 @@ pub fn run() {
209248
await_initialization,
210249
server::get_default_server_url,
211250
server::set_default_server_url,
251+
get_display_backend,
252+
set_display_backend,
212253
markdown::parse_markdown_command,
213254
check_app_exists
214255
])
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use serde::{Deserialize, Serialize};
2+
use serde_json::json;
3+
use std::path::PathBuf;
4+
use tauri::AppHandle;
5+
use tauri_plugin_store::StoreExt;
6+
7+
use crate::constants::SETTINGS_STORE;
8+
9+
pub const LINUX_DISPLAY_CONFIG_KEY: &str = "linuxDisplayConfig";
10+
11+
#[derive(Default, Serialize, Deserialize)]
12+
struct DisplayConfig {
13+
wayland: Option<bool>,
14+
}
15+
16+
fn dir() -> Option<PathBuf> {
17+
Some(dirs::data_dir()?.join("ai.opencode.desktop"))
18+
}
19+
20+
fn path() -> Option<PathBuf> {
21+
dir().map(|dir| dir.join(SETTINGS_STORE))
22+
}
23+
24+
pub fn read_wayland() -> Option<bool> {
25+
let path = path()?;
26+
let raw = std::fs::read_to_string(path).ok()?;
27+
let config = serde_json::from_str::<DisplayConfig>(&raw).ok()?;
28+
config.wayland
29+
}
30+
31+
pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> {
32+
let store = app
33+
.store(SETTINGS_STORE)
34+
.map_err(|e| format!("Failed to open settings store: {}", e))?;
35+
36+
store.set(
37+
LINUX_DISPLAY_CONFIG_KEY,
38+
json!(DisplayConfig {
39+
wayland: Some(value),
40+
}),
41+
);
42+
store
43+
.save()
44+
.map_err(|e| format!("Failed to save settings store: {}", e))?;
45+
46+
Ok(())
47+
}

packages/desktop/src-tauri/src/main.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
33

44
// borrowed from https://github.com/skyline69/balatro-mod-manager
5+
#[cfg(target_os = "linux")]
6+
mod display;
7+
58
#[cfg(target_os = "linux")]
69
fn configure_display_backend() -> Option<String> {
710
use std::env;
@@ -23,12 +26,16 @@ fn configure_display_backend() -> Option<String> {
2326
return None;
2427
}
2528

26-
// Allow users to explicitly keep Wayland if they know their setup is stable.
27-
let allow_wayland = matches!(
28-
env::var("OC_ALLOW_WAYLAND"),
29-
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
30-
);
29+
let prefer_wayland = display::read_wayland().unwrap_or(false);
30+
let allow_wayland = prefer_wayland
31+
|| matches!(
32+
env::var("OC_ALLOW_WAYLAND"),
33+
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
34+
);
3135
if allow_wayland {
36+
if prefer_wayland {
37+
return Some("Wayland session detected; using native Wayland from settings".into());
38+
}
3239
return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into());
3340
}
3441

packages/desktop/src/bindings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const commands = {
1010
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
1111
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
1212
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
13+
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
14+
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
1315
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
1416
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
1517
};
@@ -22,6 +24,8 @@ export const events = {
2224
/* Types */
2325
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" };
2426

27+
export type LinuxDisplayBackend = "wayland" | "auto";
28+
2529
export type LoadingWindowComplete = null;
2630

2731
export type ServerReadyData = {

packages/desktop/src/index.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
// @refresh reload
22
import { webviewZoom } from "./webview-zoom"
33
import { render } from "solid-js/web"
4-
import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app"
4+
import {
5+
AppBaseProviders,
6+
AppInterface,
7+
PlatformProvider,
8+
Platform,
9+
DisplayBackend,
10+
useCommand,
11+
} from "@opencode-ai/app"
512
import { open, save } from "@tauri-apps/plugin-dialog"
613
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
714
import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener"
815
import { open as shellOpen } from "@tauri-apps/plugin-shell"
916
import { type as ostype } from "@tauri-apps/plugin-os"
1017
import { check, Update } from "@tauri-apps/plugin-updater"
1118
import { getCurrentWindow } from "@tauri-apps/api/window"
19+
import { invoke } from "@tauri-apps/api/core"
1220
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
1321
import { relaunch } from "@tauri-apps/plugin-process"
1422
import { AsyncStorage } from "@solid-primitives/storage"
@@ -338,6 +346,15 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
338346
await commands.setDefaultServerUrl(url)
339347
},
340348

349+
getDisplayBackend: async () => {
350+
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null)
351+
return result
352+
},
353+
354+
setDisplayBackend: async (backend) => {
355+
await invoke("set_display_backend", { backend }).catch(() => undefined)
356+
},
357+
341358
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
342359

343360
webviewZoom,

0 commit comments

Comments
 (0)