diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc79..db057a4c41f 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,8 +1,10 @@ -import { Component, createMemo, type JSX } from "solid-js" +import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" @@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => { checking: false, }) + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const check = () => { if (!platform.checkUpdate) return setStore("checking", true) @@ -410,13 +414,49 @@ export const SettingsGeneral: Component = () => { + + + {(_) => { + const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.()) + const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest) + + const onChange = (checked: boolean) => + platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch()) + + return ( + + {language.t("settings.general.section.display")} + + + + {language.t("settings.general.row.wayland.title")} + + + + + + + } + description={language.t("settings.general.row.wayland.description")} + > + + + + + + + ) + }} + ) } interface SettingsRowProps { - title: string + title: string | JSX.Element description: string | JSX.Element children: JSX.Element } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3fca502badb..7aa6c655400 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -57,6 +57,12 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServerUrl?(url: string | null): Promise | void + /** Get the preferred display backend (desktop only) */ + getDisplayBackend?(): Promise | DisplayBackend | null + + /** Set the preferred display backend (desktop only) */ + setDisplayBackend?(backend: DisplayBackend): Promise + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise @@ -70,6 +76,8 @@ export type Platform = { readClipboardImage?(): Promise } +export type DisplayBackend = "auto" | "wayland" + export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ name: "Platform", init: (props: { value: Platform }) => { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 8fba6861b0b..f4f49f055be 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -588,6 +588,7 @@ export const dict = { "settings.general.section.notifications": "System notifications", "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", + "settings.general.section.display": "Display", "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", @@ -598,6 +599,11 @@ export const dict = { "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.wayland.title": "Use native Wayland", + "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", + "settings.general.row.wayland.tooltip": + "On Linux with mixed refresh-rate monitors, native Wayland can be more stable.", + "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index fb668200927..59e1431fa8c 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,3 @@ -export { PlatformProvider, type Platform } from "./context/platform" +export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" export { useCommand } from "./context/command" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 14105e5dd39..92eead7867f 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod cli; mod constants; #[cfg(windows)] mod job_object; +#[cfg(target_os = "linux")] +mod linux_display; mod markdown; mod server; mod window_customizer; @@ -194,6 +196,43 @@ fn check_macos_app(app_name: &str) -> bool { .unwrap_or(false) } +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum LinuxDisplayBackend { + Wayland, + Auto, +} + +#[tauri::command] +#[specta::specta] +fn get_display_backend() -> Option { + #[cfg(target_os = "linux")] + { + let prefer = linux_display::read_wayland().unwrap_or(false); + return Some(if prefer { + LinuxDisplayBackend::Wayland + } else { + LinuxDisplayBackend::Auto + }); + } + + #[cfg(not(target_os = "linux"))] + None +} + +#[tauri::command] +#[specta::specta] +fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let prefer = matches!(_backend, LinuxDisplayBackend::Wayland); + return linux_display::write_wayland(&_app, prefer); + } + + #[cfg(not(target_os = "linux"))] + Ok(()) +} + #[cfg(target_os = "linux")] fn check_linux_app(app_name: &str) -> bool { return true; @@ -209,6 +248,8 @@ pub fn run() { await_initialization, server::get_default_server_url, server::set_default_server_url, + get_display_backend, + set_display_backend, markdown::parse_markdown_command, check_app_exists ]) diff --git a/packages/desktop/src-tauri/src/linux_display.rs b/packages/desktop/src-tauri/src/linux_display.rs new file mode 100644 index 00000000000..9e1cf90918a --- /dev/null +++ b/packages/desktop/src-tauri/src/linux_display.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +use crate::constants::SETTINGS_STORE; + +pub const LINUX_DISPLAY_CONFIG_KEY: &str = "linuxDisplayConfig"; + +#[derive(Default, Serialize, Deserialize)] +struct DisplayConfig { + wayland: Option, +} + +fn dir() -> Option { + Some(dirs::data_dir()?.join("ai.opencode.desktop")) +} + +fn path() -> Option { + dir().map(|dir| dir.join(SETTINGS_STORE)) +} + +pub fn read_wayland() -> Option { + let path = path()?; + let raw = std::fs::read_to_string(path).ok()?; + let config = serde_json::from_str::(&raw).ok()?; + config.wayland +} + +pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + store.set( + LINUX_DISPLAY_CONFIG_KEY, + json!(DisplayConfig { + wayland: Some(value), + }), + ); + store + .save() + .map_err(|e| format!("Failed to save settings store: {}", e))?; + + Ok(()) +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 9ffee8aa5ce..a95c62578c0 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2,6 +2,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // borrowed from https://github.com/skyline69/balatro-mod-manager +#[cfg(target_os = "linux")] +mod display; + #[cfg(target_os = "linux")] fn configure_display_backend() -> Option { use std::env; @@ -23,12 +26,16 @@ fn configure_display_backend() -> Option { return None; } - // Allow users to explicitly keep Wayland if they know their setup is stable. - let allow_wayland = matches!( - env::var("OC_ALLOW_WAYLAND"), - Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") - ); + let prefer_wayland = display::read_wayland().unwrap_or(false); + let allow_wayland = prefer_wayland + || matches!( + env::var("OC_ALLOW_WAYLAND"), + Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") + ); if allow_wayland { + if prefer_wayland { + return Some("Wayland session detected; using native Wayland from settings".into()); + } return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into()); } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 562a98acaec..2db1a624cc1 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -10,6 +10,8 @@ export const commands = { awaitInitialization: (events: Channel) => __TAURI_INVOKE("await_initialization", { events }), getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"), + setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE("set_display_backend", { backend }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), }; @@ -22,6 +24,8 @@ export const events = { /* Types */ export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }; +export type LinuxDisplayBackend = "wayland" | "auto"; + export type LoadingWindowComplete = null; export type ServerReadyData = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index cf007bdd3cd..25e9f825c70 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,7 +1,14 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" +import { + AppBaseProviders, + AppInterface, + PlatformProvider, + Platform, + DisplayBackend, + useCommand, +} from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" @@ -9,6 +16,7 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" import { getCurrentWindow } from "@tauri-apps/api/window" +import { invoke } from "@tauri-apps/api/core" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" @@ -338,6 +346,15 @@ const createPlatform = (password: Accessor): Platform => ({ await commands.setDefaultServerUrl(url) }, + getDisplayBackend: async () => { + const result = await invoke("get_display_backend").catch(() => null) + return result + }, + + setDisplayBackend: async (backend) => { + await invoke("set_display_backend", { backend }).catch(() => undefined) + }, + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), webviewZoom,