fix(desktop): gate first launch onboarding (#34930)

This commit is contained in:
Brendan Allan 2026-07-06 16:20:15 +08:00 committed by GitHub
commit dffecb6478
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 204 additions and 43 deletions

View file

@ -19,6 +19,7 @@ import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
import {
getDefaultServerUrl,
preferAppEnv,
@ -277,6 +278,8 @@ const main = Effect.gen(function* () {
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding,
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),

View file

@ -27,6 +27,8 @@ type Deps = {
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
@ -50,6 +52,10 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending())
ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
)
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),

View file

@ -0,0 +1,28 @@
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { app } from "electron"
import { getStore } from "./store"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
import { write as writeLog } from "./logging"
const DEFAULT_PROJECT_DIR = "New OpenCode Project"
export function isFirstLaunchOnboardingPending() {
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
writeLog("onboarding", "first launch onboarding pending checked", { pending })
return pending
}
export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
if (!isFirstLaunchOnboardingPending()) {
writeLog("onboarding", "first launch onboarding already completed")
return null
}
const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
if (defaultProject) await mkdir(defaultProject, { recursive: true })
getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
return defaultProject
}

View file

@ -1,5 +1,6 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds"

View file

@ -59,6 +59,9 @@ const api: ElectronAPI = {
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
finishFirstLaunchOnboarding: (createDefaultProject) =>
ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),

View file

@ -49,6 +49,8 @@ export type ElectronAPI = {
consumeInitialDeepLinks: () => Promise<string[]>
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
isFirstLaunchOnboardingPending: () => Promise<boolean>
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>

View file

@ -23,6 +23,7 @@ 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"
@ -347,6 +348,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
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
@ -400,12 +402,22 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) {
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}>
<AppInterface
defaultServer={key}
servers={servers()}
router={router}
startup={onboarding.promise}
serverScoped={
<DesktopFirstLaunchOnboarding
initialUrl={getLastActiveUrl(platform.windowID ?? "browser")}
onLoaded={onboarding.resolve}
/>
}
>
<Inner />
</AppInterface>
)}

View file

@ -0,0 +1,79 @@
import {
ServerConnection,
useLayout,
useProviders,
useServer,
useServerSDK,
useServerSync,
useTabs,
} from "@opencode-ai/app"
import { onMount, startTransition } from "solid-js"
export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoaded: () => void }) {
const server = useServer()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const layout = useLayout()
const providers = useProviders()
const tabs = useTabs()
onMount(() => {
void runFirstLaunchOnboarding().finally(props.onLoaded)
})
async function runFirstLaunchOnboarding() {
try {
await Promise.all(
[server.ready.promise, layout.ready.promise, tabs.ready.promise, tabs.recentReady.promise].map(
(p) => p ?? Promise.resolve(),
),
)
if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending()
if (!pending) return
const sessions = await serverSDK()
.client.session.list()
.then((x) => x.data ?? [])
.catch(() => undefined)
const connectedProviders = providers.connected()
const paidProviders = providers.paid()
const persistedProjects = layout.projects.list()
const shouldTrigger =
props.initialUrl === "/" &&
sessions?.length === 0 &&
paidProviders.length === 0 &&
persistedProjects.length === 0 &&
tabs.store.length === 0 &&
server.list.every(ServerConnection.builtin)
console.info("[desktop-onboarding] first launch onboarding evaluated", {
pending,
shouldTrigger,
initialUrl: props.initialUrl,
sessions: sessions?.length,
connectedProviders: connectedProviders.length,
paidProviders: paidProviders.length,
serverProjects: serverSync().data.project.length,
persistedProjects: persistedProjects.length,
tabs: tabs.store.length,
servers: server.list.map(ServerConnection.key),
})
const directory = await window.api.finishFirstLaunchOnboarding(shouldTrigger)
if (!shouldTrigger || !directory) return
console.info("[desktop-onboarding] starting first launch draft", { directory })
server.projects.open(directory)
server.projects.touch(directory)
await startTransition(() => {
tabs.newDraft({ server: server.key, directory })
})
} catch (error) {
console.error("[desktop-onboarding] first launch onboarding failed", error)
}
}
return null
}