index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. // @refresh reload
  2. import {
  3. ACCEPTED_FILE_EXTENSIONS,
  4. AppBaseProviders,
  5. AppInterface,
  6. handleNotificationClick,
  7. loadLocaleDict,
  8. normalizeLocale,
  9. type Locale,
  10. type Platform,
  11. PlatformProvider,
  12. ServerConnection,
  13. useCommand,
  14. useWslServers,
  15. } from "@kirincode-ai/app"
  16. import type { UpdaterState } from "@kirincode-ai/app/updater"
  17. import * as Sentry from "@sentry/solid"
  18. import type { AsyncStorage } from "@solid-primitives/storage"
  19. import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
  20. import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
  21. import { render } from "solid-js/web"
  22. import pkg from "../../package.json"
  23. import { initI18n, t } from "./i18n"
  24. import { initializationData, initializationReady } from "./initialization"
  25. import { DesktopFirstLaunchOnboarding } from "./onboarding"
  26. import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
  27. import { availableStartupServer, readyWslConnections } from "./wsl/connections"
  28. import "./styles.css"
  29. import { Splash } from "@kirincode-ai/ui/logo"
  30. import { useTheme } from "@kirincode-ai/ui/theme/context"
  31. const root = document.getElementById("root")
  32. if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
  33. throw new Error(t("error.dev.rootNotFound"))
  34. }
  35. if (import.meta.env.VITE_SENTRY_DSN) {
  36. Sentry.init({
  37. dsn: import.meta.env.VITE_SENTRY_DSN,
  38. environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
  39. release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
  40. initialScope: {
  41. tags: {
  42. platform: "desktop",
  43. },
  44. },
  45. integrations: (integrations) => {
  46. return integrations.filter(
  47. (i) =>
  48. i.name !== "Breadcrumbs" &&
  49. !(
  50. import.meta.env.KIRINCODE_CHANNEL === "prod" &&
  51. (i.name === "GlobalHandlers" || i.name === "BrowserApiErrors")
  52. ),
  53. )
  54. },
  55. })
  56. }
  57. void initI18n()
  58. const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
  59. void window.api.updater.subscribe(setUpdaterState)
  60. const deepLinkEvent = "kirincode:deep-link"
  61. type DesktopWindowState = {
  62. id?: string
  63. }
  64. const emitDeepLinks = (urls: string[]) => {
  65. if (urls.length === 0) return
  66. window.__KIRINCODE__ ??= {}
  67. const pending = window.__KIRINCODE__.deepLinks ?? []
  68. window.__KIRINCODE__.deepLinks = [...pending, ...urls]
  69. window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
  70. }
  71. const listenForDeepLinks = () => {
  72. void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
  73. return window.api.onDeepLink((urls) => emitDeepLinks(urls))
  74. }
  75. function windowLastActiveUrlKey(windowID: string) {
  76. return `kirincode.desktop.window.${windowID}.last-active-url`
  77. }
  78. function getLastActiveUrl(windowID: string) {
  79. if (typeof localStorage !== "object") return "/"
  80. try {
  81. const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
  82. if (value?.startsWith("/") && !value.startsWith("//")) return value
  83. } catch {}
  84. return "/"
  85. }
  86. function setLastActiveUrl(windowID: string, value: string) {
  87. if (typeof localStorage !== "object") return
  88. try {
  89. localStorage.setItem(windowLastActiveUrlKey(windowID), value)
  90. } catch {}
  91. }
  92. function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
  93. const history = createMemoryHistory()
  94. const initialUrl = getLastActiveUrl(props.windowID)
  95. if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
  96. onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
  97. return <MemoryRouter {...props} history={history} />
  98. }
  99. const createPlatform = (windowState: DesktopWindowState): Platform => {
  100. const attachmentPaths = new WeakMap<File, string>()
  101. const os = (() => {
  102. const ua = navigator.userAgent
  103. if (ua.includes("Mac")) return "macos"
  104. if (ua.includes("Windows")) return "windows"
  105. if (ua.includes("Linux")) return "linux"
  106. return undefined
  107. })()
  108. const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
  109. switch (action) {
  110. case "view.resetZoom":
  111. resetZoom()
  112. return
  113. case "view.zoomIn":
  114. zoomIn()
  115. return
  116. case "view.zoomOut":
  117. zoomOut()
  118. return
  119. }
  120. return window.api.runDesktopMenuAction(action)
  121. }
  122. const storage = (() => {
  123. const cache = new Map<string, AsyncStorage>()
  124. const createStorage = (name: string) => {
  125. const api: AsyncStorage = {
  126. getItem: (key: string) => window.api.storeGet(name, key),
  127. setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
  128. removeItem: (key: string) => window.api.storeDelete(name, key),
  129. clear: () => window.api.storeClear(name),
  130. key: async (index: number) => (await window.api.storeKeys(name))[index],
  131. getLength: () => window.api.storeLength(name),
  132. get length() {
  133. return api.getLength()
  134. },
  135. }
  136. return api
  137. }
  138. return (name = "default.dat") => {
  139. const cached = cache.get(name)
  140. if (cached) return cached
  141. const api = createStorage(name)
  142. cache.set(name, api)
  143. return api
  144. }
  145. })()
  146. const wslServersApi = os === "windows" ? window.api.wslServers : undefined
  147. return {
  148. platform: "desktop",
  149. os,
  150. version: pkg.version,
  151. windowID: windowState.id,
  152. async openDirectoryPickerDialog(opts) {
  153. return window.api.openDirectoryPicker({
  154. multiple: opts?.multiple ?? false,
  155. title: opts?.title ?? t("desktop.dialog.chooseFolder"),
  156. })
  157. },
  158. async openAttachmentPickerDialog(opts, onFile) {
  159. const result = await window.api.openFilePicker({
  160. multiple: opts?.multiple ?? false,
  161. title: opts?.title ?? t("desktop.dialog.chooseFile"),
  162. defaultPath: opts?.defaultPath,
  163. extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
  164. })
  165. if (!result) return
  166. try {
  167. for (const file of result.files) {
  168. const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name)
  169. attachmentPaths.set(selected, file.path)
  170. await onFile(selected)
  171. }
  172. } finally {
  173. await window.api.releasePickedFiles(result.token)
  174. }
  175. },
  176. getPathForFile(file) {
  177. return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
  178. },
  179. async saveFilePickerDialog(opts) {
  180. return window.api.saveFilePicker({
  181. title: opts?.title ?? t("desktop.dialog.saveFile"),
  182. defaultPath: opts?.defaultPath,
  183. })
  184. },
  185. openLink(url: string) {
  186. window.api.openLink(url)
  187. },
  188. async openPath(path: string, app?: string) {
  189. if (os === "windows") {
  190. const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
  191. return window.api.openPath(path, resolvedApp ?? undefined)
  192. }
  193. return window.api.openPath(path, app)
  194. },
  195. back() {
  196. window.history.back()
  197. },
  198. forward() {
  199. window.history.forward()
  200. },
  201. storage,
  202. updater: {
  203. state: updaterState,
  204. check: () => window.api.updater.check(),
  205. install: () => window.api.updater.install(),
  206. },
  207. exportDebugLogs: () => window.api.exportDebugLogs(),
  208. recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
  209. restart: async () => {
  210. await window.api.killSidecar().catch(() => undefined)
  211. window.api.relaunch()
  212. },
  213. notify: async (title, description, href) => {
  214. const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
  215. if (focused) return
  216. const notification = new Notification(title, {
  217. body: description ?? "",
  218. icon: "https://kirincode.ai/favicon-96x96-v3.png",
  219. })
  220. notification.onclick = () => {
  221. void window.api.showWindow()
  222. void window.api.setWindowFocus()
  223. handleNotificationClick(href)
  224. notification.close()
  225. }
  226. },
  227. fetch: (input, init) => {
  228. if (input instanceof Request) return fetch(input)
  229. return fetch(input, init)
  230. },
  231. getDefaultServer: async () => {
  232. const url = await window.api.getDefaultServerUrl().catch(() => null)
  233. if (!url) return null
  234. return ServerConnection.Key.make(url)
  235. },
  236. setDefaultServer: async (url: string | null) => {
  237. await window.api.setDefaultServerUrl(url)
  238. },
  239. wslServers: wslServersApi,
  240. getDisplayBackend: async () => {
  241. return window.api.getDisplayBackend().catch(() => null)
  242. },
  243. setDisplayBackend: async (backend) => {
  244. await window.api.setDisplayBackend(backend)
  245. },
  246. parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
  247. webviewZoom,
  248. getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
  249. setPinchZoomEnabled,
  250. runDesktopMenuAction,
  251. checkAppExists: async (appName: string) => {
  252. return window.api.checkAppExists(appName)
  253. },
  254. async readClipboardImage() {
  255. const image = await window.api.readClipboardImage().catch(() => null)
  256. if (!image) return null
  257. const blob = new Blob([image.buffer], { type: "image/png" })
  258. return new File([blob], `pasted-image-${Date.now()}.png`, {
  259. type: "image/png",
  260. })
  261. },
  262. }
  263. }
  264. let menuTrigger = null as null | ((id: string) => void)
  265. window.api.onMenuCommand((id) => {
  266. menuTrigger?.(id)
  267. })
  268. listenForDeepLinks()
  269. function LoadingSplash() {
  270. const [dots, setDots] = createSignal("")
  271. const [pc, setPc] = createSignal(20)
  272. onMount(() => {
  273. const i = setInterval(() => {
  274. setDots(d => d.length >= 3 ? "" : d + ".")
  275. setPc(p => p < 90 ? p + 5 : p)
  276. }, 400)
  277. return () => clearInterval(i)
  278. })
  279. return (
  280. <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6">
  281. <Splash class="w-16 h-20 opacity-60 animate-pulse" />
  282. <div class="flex flex-col items-center gap-3">
  283. <span class="text-14-medium text-text-muted">
  284. 正在启动 KirinCode<span class="inline-block w-8 text-left">{dots()}</span>
  285. </span>
  286. <div class="w-40 h-1.5 bg-background-element rounded-full overflow-hidden">
  287. <div class="h-full bg-primary rounded-full transition-all duration-500" style={{width: `${pc()}%`}} />
  288. </div>
  289. </div>
  290. </div>
  291. )
  292. }
  293. function DesktopRoot(props: { windowState: DesktopWindowState }) {
  294. const platform = createPlatform(props.windowState)
  295. const loadLocale = async () => {
  296. const current = await platform.storage?.("kirincode.global.dat").getItem("language")
  297. const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
  298. const raw = current ?? legacy
  299. if (!raw) return
  300. const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
  301. if (!locale) return
  302. const next = normalizeLocale(locale)
  303. if (next !== "en") await loadLocaleDict(next)
  304. return next satisfies Locale
  305. }
  306. const [windowCount] = createResource(() => window.api.getWindowCount())
  307. // Fetch sidecar credentials (available immediately, before health check)
  308. const [sidecar] = createResource(() => window.api.awaitInitialization())
  309. const [defaultServer] = createResource(() => platform.getDefaultServer?.())
  310. const [locale] = createResource(loadLocale)
  311. const router = (props: BaseRouterProps) => (
  312. <DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
  313. )
  314. const onboarding = Promise.withResolvers<void>()
  315. function handleClick(e: MouseEvent) {
  316. const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
  317. if (link?.href) {
  318. e.preventDefault()
  319. platform.openLink(link.href)
  320. }
  321. }
  322. function Inner() {
  323. const cmd = useCommand()
  324. menuTrigger = (id) => cmd.trigger(id)
  325. const theme = useTheme()
  326. createEffect(() => {
  327. theme.themeId()
  328. theme.mode()
  329. const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
  330. if (bg) {
  331. void window.api.setBackgroundColor(bg)
  332. }
  333. })
  334. return null
  335. }
  336. function App() {
  337. const wslServers = useWslServers()
  338. const ready = createMemo(
  339. () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
  340. )
  341. const servers = createMemo(() => {
  342. const data = initializationData(sidecar)
  343. const list: ServerConnection.Any[] = []
  344. if (data) {
  345. list.push({
  346. displayName: "Local Server",
  347. type: "sidecar",
  348. variant: "base",
  349. http: {
  350. url: data.url,
  351. username: data.username ?? undefined,
  352. password: data.password ?? undefined,
  353. },
  354. })
  355. }
  356. list.push(...readyWslConnections(wslServers.data))
  357. return list
  358. })
  359. const effectiveDefaultServer = createMemo(() =>
  360. ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
  361. )
  362. return (
  363. <Show when={ready()} fallback={<LoadingSplash />}>
  364. <Show when={effectiveDefaultServer()} keyed>
  365. {(key) => (
  366. <AppInterface
  367. defaultServer={key}
  368. servers={servers()}
  369. router={router}
  370. startup={onboarding.promise}
  371. serverScoped={
  372. <DesktopFirstLaunchOnboarding
  373. initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
  374. onLoaded={onboarding.resolve}
  375. />
  376. }
  377. >
  378. <Inner />
  379. </AppInterface>
  380. )}
  381. </Show>
  382. </Show>
  383. )
  384. }
  385. onMount(() => {
  386. document.addEventListener("click", handleClick)
  387. onCleanup(() => {
  388. document.removeEventListener("click", handleClick)
  389. })
  390. })
  391. return (
  392. <PlatformProvider value={platform}>
  393. <AppBaseProviders locale={locale.latest}>
  394. <Show when={true}>{(_) => <App />}</Show>
  395. </AppBaseProviders>
  396. </PlatformProvider>
  397. )
  398. }
  399. render(() => {
  400. const [windowState] = createResource(async () => {
  401. const api = window.api as typeof window.api & {
  402. getWindowID?: () => Promise<string>
  403. }
  404. return { id: await api.getWindowID?.() }
  405. })
  406. return (
  407. <Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
  408. {(state) => <DesktopRoot windowState={state} />}
  409. </Show>
  410. )
  411. }, root!)