[codex] Reduce desktop release startup work (#269)

This commit is contained in:
Wes
2026-04-08 11:13:14 -07:00
committed by GitHub
parent 8bfce0975b
commit 7e4bcfb8c2
16 changed files with 315 additions and 197 deletions
+6
View File
@@ -32,6 +32,12 @@ if (publicKey && endpoint) {
const missing = [];
if (!publicKey) missing.push("SPROUT_UPDATER_PUBLIC_KEY");
if (!endpoint) missing.push("SPROUT_UPDATER_ENDPOINT");
if (releaseConfig.plugins) {
delete releaseConfig.plugins.updater;
if (Object.keys(releaseConfig.plugins).length === 0) {
delete releaseConfig.plugins;
}
}
console.log(`Updater config skipped (missing: ${missing.join(", ")})`);
}
+16
View File
@@ -1,6 +1,9 @@
fn main() {
println!("cargo:rerun-if-env-changed=SPROUT_RELAY_URL");
println!("cargo:rerun-if-env-changed=SPROUT_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=SPROUT_UPDATER_ENDPOINT");
println!("cargo:rustc-check-cfg=cfg(sprout_updater_enabled)");
if let Ok(relay_url) = std::env::var("SPROUT_RELAY_URL") {
println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_URL={relay_url}");
@@ -10,5 +13,18 @@ fn main() {
println!("cargo:rustc-env=SPROUT_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
let updater_public_key = std::env::var("SPROUT_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let updater_endpoint = std::env::var("SPROUT_UPDATER_ENDPOINT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if updater_public_key.is_some() && updater_endpoint.is_some() {
println!("cargo:rustc-cfg=sprout_updater_enabled");
}
tauri_build::build()
}
+7 -1
View File
@@ -288,13 +288,19 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init());
// The updater config is only generated for signed release builds.
// Only register the updater in release builds that were compiled with a
// real updater configuration. Local unsigned builds omit that config and
// should still launch for debugging.
#[cfg(sprout_updater_enabled)]
let builder = if cfg!(debug_assertions) {
builder
} else {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
#[cfg(not(sprout_updater_enabled))]
let builder = builder;
let shutdown_started = Arc::new(AtomicBool::new(false));
let restore_shutdown_started = Arc::clone(&shutdown_started);
let app = builder
-5
View File
@@ -30,11 +30,6 @@
"csp": null
}
},
"plugins": {
"updater": {
"endpoints": []
}
},
"bundle": {
"active": true,
"targets": "all",
+52 -28
View File
@@ -29,7 +29,6 @@ import {
import { usePresenceSession } from "@/features/presence/hooks";
import { useProfileQuery } from "@/features/profile/hooks";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
import { SettingsScreen } from "@/features/settings/ui/SettingsScreen";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
@@ -46,6 +45,11 @@ import {
type AppView = "home" | "channel" | "agents" | "workflows";
const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
});
function toSearchHit(target: DesktopNotificationTarget): SearchHit | null {
if (!target.eventId) {
return null;
@@ -250,14 +254,32 @@ export function AppShell() {
React.useEffect(() => {
let isCancelled = false;
void relayClient.preconnect().catch((error) => {
if (!isCancelled) {
console.error("Failed to preconnect to relay", error);
const startPreconnect = () => {
if (isCancelled) {
return;
}
});
void relayClient.preconnect().catch((error) => {
if (!isCancelled) {
console.error("Failed to preconnect to relay", error);
}
});
};
if ("requestIdleCallback" in window) {
const idleId = window.requestIdleCallback(startPreconnect, {
timeout: 1_500,
});
return () => {
isCancelled = true;
window.cancelIdleCallback(idleId);
};
}
const timeoutId = globalThis.setTimeout(startPreconnect, 250);
return () => {
isCancelled = true;
globalThis.clearTimeout(timeoutId);
};
}, []);
@@ -486,29 +508,31 @@ export function AppShell() {
/>
{settingsOpen ? (
<SettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
</AppShellProvider>
@@ -0,0 +1,96 @@
import * as React from "react";
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteScreenProps = {
channelId: string;
selectedPostId: string | null;
targetMessageId: string | null;
targetReplyId: string | null;
};
export function ChannelRouteScreen({
channelId,
selectedPostId,
targetMessageId,
targetReplyId,
}: ChannelRouteScreenProps) {
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const channels = channelsQuery.data ?? [];
const activeChannel =
channels.find((channel) => channel.id === channelId) ?? null;
const [targetMessageEvent, setTargetMessageEvent] =
React.useState<RelayEvent | null>(() =>
getCachedSearchHitEvent(targetMessageId),
);
React.useEffect(() => {
let isCancelled = false;
if (!targetMessageId || selectedPostId) {
setTargetMessageEvent(null);
return () => {
isCancelled = true;
};
}
setTargetMessageEvent(getCachedSearchHitEvent(targetMessageId));
void getEventById(targetMessageId)
.then((event) => {
if (!isCancelled) {
setTargetMessageEvent(event);
}
})
.catch((error) => {
if (!isCancelled) {
console.error(
"Failed to load route target event",
targetMessageId,
error,
);
}
});
return () => {
isCancelled = true;
};
}, [selectedPostId, targetMessageId]);
if (channelsQuery.isPending && !activeChannel) {
return (
<ViewLoadingFallback
includeHeader
kind={selectedPostId ? "forum" : "channel"}
/>
);
}
return (
<ChannelScreen
activeChannel={activeChannel}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {
void closeForumPost(channelId);
}}
onSelectForumPost={(postId) => {
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId}
targetMessageEvent={targetMessageEvent}
targetMessageId={targetMessageId}
/>
);
}
@@ -0,0 +1,27 @@
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
type WorkflowsRouteScreenProps = {
selectedWorkflowId: string | null;
};
export function WorkflowsRouteScreen({
selectedWorkflowId,
}: WorkflowsRouteScreenProps) {
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
return (
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
onSelectWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
selectedWorkflowId={selectedWorkflowId}
/>
);
}
@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ChannelRouteScreen } from "@/app/routes/channels.$channelId";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ForumPostRouteSearch = {
replyId?: string;
@@ -22,16 +23,25 @@ export const Route = createFileRoute("/channels/$channelId/posts/$postId")({
component: ForumPostRouteComponent,
});
const ChannelRouteScreen = React.lazy(async () => {
const module = await import("./ChannelRouteScreen");
return { default: module.ChannelRouteScreen };
});
function ForumPostRouteComponent() {
const { channelId, postId } = Route.useParams();
const search = Route.useSearch();
return (
<ChannelRouteScreen
channelId={channelId}
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
/>
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="forum" />}
>
<ChannelRouteScreen
channelId={channelId}
selectedPostId={postId}
targetMessageId={null}
targetReplyId={search.replyId ?? null}
/>
</React.Suspense>
);
}
+14 -91
View File
@@ -1,14 +1,6 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ChannelRouteSearch = {
@@ -31,94 +23,25 @@ export const Route = createFileRoute("/channels/$channelId")({
component: ChannelRouteComponent,
});
export function ChannelRouteScreen({
channelId,
selectedPostId,
targetMessageId,
targetReplyId,
}: {
channelId: string;
selectedPostId: string | null;
targetMessageId: string | null;
targetReplyId: string | null;
}) {
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const profileQuery = useProfileQuery();
const channels = channelsQuery.data ?? [];
const activeChannel =
channels.find((channel) => channel.id === channelId) ?? null;
const [targetMessageEvent, setTargetMessageEvent] =
React.useState<RelayEvent | null>(() =>
getCachedSearchHitEvent(targetMessageId),
);
React.useEffect(() => {
let isCancelled = false;
if (!targetMessageId || selectedPostId) {
setTargetMessageEvent(null);
return () => {
isCancelled = true;
};
}
setTargetMessageEvent(getCachedSearchHitEvent(targetMessageId));
void getEventById(targetMessageId)
.then((event) => {
if (!isCancelled) {
setTargetMessageEvent(event);
}
})
.catch((error) => {
if (!isCancelled) {
console.error(
"Failed to load route target event",
targetMessageId,
error,
);
}
});
return () => {
isCancelled = true;
};
}, [selectedPostId, targetMessageId]);
if (channelsQuery.isPending && !activeChannel) {
return <ViewLoadingFallback includeHeader kind="channel" />;
}
return (
<ChannelScreen
activeChannel={activeChannel}
currentIdentity={identityQuery.data}
currentProfile={profileQuery.data}
onCloseForumPost={() => {
void closeForumPost(channelId);
}}
onSelectForumPost={(postId) => {
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetForumReplyId={targetReplyId}
targetMessageEvent={targetMessageEvent}
targetMessageId={targetMessageId}
/>
);
}
const ChannelRouteScreen = React.lazy(async () => {
const module = await import("./ChannelRouteScreen");
return { default: module.ChannelRouteScreen };
});
function ChannelRouteComponent() {
const { channelId } = Route.useParams();
const search = Route.useSearch();
return (
<ChannelRouteScreen
channelId={channelId}
selectedPostId={null}
targetMessageId={search.messageId ?? null}
targetReplyId={null}
/>
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
>
<ChannelRouteScreen
channelId={channelId}
selectedPostId={null}
targetMessageId={search.messageId ?? null}
targetReplyId={null}
/>
</React.Suspense>
);
}
@@ -1,13 +1,25 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { WorkflowsRouteScreen } from "@/app/routes/workflows";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows/$workflowId")({
component: WorkflowDetailRouteComponent,
});
const WorkflowsRouteScreen = React.lazy(async () => {
const module = await import("./WorkflowsRouteScreen");
return { default: module.WorkflowsRouteScreen };
});
function WorkflowDetailRouteComponent() {
const { workflowId } = Route.useParams();
return <WorkflowsRouteScreen selectedWorkflowId={workflowId} />;
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="workflows" />}
>
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
</React.Suspense>
);
}
+13 -25
View File
@@ -1,35 +1,23 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
export const Route = createFileRoute("/workflows")({
component: WorkflowsRouteComponent,
});
export function WorkflowsRouteScreen({
selectedWorkflowId,
}: {
selectedWorkflowId: string | null;
}) {
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);
return (
<WorkflowsScreen
channels={memberChannels}
onCloseWorkflow={closeWorkflowDetail}
onSelectWorkflow={(workflowId) => {
void goWorkflow(workflowId);
}}
selectedWorkflowId={selectedWorkflowId}
/>
);
}
const WorkflowsRouteScreen = React.lazy(async () => {
const module = await import("./WorkflowsRouteScreen");
return { default: module.WorkflowsRouteScreen };
});
function WorkflowsRouteComponent() {
return <WorkflowsRouteScreen selectedWorkflowId={null} />;
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="workflows" />}
>
<WorkflowsRouteScreen selectedWorkflowId={null} />
</React.Suspense>
);
}
+1 -1
View File
@@ -56,7 +56,7 @@ function getNextZoomFactor(action: ZoomAction, zoomFactor: number) {
export function useWebviewZoomShortcuts() {
const zoomFactorRef = React.useRef(DEFAULT_ZOOM_FACTOR);
React.useEffect(() => {
React.useLayoutEffect(() => {
const webview = getCurrentWebview();
function handleKeyDown(event: KeyboardEvent) {
+43 -37
View File
@@ -6,7 +6,11 @@ import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { HomeFeedResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { FeedSection } from "./FeedSection";
const FeedSection = React.lazy(async () => {
const module = await import("./FeedSection");
return { default: module.FeedSection };
});
type FeedFilter = "all" | "mention" | "needs_action";
@@ -117,42 +121,44 @@ export function HomeView({
))}
</div>
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
) : null}
{showNeedsAction ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
</div>
<React.Suspense fallback={null}>
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
{showMentions ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="When someone mentions you, it will land here."
emptyTitle="No mentions right now"
icon={AtSign}
items={feed.feed.mentions}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={false}
title="Mentions"
/>
) : null}
{showNeedsAction ? (
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
profiles={feedProfiles}
doneSet={doneSet}
emptyDescription="Approval requests and reminders will appear here."
emptyTitle="Nothing needs action"
icon={CircleAlert}
items={feed.feed.needsAction}
onMarkDone={markDone}
onOpenChannel={onOpenChannel}
onUndoDone={undoDone}
showDoneAction={true}
title="Needs Action"
/>
) : null}
</div>
</React.Suspense>
</div>
</div>
);
+4
View File
@@ -4,6 +4,9 @@ import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
async function createStream(
page: import("@playwright/test").Page,
channelName: string,
@@ -156,6 +159,7 @@ async function getLoggedNotificationCount(
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});
+3
View File
@@ -280,12 +280,14 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await page.keyboard.press(
process.platform === "darwin" ? "Meta+," : "Control+,",
);
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page.getByTestId("settings-nav-appearance")).toBeVisible();
await page.getByTestId("settings-nav-appearance").click();
// Default theme is catppuccin-macchiato (dark)
@@ -347,6 +349,7 @@ test("opens settings with the keyboard shortcut and updates theme", async ({
test("supports webview zoom keyboard shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await page.keyboard.press(
process.platform === "darwin" ? "Meta+Shift+Equal" : "Control+Shift+Equal",
+2
View File
@@ -5,6 +5,7 @@ import { assertRelaySeeded } from "../helpers/seed";
const isCi = Boolean(process.env.CI);
const relayDeliveryTimeoutMs = isCi ? 15_000 : 5_000;
const relaySeedHookTimeoutMs = isCi ? 90_000 : 30_000;
async function expectTimelineToContain(page: Page, text: string) {
await expect(page.getByTestId("message-timeline")).toContainText(text, {
@@ -94,6 +95,7 @@ async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
}
test.beforeAll(async () => {
test.setTimeout(relaySeedHookTimeoutMs);
await assertRelaySeeded();
});