Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -410,13 +414,49 @@ export const SettingsGeneral: Component = () => {
</SettingsRow>
</div>
</div>

<Show when={linux()}>
{(_) => {
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 (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>

<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={value() === "wayland"} onChange={onChange} />
</div>
</SettingsRow>
</div>
</div>
)
}}
</Show>
</div>
</div>
)
}

interface SettingsRowProps {
title: string
title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void

/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

/** Set the preferred display backend (desktop only) */
setDisplayBackend?(backend: DisplayBackend): Promise<void>

/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>

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

export type DisplayBackend = "auto" | "wayland"

export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
name: "Platform",
init: (props: { value: Platform }) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
41 changes: 41 additions & 0 deletions packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<LinuxDisplayBackend> {
#[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;
Expand All @@ -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
])
Expand Down
47 changes: 47 additions & 0 deletions packages/desktop/src-tauri/src/linux_display.rs
Original file line number Diff line number Diff line change
@@ -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<bool>,
}

fn dir() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("ai.opencode.desktop"))
}

fn path() -> Option<PathBuf> {
dir().map(|dir| dir.join(SETTINGS_STORE))
}

pub fn read_wayland() -> Option<bool> {
let path = path()?;
let raw = std::fs::read_to_string(path).ok()?;
let config = serde_json::from_str::<DisplayConfig>(&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(())
}
17 changes: 12 additions & 5 deletions packages/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
use std::env;
Expand All @@ -23,12 +26,16 @@ fn configure_display_backend() -> Option<String> {
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());
}

Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const commands = {
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
};
Expand All @@ -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 = {
Expand Down
19 changes: 18 additions & 1 deletion packages/desktop/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
// @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"
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"
Expand Down Expand Up @@ -338,6 +346,15 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
await commands.setDefaultServerUrl(url)
},

getDisplayBackend: async () => {
const result = await invoke<DisplayBackend | null>("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,
Expand Down
Loading