| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475 |
- // @refresh reload
- import {
- ACCEPTED_FILE_EXTENSIONS,
- AppBaseProviders,
- AppInterface,
- handleNotificationClick,
- loadLocaleDict,
- normalizeLocale,
- type Locale,
- type Platform,
- PlatformProvider,
- ServerConnection,
- useCommand,
- useWslServers,
- } from "@kirincode-ai/app"
- import type { UpdaterState } from "@kirincode-ai/app/updater"
- import * as Sentry from "@sentry/solid"
- import type { AsyncStorage } from "@solid-primitives/storage"
- import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
- import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
- import { render } from "solid-js/web"
- import pkg from "../../package.json"
- import { initI18n, t } from "./i18n"
- import { initializationData, initializationReady } from "./initialization"
- import { DesktopFirstLaunchOnboarding } from "./onboarding"
- import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
- import { availableStartupServer, readyWslConnections } from "./wsl/connections"
- import "./styles.css"
- import { Splash } from "@kirincode-ai/ui/logo"
- import { useTheme } from "@kirincode-ai/ui/theme/context"
- const root = document.getElementById("root")
- if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
- throw new Error(t("error.dev.rootNotFound"))
- }
- if (import.meta.env.VITE_SENTRY_DSN) {
- Sentry.init({
- dsn: import.meta.env.VITE_SENTRY_DSN,
- environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
- release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
- initialScope: {
- tags: {
- platform: "desktop",
- },
- },
- integrations: (integrations) => {
- return integrations.filter(
- (i) =>
- i.name !== "Breadcrumbs" &&
- !(
- import.meta.env.KIRINCODE_CHANNEL === "prod" &&
- (i.name === "GlobalHandlers" || i.name === "BrowserApiErrors")
- ),
- )
- },
- })
- }
- void initI18n()
- const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
- void window.api.updater.subscribe(setUpdaterState)
- const deepLinkEvent = "kirincode:deep-link"
- type DesktopWindowState = {
- id?: string
- }
- const emitDeepLinks = (urls: string[]) => {
- if (urls.length === 0) return
- window.__KIRINCODE__ ??= {}
- const pending = window.__KIRINCODE__.deepLinks ?? []
- window.__KIRINCODE__.deepLinks = [...pending, ...urls]
- window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
- }
- const listenForDeepLinks = () => {
- void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
- return window.api.onDeepLink((urls) => emitDeepLinks(urls))
- }
- function windowLastActiveUrlKey(windowID: string) {
- return `kirincode.desktop.window.${windowID}.last-active-url`
- }
- function getLastActiveUrl(windowID: string) {
- if (typeof localStorage !== "object") return "/"
- try {
- const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
- if (value?.startsWith("/") && !value.startsWith("//")) return value
- } catch {}
- return "/"
- }
- function setLastActiveUrl(windowID: string, value: string) {
- if (typeof localStorage !== "object") return
- try {
- localStorage.setItem(windowLastActiveUrlKey(windowID), value)
- } catch {}
- }
- function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
- const history = createMemoryHistory()
- const initialUrl = getLastActiveUrl(props.windowID)
- if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
- onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
- return <MemoryRouter {...props} history={history} />
- }
- const createPlatform = (windowState: DesktopWindowState): Platform => {
- const attachmentPaths = new WeakMap<File, string>()
- const os = (() => {
- const ua = navigator.userAgent
- if (ua.includes("Mac")) return "macos"
- if (ua.includes("Windows")) return "windows"
- if (ua.includes("Linux")) return "linux"
- return undefined
- })()
- const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
- switch (action) {
- case "view.resetZoom":
- resetZoom()
- return
- case "view.zoomIn":
- zoomIn()
- return
- case "view.zoomOut":
- zoomOut()
- return
- }
- return window.api.runDesktopMenuAction(action)
- }
- const storage = (() => {
- const cache = new Map<string, AsyncStorage>()
- const createStorage = (name: string) => {
- const api: AsyncStorage = {
- getItem: (key: string) => window.api.storeGet(name, key),
- setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
- removeItem: (key: string) => window.api.storeDelete(name, key),
- clear: () => window.api.storeClear(name),
- key: async (index: number) => (await window.api.storeKeys(name))[index],
- getLength: () => window.api.storeLength(name),
- get length() {
- return api.getLength()
- },
- }
- return api
- }
- return (name = "default.dat") => {
- const cached = cache.get(name)
- if (cached) return cached
- const api = createStorage(name)
- cache.set(name, api)
- return api
- }
- })()
- const wslServersApi = os === "windows" ? window.api.wslServers : undefined
- return {
- platform: "desktop",
- os,
- version: pkg.version,
- windowID: windowState.id,
- async openDirectoryPickerDialog(opts) {
- return window.api.openDirectoryPicker({
- multiple: opts?.multiple ?? false,
- title: opts?.title ?? t("desktop.dialog.chooseFolder"),
- })
- },
- async openAttachmentPickerDialog(opts, onFile) {
- const result = await window.api.openFilePicker({
- multiple: opts?.multiple ?? false,
- title: opts?.title ?? t("desktop.dialog.chooseFile"),
- defaultPath: opts?.defaultPath,
- extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
- })
- if (!result) return
- try {
- for (const file of result.files) {
- const selected = new File([await window.api.readPickedFile(result.token, file.path)], file.name)
- attachmentPaths.set(selected, file.path)
- await onFile(selected)
- }
- } finally {
- await window.api.releasePickedFiles(result.token)
- }
- },
- getPathForFile(file) {
- return attachmentPaths.get(file) ?? window.api.getPathForFile(file)
- },
- async saveFilePickerDialog(opts) {
- return window.api.saveFilePicker({
- title: opts?.title ?? t("desktop.dialog.saveFile"),
- defaultPath: opts?.defaultPath,
- })
- },
- openLink(url: string) {
- window.api.openLink(url)
- },
- async openPath(path: string, app?: string) {
- if (os === "windows") {
- const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
- return window.api.openPath(path, resolvedApp ?? undefined)
- }
- return window.api.openPath(path, app)
- },
- back() {
- window.history.back()
- },
- forward() {
- window.history.forward()
- },
- storage,
- updater: {
- state: updaterState,
- check: () => window.api.updater.check(),
- install: () => window.api.updater.install(),
- },
- exportDebugLogs: () => window.api.exportDebugLogs(),
- recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
- restart: async () => {
- await window.api.killSidecar().catch(() => undefined)
- window.api.relaunch()
- },
- notify: async (title, description, href) => {
- const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
- if (focused) return
- const notification = new Notification(title, {
- body: description ?? "",
- icon: "https://kirincode.ai/favicon-96x96-v3.png",
- })
- notification.onclick = () => {
- void window.api.showWindow()
- void window.api.setWindowFocus()
- handleNotificationClick(href)
- notification.close()
- }
- },
- fetch: (input, init) => {
- if (input instanceof Request) return fetch(input)
- return fetch(input, init)
- },
- getDefaultServer: async () => {
- const url = await window.api.getDefaultServerUrl().catch(() => null)
- if (!url) return null
- return ServerConnection.Key.make(url)
- },
- setDefaultServer: async (url: string | null) => {
- await window.api.setDefaultServerUrl(url)
- },
- wslServers: wslServersApi,
- getDisplayBackend: async () => {
- return window.api.getDisplayBackend().catch(() => null)
- },
- setDisplayBackend: async (backend) => {
- await window.api.setDisplayBackend(backend)
- },
- parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
- webviewZoom,
- getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
- setPinchZoomEnabled,
- runDesktopMenuAction,
- checkAppExists: async (appName: string) => {
- return window.api.checkAppExists(appName)
- },
- async readClipboardImage() {
- const image = await window.api.readClipboardImage().catch(() => null)
- if (!image) return null
- const blob = new Blob([image.buffer], { type: "image/png" })
- return new File([blob], `pasted-image-${Date.now()}.png`, {
- type: "image/png",
- })
- },
- }
- }
- let menuTrigger = null as null | ((id: string) => void)
- window.api.onMenuCommand((id) => {
- menuTrigger?.(id)
- })
- listenForDeepLinks()
- function LoadingSplash() {
- const [dots, setDots] = createSignal("")
- const [pc, setPc] = createSignal(20)
- onMount(() => {
- const i = setInterval(() => {
- setDots(d => d.length >= 3 ? "" : d + ".")
- setPc(p => p < 90 ? p + 5 : p)
- }, 400)
- return () => clearInterval(i)
- })
- return (
- <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6">
- <Splash class="w-16 h-20 opacity-60 animate-pulse" />
- <div class="flex flex-col items-center gap-3">
- <span class="text-14-medium text-text-muted">
- 正在启动 KirinCode<span class="inline-block w-8 text-left">{dots()}</span>
- </span>
- <div class="w-40 h-1.5 bg-background-element rounded-full overflow-hidden">
- <div class="h-full bg-primary rounded-full transition-all duration-500" style={{width: `${pc()}%`}} />
- </div>
- </div>
- </div>
- )
- }
- function DesktopRoot(props: { windowState: DesktopWindowState }) {
- const platform = createPlatform(props.windowState)
- const loadLocale = async () => {
- const current = await platform.storage?.("kirincode.global.dat").getItem("language")
- const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
- const raw = current ?? legacy
- if (!raw) return
- const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
- if (!locale) return
- const next = normalizeLocale(locale)
- if (next !== "en") await loadLocaleDict(next)
- return next satisfies Locale
- }
- const [windowCount] = createResource(() => window.api.getWindowCount())
- // Fetch sidecar credentials (available immediately, before health check)
- const [sidecar] = createResource(() => window.api.awaitInitialization())
- const [defaultServer] = createResource(() => platform.getDefaultServer?.())
- const [locale] = createResource(loadLocale)
- const router = (props: BaseRouterProps) => (
- <DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
- )
- const onboarding = Promise.withResolvers<void>()
- function handleClick(e: MouseEvent) {
- const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
- if (link?.href) {
- e.preventDefault()
- platform.openLink(link.href)
- }
- }
- function Inner() {
- const cmd = useCommand()
- menuTrigger = (id) => cmd.trigger(id)
- const theme = useTheme()
- createEffect(() => {
- theme.themeId()
- theme.mode()
- const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
- if (bg) {
- void window.api.setBackgroundColor(bg)
- }
- })
- return null
- }
- function App() {
- const wslServers = useWslServers()
- const ready = createMemo(
- () => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
- )
- const servers = createMemo(() => {
- const data = initializationData(sidecar)
- const list: ServerConnection.Any[] = []
- if (data) {
- list.push({
- displayName: "Local Server",
- type: "sidecar",
- variant: "base",
- http: {
- url: data.url,
- username: data.username ?? undefined,
- password: data.password ?? undefined,
- },
- })
- }
- list.push(...readyWslConnections(wslServers.data))
- return list
- })
- const effectiveDefaultServer = createMemo(() =>
- ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
- )
- return (
- <Show when={ready()} fallback={<LoadingSplash />}>
- <Show when={effectiveDefaultServer()} keyed>
- {(key) => (
- <AppInterface
- defaultServer={key}
- servers={servers()}
- router={router}
- startup={onboarding.promise}
- serverScoped={
- <DesktopFirstLaunchOnboarding
- initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
- onLoaded={onboarding.resolve}
- />
- }
- >
- <Inner />
- </AppInterface>
- )}
- </Show>
- </Show>
- )
- }
- onMount(() => {
- document.addEventListener("click", handleClick)
- onCleanup(() => {
- document.removeEventListener("click", handleClick)
- })
- })
- return (
- <PlatformProvider value={platform}>
- <AppBaseProviders locale={locale.latest}>
- <Show when={true}>{(_) => <App />}</Show>
- </AppBaseProviders>
- </PlatformProvider>
- )
- }
- render(() => {
- const [windowState] = createResource(async () => {
- const api = window.api as typeof window.api & {
- getWindowID?: () => Promise<string>
- }
- return { id: await api.getWindowID?.() }
- })
- return (
- <Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
- {(state) => <DesktopRoot windowState={state} />}
- </Show>
- )
- }, root!)
|