index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. // @refresh reload
  2. import "./webview-zoom"
  3. import { render } from "solid-js/web"
  4. import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
  5. import { open, save } from "@tauri-apps/plugin-dialog"
  6. import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
  7. import { open as shellOpen } from "@tauri-apps/plugin-shell"
  8. import { type as ostype } from "@tauri-apps/plugin-os"
  9. import { check, Update } from "@tauri-apps/plugin-updater"
  10. import { invoke } from "@tauri-apps/api/core"
  11. import { getCurrentWindow } from "@tauri-apps/api/window"
  12. import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
  13. import { relaunch } from "@tauri-apps/plugin-process"
  14. import { AsyncStorage } from "@solid-primitives/storage"
  15. import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
  16. import { Store } from "@tauri-apps/plugin-store"
  17. import { Splash } from "@opencode-ai/ui/logo"
  18. import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js"
  19. import { UPDATER_ENABLED } from "./updater"
  20. import { createMenu } from "./menu"
  21. import { initI18n, t } from "./i18n"
  22. import pkg from "../package.json"
  23. import "./styles.css"
  24. const root = document.getElementById("root")
  25. if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
  26. throw new Error(t("error.dev.rootNotFound"))
  27. }
  28. void initI18n()
  29. // Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
  30. // This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
  31. const originalGetComputedStyle = window.getComputedStyle
  32. window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
  33. if (!(elt instanceof Element)) {
  34. // Fall back to a safe element when a non-element is passed.
  35. return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
  36. }
  37. return originalGetComputedStyle(elt, pseudoElt ?? undefined)
  38. }) as typeof window.getComputedStyle
  39. let update: Update | null = null
  40. const deepLinkEvent = "opencode:deep-link"
  41. const emitDeepLinks = (urls: string[]) => {
  42. if (urls.length === 0) return
  43. window.__OPENCODE__ ??= {}
  44. const pending = window.__OPENCODE__.deepLinks ?? []
  45. window.__OPENCODE__.deepLinks = [...pending, ...urls]
  46. window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
  47. }
  48. const listenForDeepLinks = async () => {
  49. const startUrls = await getCurrent().catch(() => null)
  50. if (startUrls?.length) emitDeepLinks(startUrls)
  51. await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
  52. }
  53. const createPlatform = (password: Accessor<string | null>): Platform => ({
  54. platform: "desktop",
  55. os: (() => {
  56. const type = ostype()
  57. if (type === "macos" || type === "windows" || type === "linux") return type
  58. return undefined
  59. })(),
  60. version: pkg.version,
  61. async openDirectoryPickerDialog(opts) {
  62. const result = await open({
  63. directory: true,
  64. multiple: opts?.multiple ?? false,
  65. title: opts?.title ?? t("desktop.dialog.chooseFolder"),
  66. })
  67. return result
  68. },
  69. async openFilePickerDialog(opts) {
  70. const result = await open({
  71. directory: false,
  72. multiple: opts?.multiple ?? false,
  73. title: opts?.title ?? t("desktop.dialog.chooseFile"),
  74. })
  75. return result
  76. },
  77. async saveFilePickerDialog(opts) {
  78. const result = await save({
  79. title: opts?.title ?? t("desktop.dialog.saveFile"),
  80. defaultPath: opts?.defaultPath,
  81. })
  82. return result
  83. },
  84. openLink(url: string) {
  85. void shellOpen(url).catch(() => undefined)
  86. },
  87. back() {
  88. window.history.back()
  89. },
  90. forward() {
  91. window.history.forward()
  92. },
  93. storage: (() => {
  94. type StoreLike = {
  95. get(key: string): Promise<string | null | undefined>
  96. set(key: string, value: string): Promise<unknown>
  97. delete(key: string): Promise<unknown>
  98. clear(): Promise<unknown>
  99. keys(): Promise<string[]>
  100. length(): Promise<number>
  101. }
  102. const WRITE_DEBOUNCE_MS = 250
  103. const storeCache = new Map<string, Promise<StoreLike>>()
  104. const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
  105. const memoryCache = new Map<string, StoreLike>()
  106. const flushAll = async () => {
  107. const apis = Array.from(apiCache.values())
  108. await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
  109. }
  110. if ("addEventListener" in globalThis) {
  111. const handleVisibility = () => {
  112. if (document.visibilityState !== "hidden") return
  113. void flushAll()
  114. }
  115. window.addEventListener("pagehide", () => void flushAll())
  116. document.addEventListener("visibilitychange", handleVisibility)
  117. }
  118. const createMemoryStore = () => {
  119. const data = new Map<string, string>()
  120. const store: StoreLike = {
  121. get: async (key) => data.get(key),
  122. set: async (key, value) => {
  123. data.set(key, value)
  124. },
  125. delete: async (key) => {
  126. data.delete(key)
  127. },
  128. clear: async () => {
  129. data.clear()
  130. },
  131. keys: async () => Array.from(data.keys()),
  132. length: async () => data.size,
  133. }
  134. return store
  135. }
  136. const getStore = (name: string) => {
  137. const cached = storeCache.get(name)
  138. if (cached) return cached
  139. const store = Store.load(name).catch(() => {
  140. const cached = memoryCache.get(name)
  141. if (cached) return cached
  142. const memory = createMemoryStore()
  143. memoryCache.set(name, memory)
  144. return memory
  145. })
  146. storeCache.set(name, store)
  147. return store
  148. }
  149. const createStorage = (name: string) => {
  150. const pending = new Map<string, string | null>()
  151. let timer: ReturnType<typeof setTimeout> | undefined
  152. let flushing: Promise<void> | undefined
  153. const flush = async () => {
  154. if (flushing) return flushing
  155. flushing = (async () => {
  156. const store = await getStore(name)
  157. while (pending.size > 0) {
  158. const batch = Array.from(pending.entries())
  159. pending.clear()
  160. for (const [key, value] of batch) {
  161. if (value === null) {
  162. await store.delete(key).catch(() => undefined)
  163. } else {
  164. await store.set(key, value).catch(() => undefined)
  165. }
  166. }
  167. }
  168. })().finally(() => {
  169. flushing = undefined
  170. })
  171. return flushing
  172. }
  173. const schedule = () => {
  174. if (timer) return
  175. timer = setTimeout(() => {
  176. timer = undefined
  177. void flush()
  178. }, WRITE_DEBOUNCE_MS)
  179. }
  180. const api: AsyncStorage & { flush: () => Promise<void> } = {
  181. flush,
  182. getItem: async (key: string) => {
  183. const next = pending.get(key)
  184. if (next !== undefined) return next
  185. const store = await getStore(name)
  186. const value = await store.get(key).catch(() => null)
  187. if (value === undefined) return null
  188. return value
  189. },
  190. setItem: async (key: string, value: string) => {
  191. pending.set(key, value)
  192. schedule()
  193. },
  194. removeItem: async (key: string) => {
  195. pending.set(key, null)
  196. schedule()
  197. },
  198. clear: async () => {
  199. pending.clear()
  200. const store = await getStore(name)
  201. await store.clear().catch(() => undefined)
  202. },
  203. key: async (index: number) => {
  204. const store = await getStore(name)
  205. return (await store.keys().catch(() => []))[index]
  206. },
  207. getLength: async () => {
  208. const store = await getStore(name)
  209. return await store.length().catch(() => 0)
  210. },
  211. get length() {
  212. return api.getLength()
  213. },
  214. }
  215. return api
  216. }
  217. return (name = "default.dat") => {
  218. const cached = apiCache.get(name)
  219. if (cached) return cached
  220. const api = createStorage(name)
  221. apiCache.set(name, api)
  222. return api
  223. }
  224. })(),
  225. checkUpdate: async () => {
  226. if (!UPDATER_ENABLED) return { updateAvailable: false }
  227. const next = await check().catch(() => null)
  228. if (!next) return { updateAvailable: false }
  229. const ok = await next
  230. .download()
  231. .then(() => true)
  232. .catch(() => false)
  233. if (!ok) return { updateAvailable: false }
  234. update = next
  235. return { updateAvailable: true, version: next.version }
  236. },
  237. update: async () => {
  238. if (!UPDATER_ENABLED || !update) return
  239. if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
  240. await update.install().catch(() => undefined)
  241. },
  242. restart: async () => {
  243. await invoke("kill_sidecar").catch(() => undefined)
  244. await relaunch()
  245. },
  246. notify: async (title, description, href) => {
  247. const granted = await isPermissionGranted().catch(() => false)
  248. const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
  249. if (permission !== "granted") return
  250. const win = getCurrentWindow()
  251. const focused = await win.isFocused().catch(() => document.hasFocus())
  252. if (focused) return
  253. await Promise.resolve()
  254. .then(() => {
  255. const notification = new Notification(title, {
  256. body: description ?? "",
  257. icon: "https://opencode.ai/favicon-96x96-v3.png",
  258. })
  259. notification.onclick = () => {
  260. const win = getCurrentWindow()
  261. void win.show().catch(() => undefined)
  262. void win.unminimize().catch(() => undefined)
  263. void win.setFocus().catch(() => undefined)
  264. if (href) {
  265. window.history.pushState(null, "", href)
  266. window.dispatchEvent(new PopStateEvent("popstate"))
  267. }
  268. notification.close()
  269. }
  270. })
  271. .catch(() => undefined)
  272. },
  273. // @ts-expect-error
  274. fetch: (input, init) => {
  275. const pw = password()
  276. const addHeader = (headers: Headers, password: string) => {
  277. headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
  278. }
  279. if (input instanceof Request) {
  280. if (pw) addHeader(input.headers, pw)
  281. return tauriFetch(input)
  282. } else {
  283. const headers = new Headers(init?.headers)
  284. if (pw) addHeader(headers, pw)
  285. return tauriFetch(input, {
  286. ...(init as any),
  287. headers: headers,
  288. })
  289. }
  290. },
  291. getDefaultServerUrl: async () => {
  292. const result = await invoke<string | null>("get_default_server_url").catch(() => null)
  293. return result
  294. },
  295. setDefaultServerUrl: async (url: string | null) => {
  296. await invoke("set_default_server_url", { url })
  297. },
  298. parseMarkdown: async (markdown: string) => {
  299. return invoke<string>("parse_markdown_command", { markdown })
  300. },
  301. })
  302. createMenu()
  303. void listenForDeepLinks()
  304. render(() => {
  305. const [serverPassword, setServerPassword] = createSignal<string | null>(null)
  306. const platform = createPlatform(() => serverPassword())
  307. function handleClick(e: MouseEvent) {
  308. const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
  309. if (link?.href) {
  310. e.preventDefault()
  311. platform.openLink(link.href)
  312. }
  313. }
  314. onMount(() => {
  315. document.addEventListener("click", handleClick)
  316. onCleanup(() => {
  317. document.removeEventListener("click", handleClick)
  318. })
  319. })
  320. return (
  321. <PlatformProvider value={platform}>
  322. <AppBaseProviders>
  323. <ServerGate>
  324. {(data) => {
  325. setServerPassword(data().password)
  326. window.__OPENCODE__ ??= {}
  327. window.__OPENCODE__.serverPassword = data().password ?? undefined
  328. return <AppInterface defaultUrl={data().url} />
  329. }}
  330. </ServerGate>
  331. </AppBaseProviders>
  332. </PlatformProvider>
  333. )
  334. }, root!)
  335. type ServerReadyData = { url: string; password: string | null }
  336. // Gate component that waits for the server to be ready
  337. function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
  338. const [serverData] = createResource<ServerReadyData>(() =>
  339. invoke("ensure_server_ready").then((v) => {
  340. return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
  341. }),
  342. )
  343. const errorMessage = () => {
  344. const error = serverData.error
  345. if (!error) return t("error.chain.unknown")
  346. if (typeof error === "string") return error
  347. if (error instanceof Error) return error.message
  348. return String(error)
  349. }
  350. const restartApp = async () => {
  351. await invoke("kill_sidecar").catch(() => undefined)
  352. await relaunch().catch(() => undefined)
  353. }
  354. return (
  355. // Not using suspense as not all components are compatible with it (undefined refs)
  356. <Show
  357. when={serverData.state === "errored"}
  358. fallback={
  359. <Show
  360. when={serverData.state !== "pending" && serverData()}
  361. fallback={
  362. <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
  363. <Splash class="w-16 h-20 opacity-50 animate-pulse" />
  364. <div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
  365. </div>
  366. }
  367. >
  368. {(data) => props.children(data)}
  369. </Show>
  370. }
  371. >
  372. <div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
  373. <div class="text-16-semibold">{t("desktop.error.serverStartFailed.title")}</div>
  374. <div class="text-12-regular opacity-70 text-center max-w-xl">
  375. {t("desktop.error.serverStartFailed.description")}
  376. </div>
  377. <div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
  378. <pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
  379. </div>
  380. <button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
  381. {t("error.page.action.restart")}
  382. </button>
  383. <div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
  384. </div>
  385. </Show>
  386. )
  387. }