Unverified 提交 9dbc0630 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

App screenshot (#3134)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3134" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 90109b00
import fs from "node:fs";
import path from "node:path";
import { expect } from "@playwright/test";
import { test, Timeout } from "./helpers/test_helper";
const SCREENSHOT_FILENAME_REGEX = /^[0-9a-f]{40}\.png$/;
test("captures an app screenshot after the first generated commit", async ({
po,
}) => {
await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=write-index");
await po.previewPanel.expectPreviewIframeIsVisible();
const appPath = await po.appManagement.getCurrentAppPath();
const screenshotDir = path.join(appPath, ".dyad", "screenshot");
await expect(async () => {
const entries = fs.existsSync(screenshotDir)
? fs.readdirSync(screenshotDir)
: [];
const screenshots = entries.filter((entry) =>
SCREENSHOT_FILENAME_REGEX.test(entry),
);
expect(screenshots.length).toBeGreaterThan(0);
const size = fs.statSync(path.join(screenshotDir, screenshots[0])).size;
expect(size).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
await po.appManagement.getTitleBarAppNameButton().click();
await expect(po.page.getByRole("img", { name: /Preview of/ })).toBeVisible({
timeout: Timeout.MEDIUM,
});
});
......@@ -23,3 +23,5 @@ export const screenshotDataUrlAtom = atom<string | null>(null);
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
new Map(),
);
export const pendingScreenshotAppIdAtom = atom<number | null>(null);
import { useNavigate } from "@tanstack/react-router";
import { PlusCircle, Search } from "lucide-react";
import { useAtom, useSetAtom } from "jotai";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import {
SidebarGroup,
......@@ -9,15 +9,15 @@ import {
SidebarMenu,
} from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useOpenApp } from "@/hooks/useOpenApp";
import { useMemo, useState } from "react";
import { AppSearchDialog } from "./AppSearchDialog";
import { AppItem } from "./appItem";
export function AppList({ show }: { show?: boolean }) {
const navigate = useNavigate();
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const openApp = useOpenApp();
const { apps, loading, error } = useLoadApps();
// search dialog state
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
......@@ -49,13 +49,8 @@ export function AppList({ show }: { show?: boolean }) {
}
const handleAppClick = (id: number) => {
setSelectedAppId(id);
setSelectedChatId(null);
setIsSearchDialogOpen(false);
navigate({
to: "/",
search: { appId: id },
});
openApp(id);
};
const handleNewApp = () => {
......
import { useEffect, useState } from "react";
import type { ListedApp } from "@/ipc/types/app";
interface AppShowcaseCardProps {
app: ListedApp;
thumbnailUrl: string | null;
onClick: (appId: number) => void;
}
function getInitial(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "?";
const codePoint = trimmed.codePointAt(0);
return codePoint
? String.fromCodePoint(codePoint).toUpperCase()
: trimmed[0].toUpperCase();
}
export function AppShowcaseCard({
app,
thumbnailUrl,
onClick,
}: AppShowcaseCardProps) {
const [imageBroken, setImageBroken] = useState(false);
useEffect(() => {
setImageBroken(false);
}, [thumbnailUrl]);
const showImage = thumbnailUrl && !imageBroken;
return (
<button
type="button"
onClick={() => onClick(app.id)}
title={app.name}
data-testid={`app-showcase-card-${app.name}`}
className="group relative w-full aspect-[4/3] rounded-xl overflow-hidden border border-border bg-muted hover:border-primary/40 hover:shadow-md transition-all duration-200 active:scale-[0.99] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
{showImage ? (
<img
src={thumbnailUrl!}
alt=""
loading="lazy"
onError={() => setImageBroken(true)}
className="absolute inset-0 w-full h-full object-cover object-top"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/10 to-primary/30">
<span className="text-3xl font-semibold text-primary/80">
{getInitial(app.name)}
</span>
</div>
)}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/50 to-transparent pt-8 pb-2.5 px-3">
<p className="text-sm font-semibold text-white truncate text-left">
{app.name}
</p>
</div>
</button>
);
}
import { useMemo } from "react";
import { useNavigate } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useOpenApp } from "@/hooks/useOpenApp";
import { AppShowcaseCard } from "@/components/AppShowcaseCard";
import { useAppThumbnails } from "@/hooks/useAppThumbnails";
import { sortAppsForShowcase } from "@/lib/sortApps";
const MAX_FEATURED_APPS = 10;
export function FeaturedAppShowcase() {
const { apps } = useLoadApps();
const openApp = useOpenApp();
const navigate = useNavigate();
const sortedApps = useMemo(() => sortAppsForShowcase(apps), [apps]);
const featured = useMemo(
() => sortedApps.slice(0, MAX_FEATURED_APPS),
[sortedApps],
);
const featuredIds = useMemo(() => featured.map((a) => a.id), [featured]);
const thumbnailByAppId = useAppThumbnails(featuredIds);
if (sortedApps.length === 0) {
return null;
}
const hasMore = sortedApps.length > MAX_FEATURED_APPS;
return (
<section
data-testid="featured-app-showcase"
className="w-full max-w-6xl mx-auto px-8 mt-8 mb-12"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Featured Apps </h2>
<button
type="button"
onClick={() => navigate({ to: "/apps" })}
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
See more
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="relative">
<div
tabIndex={0}
role="region"
aria-label="App showcase"
className="flex gap-4 overflow-x-auto scrollbar-on-hover pb-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded-lg"
>
{featured.map((app) => (
<div key={app.id} className="w-56 flex-shrink-0">
<AppShowcaseCard
app={app}
thumbnailUrl={thumbnailByAppId.get(app.id) ?? null}
onClick={openApp}
/>
</div>
))}
{hasMore && (
<button
type="button"
onClick={() => navigate({ to: "/apps" })}
className="flex flex-col items-center justify-center w-56 aspect-[4/3] flex-shrink-0 rounded-xl border border-dashed border-border bg-(--background-lighter) hover:border-primary/40 hover:bg-(--background-lightest) transition-all duration-200 active:scale-[0.99]"
>
<ChevronRight className="w-6 h-6 text-muted-foreground mb-1" />
<span className="text-sm font-medium">See more</span>
</button>
)}
</div>
{/* Trailing fade hints that more cards exist off-screen, since the
scrollbar is hidden until hover and the cards fill the width. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-background to-transparent rounded-r-lg"
/>
</div>
</section>
);
}
......@@ -109,8 +109,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const handleImportFromUrl = async () => {
setImporting(true);
try {
const match = extractRepoNameFromUrl(url);
const repoName = match ? match[2] : "";
const repoName = extractRepoNameFromUrl(url) ?? "";
const appName = githubAppName.trim() || repoName;
const result = await ipc.github.cloneRepoFromUrl({
url,
......@@ -131,6 +130,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId,
appId: result.app.id,
});
}
onClose();
......@@ -167,6 +167,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId,
appId: result.app.id,
});
}
onClose();
......@@ -266,6 +267,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
streamMessage({
prompt: AI_RULES_PROMPT,
chatId: result.chatId,
appId: result.appId,
});
}
setSelectedAppId(result.appId);
......
......@@ -793,6 +793,7 @@ export const SecurityPanel = () => {
await streamMessage({
prompt: "/security-review",
chatId,
appId: selectedAppId,
onSettled: () => {
refetch();
setIsRunningReview(false);
......@@ -829,6 +830,7 @@ ${finding.description}`;
await streamMessage({
prompt,
chatId,
appId: selectedAppId,
onSettled: () => {
setFixingFindingKey(null);
},
......@@ -904,6 +906,7 @@ ${issuesList}`;
await streamMessage({
prompt,
chatId,
appId: selectedAppId,
onSettled: () => {
setIsFixingSelected(false);
setSelectedFindings(new Set());
......
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { ipc } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
/**
* Fetches thumbnails for the given app ids and exposes them as a
* `Map<appId, thumbnailUrl>`. Pass the full, stable set of app ids (not a
* filtered subset) so the underlying IPC call can be cached across searches
* and shared between consumers.
*/
export function useAppThumbnails(appIds: number[]): Map<number, string | null> {
const sortedIds = useMemo(() => [...appIds].sort((a, b) => a - b), [appIds]);
const { data } = useQuery({
queryKey: [...queryKeys.apps.thumbnails, sortedIds],
queryFn: () => ipc.app.listAppThumbnails({ appIds: sortedIds }),
enabled: sortedIds.length > 0,
});
return useMemo(() => {
const map = new Map<number, string | null>();
for (const t of data?.thumbnails ?? []) {
map.set(t.appId, t.thumbnailUrl);
}
return map;
}, [data]);
}
......@@ -2,9 +2,12 @@ import { ipc } from "@/ipc/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
import { queryKeys } from "@/lib/queryKeys";
import { useSetAtom } from "jotai";
import { pendingScreenshotAppIdAtom } from "@/atoms/previewAtoms";
export function useCommitChanges() {
const queryClient = useQueryClient();
const setPendingScreenshotAppId = useSetAtom(pendingScreenshotAppIdAtom);
const { mutateAsync: commitChanges, isPending: isCommitting } = useMutation({
mutationFn: async ({
......@@ -18,6 +21,7 @@ export function useCommitChanges() {
},
onSuccess: (_, { appId }) => {
showSuccess("Changes committed successfully");
setPendingScreenshotAppId(appId);
// Invalidate uncommitted files query
queryClient.invalidateQueries({
queryKey: queryKeys.uncommittedFiles.byApp({ appId }),
......
import { useSetAtom } from "jotai";
import { useNavigate } from "@tanstack/react-router";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
export function useOpenApp() {
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate();
return (appId: number) => {
setSelectedAppId(appId);
setSelectedChatId(null);
navigate({ to: "/", search: { appId } });
};
}
......@@ -18,6 +18,7 @@ import {
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { pendingScreenshotAppIdAtom } from "@/atoms/previewAtoms";
import type { ChatResponseEnd, App, Chat } from "@/ipc/types";
import type { ChatSummary } from "@/lib/schemas";
import { useChats } from "./useChats";
......@@ -30,7 +31,7 @@ import { useRunApp } from "./useRunApp";
import { useCountTokens } from "./useCountTokens";
import { useUserBudgetInfo } from "./useUserBudgetInfo";
import { usePostHog } from "posthog-js/react";
import { useCheckProblems } from "./useCheckProblems";
import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
......@@ -62,7 +63,7 @@ export function useStreamChat({
const { refreshVersions } = useVersions(selectedAppId);
const { refreshAppIframe } = useRunApp();
const { refetchUserBudget } = useUserBudgetInfo();
const { checkProblems } = useCheckProblems(selectedAppId);
const setPendingScreenshotAppId = useSetAtom(pendingScreenshotAppIdAtom);
const { settings } = useSettings();
const setRecentStreamChatIds = useSetAtom(recentStreamChatIdsAtom);
const [queuedMessagesById, setQueuedMessagesById] = useAtom(
......@@ -88,6 +89,7 @@ export function useStreamChat({
async ({
prompt,
chatId,
appId,
redo,
attachments,
selectedComponents,
......@@ -96,6 +98,7 @@ export function useStreamChat({
}: {
prompt: string;
chatId: number;
appId?: number;
redo?: boolean;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
......@@ -169,6 +172,28 @@ export function useStreamChat({
}
let hasIncrementedStreamCount = false;
// Resolve the target app from the chat itself when the caller didn't
// pass one. Falling back to `selectedAppId` is wrong for background
// queue processing, where the user may have switched to a different
// app while a queued message streams for the original chat.
let resolvedAppIdFromChat: number | null = null;
if (appId === undefined) {
// queryKeys.chats.all matches detail/search caches too (non-array data),
// so guard against non-array entries before calling .find.
const chatsCaches = queryClient.getQueriesData<ChatSummary[]>({
queryKey: queryKeys.chats.all,
});
for (const [, cachedChats] of chatsCaches) {
if (!Array.isArray(cachedChats)) continue;
const found = cachedChats.find((c) => c.id === chatId);
if (found) {
resolvedAppIdFromChat = found.appId;
break;
}
}
}
const targetAppId =
appId ?? resolvedAppIdFromChat ?? selectedAppId ?? null;
try {
const cachedChat =
requestedChatMode === null
......@@ -290,10 +315,10 @@ export function useStreamChat({
!document.hasFocus()
) {
const app = queryClient.getQueryData<App | null>(
queryKeys.apps.detail({ appId: selectedAppId }),
queryKeys.apps.detail({ appId: targetAppId ?? null }),
);
const chats = queryClient.getQueryData<ChatSummary[]>(
queryKeys.chats.list({ appId: selectedAppId }),
queryKeys.chats.list({ appId: targetAppId ?? null }),
);
const chat = chats?.find((c) => c.id === chatId);
const appName = app?.name ?? "Dyad";
......@@ -313,8 +338,15 @@ export function useStreamChat({
setIsPreviewOpen(true);
}
refreshAppIframe();
if (settings?.enableAutoFixProblems) {
checkProblems();
if (targetAppId) {
setPendingScreenshotAppId(targetAppId);
}
if (settings?.enableAutoFixProblems && targetAppId) {
queryClient.invalidateQueries({
queryKey: queryKeys.problems.byApp({
appId: targetAppId,
}),
});
}
}
if (response.extraFiles) {
......@@ -449,7 +481,6 @@ export function useStreamChat({
setIsStreamingById,
setIsPreviewOpen,
setStreamCompletedSuccessfullyById,
checkProblems,
selectedAppId,
refetchUserBudget,
settings,
......
import { ipcMain, app, dialog } from "electron";
import { db, getDatabasePath } from "../../db";
import { apps, chats, messages } from "../../db/schema";
import { desc, eq, like } from "drizzle-orm";
import { desc, eq, inArray, like } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { appContracts } from "../types/app";
import type { AppFileSearchResult } from "../types/app";
......@@ -35,6 +35,38 @@ import {
import { getEnvVar } from "../utils/read_env";
import { readSettings } from "../../main/settings";
import { addLog, clearLogs } from "../../lib/log_store";
import {
DYAD_SCREENSHOT_DIR_NAME,
MAX_SCREENSHOTS_PER_APP,
SCREENSHOT_FILENAME_REGEX,
} from "../utils/media_path_utils";
/**
* Read screenshot entries for a single app directory, filtered by filename
* pattern and stat'd for mtime. Swallows per-file errors (races with prune).
*/
async function readScreenshotEntries(
screenshotDir: string,
): Promise<{ name: string; mtimeMs: number }[]> {
let entries: string[];
try {
entries = await fsPromises.readdir(screenshotDir);
} catch {
return [];
}
const results: { name: string; mtimeMs: number }[] = [];
for (const entry of entries) {
if (!SCREENSHOT_FILENAME_REGEX.test(entry)) continue;
try {
const stat = await fsPromises.stat(path.join(screenshotDir, entry));
results.push({ name: entry, mtimeMs: stat.mtimeMs });
} catch {
// File disappeared between readdir and stat — skip.
}
}
results.sort((a, b) => b.mtimeMs - a.mtimeMs);
return results;
}
import fixPath from "fix-path";
......@@ -71,6 +103,7 @@ import {
gitInit,
gitListBranches,
gitRenameBranch,
getCurrentCommitHash,
} from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender";
import type { AppOutput } from "../types/misc";
......@@ -2684,6 +2717,139 @@ export function registerAppHandlers() {
}
});
// Screenshot handlers
createTypedHandler(appContracts.getCurrentCommitHash, async (_, params) => {
const { appId } = params;
const appRecord = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!appRecord) {
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(appRecord.path);
try {
const commitHash = await getCurrentCommitHash({ path: appPath });
return { commitHash };
} catch {
return { commitHash: null };
}
});
createTypedHandler(appContracts.saveAppScreenshot, async (_, params) => {
const { appId, dataUrl, commitHash } = params;
// Validate data URL format
if (!/^data:image\/(png|jpe?g|webp);base64,/.test(dataUrl)) {
throw new DyadError(
"Invalid screenshot data URL format",
DyadErrorKind.Validation,
);
}
// Enforce a max size of 5 MB
const MAX_DATA_URL_LENGTH = 5 * 1024 * 1024;
if (dataUrl.length > MAX_DATA_URL_LENGTH) {
throw new DyadError(
"Screenshot data URL exceeds maximum allowed size",
DyadErrorKind.Validation,
);
}
const appRecord = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!appRecord) {
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(appRecord.path);
if (!SCREENSHOT_FILENAME_REGEX.test(`${commitHash}.png`)) {
logger.warn(
`Skipping screenshot save for app ${appId}: unexpected commit hash format`,
);
return;
}
const screenshotDir = path.join(appPath, DYAD_SCREENSHOT_DIR_NAME);
await fsPromises.mkdir(screenshotDir, { recursive: true });
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
await fsPromises.writeFile(
path.join(screenshotDir, `${commitHash}.png`),
buffer,
);
// Prune: keep only the newest MAX_SCREENSHOTS_PER_APP by mtime.
// Swallow ENOENT on unlink to tolerate concurrent saves.
try {
const screenshots = await readScreenshotEntries(screenshotDir);
for (const extra of screenshots.slice(MAX_SCREENSHOTS_PER_APP)) {
await fsPromises
.unlink(path.join(screenshotDir, extra.name))
.catch(() => {});
}
} catch (err) {
logger.warn(`Failed to prune screenshots for app ${appId}`, err);
}
});
createTypedHandler(appContracts.listAppScreenshots, async (_, params) => {
const { appId } = params;
const appRecord = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!appRecord) {
throw new DyadError("App not found", DyadErrorKind.NotFound);
}
const appPath = getDyadAppPath(appRecord.path);
const screenshotDir = path.join(appPath, DYAD_SCREENSHOT_DIR_NAME);
const entries = await readScreenshotEntries(screenshotDir);
const screenshots = entries.map(({ name }) => ({
commitHash: name.slice(0, -".png".length),
url: `dyad-media://media/${encodeURIComponent(appRecord.path)}/${DYAD_SCREENSHOT_DIR_NAME}/${name}`,
}));
return { screenshots };
});
createTypedHandler(appContracts.listAppThumbnails, async (_, params) => {
const { appIds } = params;
if (appIds.length === 0) {
return { thumbnails: [] };
}
const records = await db.query.apps.findMany({
where: inArray(apps.id, appIds),
});
const recordById = new Map(records.map((r) => [r.id, r]));
const thumbnails = await Promise.all(
appIds.map(async (appId) => {
const record = recordById.get(appId);
if (!record) {
return { appId, thumbnailUrl: null };
}
const appPath = getDyadAppPath(record.path);
const screenshotDir = path.join(appPath, DYAD_SCREENSHOT_DIR_NAME);
const entries = await readScreenshotEntries(screenshotDir);
const latest = entries[0];
if (!latest) {
return { appId, thumbnailUrl: null };
}
const thumbnailUrl = `dyad-media://media/${encodeURIComponent(record.path)}/${DYAD_SCREENSHOT_DIR_NAME}/${latest.name}`;
return { appId, thumbnailUrl };
}),
);
return { thumbnails };
});
void reconcileCloudSandboxes().catch((error) => {
logger.warn("Failed to reconcile cloud sandboxes on startup:", error);
});
......
......@@ -460,6 +460,48 @@ export const appContracts = {
input: z.object({ appId: z.number().nullable() }),
output: z.void(),
}),
getCurrentCommitHash: defineContract({
channel: "app:get-current-commit-hash",
input: z.object({ appId: z.number() }),
output: z.object({ commitHash: z.string().nullable() }),
}),
saveAppScreenshot: defineContract({
channel: "app:save-screenshot",
input: z.object({
appId: z.number(),
dataUrl: z.string(),
// Commit hash captured at the time the screenshot was requested.
// Required to avoid saving the screenshot under a newer HEAD if
// another commit lands between capture request and save.
commitHash: z.string(),
}),
output: z.void(),
}),
listAppScreenshots: defineContract({
channel: "app:list-screenshots",
input: z.object({ appId: z.number() }),
output: z.object({
screenshots: z.array(
z.object({ commitHash: z.string(), url: z.string() }),
),
}),
}),
listAppThumbnails: defineContract({
channel: "app:list-thumbnails",
input: z.object({ appIds: z.array(z.number()) }),
output: z.object({
thumbnails: z.array(
z.object({
appId: z.number(),
thumbnailUrl: z.string().nullable(),
}),
),
}),
}),
} as const;
// =============================================================================
......
import path from "node:path";
/**
* The root ".dyad" directory within each app that holds Dyad-managed files.
*/
export const DYAD_INTERNAL_DIR_NAME = ".dyad";
/**
* The ".dyad"-relative subdir for uploaded media files.
*/
export const DYAD_MEDIA_SUBDIR = "media";
/**
* The ".dyad"-relative subdir for screenshot files.
*/
export const DYAD_SCREENSHOT_SUBDIR = "screenshot";
/**
* The subdirectory within each app where uploaded media files are stored.
*/
export const DYAD_MEDIA_DIR_NAME = ".dyad/media";
export const DYAD_MEDIA_DIR_NAME = `${DYAD_INTERNAL_DIR_NAME}/${DYAD_MEDIA_SUBDIR}`;
/**
* The subdirectory within each app where screenshot files are stored.
*/
export const DYAD_SCREENSHOT_DIR_NAME = `${DYAD_INTERNAL_DIR_NAME}/${DYAD_SCREENSHOT_SUBDIR}`;
/**
* Maximum number of per-commit screenshots retained per app.
*/
export const MAX_SCREENSHOTS_PER_APP = 100;
/**
* Matches a screenshot filename keyed by a 40-char hex SHA-1 commit hash.
*/
export const SCREENSHOT_FILENAME_REGEX = /^[0-9a-f]{40}\.png$/;
/**
* Check if an absolute path falls within the app's .dyad/media directory.
......
......@@ -40,6 +40,9 @@ export const queryKeys = {
all: ["apps"] as const,
detail: ({ appId }: { appId: number | null }) =>
["apps", "detail", appId] as const,
screenshots: ({ appId }: { appId: number | null }) =>
["apps", "screenshots", appId] as const,
thumbnails: ["apps", "thumbnails"] as const,
search: ({ query }: { query: string }) =>
["apps", "search", query] as const,
},
......
import type { ListedApp } from "@/ipc/types/app";
/**
* Sort apps for the home showcase and /apps grid: favorites first, then by
* most-recently updated (falling back to createdAt if updatedAt is missing).
*/
export function sortAppsForShowcase(apps: ListedApp[]): ListedApp[] {
return [...apps].sort((a, b) => {
if (a.isFavorite !== b.isFavorite) {
return a.isFavorite ? -1 : 1;
}
const aTime = new Date(a.updatedAt ?? a.createdAt).getTime();
const bTime = new Date(b.updatedAt ?? b.createdAt).getTime();
return bTime - aTime;
});
}
......@@ -36,6 +36,11 @@ import {
startPerformanceMonitoring,
stopPerformanceMonitoring,
} from "./utils/performance_monitor";
import {
DYAD_INTERNAL_DIR_NAME,
DYAD_MEDIA_SUBDIR,
DYAD_SCREENSHOT_SUBDIR,
} from "./ipc/utils/media_path_utils";
import {
stopAllAppsSync,
stopAppGarbageCollection,
......@@ -45,10 +50,6 @@ import { cleanupOldMediaFiles } from "./ipc/utils/media_cleanup";
import fs from "fs";
import { gitAddSafeDirectory } from "./ipc/utils/git_utils";
import { getDyadAppsBaseDirectory, getDyadAppPath } from "./paths/paths";
import {
DYAD_MEDIA_DIR_NAME,
isWithinDyadMediaDir,
} from "./ipc/utils/media_path_utils";
log.errorHandler.startCatching();
log.eventLogger.startLogging();
......@@ -207,23 +208,26 @@ export async function onReady() {
// Start performance monitoring
startPerformanceMonitoring();
// Handle dyad-media:// protocol requests to serve persistent media files.
// Handle dyad-media:// protocol requests to serve persistent media and screenshot files.
protocol.handle("dyad-media", async (request) => {
const url = new URL(request.url);
// Format: dyad-media://media/{app-path}/.dyad/media/{filename}
// Format: dyad-media://media/{app-path}/.dyad/{subdir}/{filename}
// where {subdir} is DYAD_MEDIA_SUBDIR or DYAD_SCREENSHOT_SUBDIR.
// Uses a fixed hostname to avoid URL hostname normalization (lowercasing).
// The app-path segment is URI-encoded, so split on "/" before decoding
// to correctly handle absolute paths (which contain encoded slashes).
const pathSegments = url.pathname.slice(1).split("/");
const allowedSubdirs = [DYAD_MEDIA_SUBDIR, DYAD_SCREENSHOT_SUBDIR];
if (
pathSegments.length !== 4 ||
pathSegments[1] !== ".dyad" ||
pathSegments[2] !== "media"
pathSegments[1] !== DYAD_INTERNAL_DIR_NAME ||
!allowedSubdirs.includes(pathSegments[2])
) {
return new Response("Forbidden", { status: 403 });
}
const appPathRaw = decodeURIComponent(pathSegments[0]);
const subdir = pathSegments[2];
const filename = decodeURIComponent(pathSegments[3]);
// Defense-in-depth: reject filenames with path separators or traversal
......@@ -238,11 +242,14 @@ export async function onReady() {
// Resolve the app directory, handling both relative names and absolute
// paths from imported apps (skipCopy).
const appPath = getDyadAppPath(appPathRaw);
const mediaDir = path.resolve(path.join(appPath, DYAD_MEDIA_DIR_NAME));
const resolvedPath = path.resolve(path.join(mediaDir, filename));
const targetDir = path.resolve(
path.join(appPath, DYAD_INTERNAL_DIR_NAME, subdir),
);
const resolvedPath = path.resolve(path.join(targetDir, filename));
// Security: ensure the resolved path stays within the app's .dyad/media directory
if (!isWithinDyadMediaDir(resolvedPath, appPath)) {
// Security: ensure the resolved path stays within the app's .dyad/{subdir} directory
const relativePath = path.relative(targetDir, resolvedPath);
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return new Response("Forbidden", { status: 403 });
}
......
......@@ -4,7 +4,7 @@ import { useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { ipc } from "@/ipc/types";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
ArrowLeft,
......@@ -37,7 +37,7 @@ import { GitHubConnector } from "@/components/GitHubConnector";
import { SupabaseConnector } from "@/components/SupabaseConnector";
import { NeonConnector } from "@/components/NeonConnector";
import { showError, showSuccess } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { Info, Loader2 } from "lucide-react";
import {
......@@ -54,6 +54,7 @@ import { CapacitorControls } from "@/components/CapacitorControls";
import { GithubCollaboratorManager } from "@/components/GithubCollaboratorManager";
import { useAddAppToFavorite } from "@/hooks/useAddAppToFavorite";
import { useTranslation } from "react-i18next";
import { queryKeys } from "@/lib/queryKeys";
function UnavailableIntegrationCard({
provider,
......@@ -118,6 +119,17 @@ export default function AppDetailsPage() {
// Get the appId and provider filter from search params
const appId = search.appId ? Number(search.appId) : null;
const providerFilter = search.provider;
const { data: screenshotsData } = useQuery({
queryKey: queryKeys.apps.screenshots({ appId }),
queryFn: () => ipc.app.listAppScreenshots({ appId: appId! }),
enabled: !!appId,
});
const [screenshotLoadFailed, setScreenshotLoadFailed] = useState(false);
const latestScreenshotUrl = screenshotsData?.screenshots[0]?.url ?? null;
useEffect(() => {
setScreenshotLoadFailed(false);
}, [latestScreenshotUrl]);
const selectedApp = appId ? appsList.find((app) => app.id === appId) : null;
const handleDeleteApp = async () => {
......@@ -410,6 +422,17 @@ export default function AppDetailsPage() {
</Popover>
</div>
{latestScreenshotUrl && !screenshotLoadFailed && (
<div className="mb-4 rounded-lg overflow-hidden border border-border bg-muted aspect-video">
<img
src={latestScreenshotUrl}
alt={`Preview of ${selectedApp?.name ?? "app"}`}
onError={() => setScreenshotLoadFailed(true)}
className="w-full h-full object-contain"
/>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm mb-4">
<div>
<span className="block text-gray-500 dark:text-gray-400 mb-0.5 text-xs">
......
import { useMemo, useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useOpenApp } from "@/hooks/useOpenApp";
import { AppShowcaseCard } from "@/components/AppShowcaseCard";
import { useAppThumbnails } from "@/hooks/useAppThumbnails";
import { sortAppsForShowcase } from "@/lib/sortApps";
export default function AppsPage() {
const router = useRouter();
const navigate = useNavigate();
const { apps, loading } = useLoadApps();
const openApp = useOpenApp();
const [searchQuery, setSearchQuery] = useState("");
const filteredApps = useMemo(() => {
const sorted = sortAppsForShowcase(apps);
const q = searchQuery.trim().toLowerCase();
if (!q) return sorted;
return sorted.filter((app) => app.name.toLowerCase().includes(q));
}, [apps, searchQuery]);
// Fetch thumbnails for ALL apps once and filter client-side so typing in
// the search box doesn't trigger a burst of IPC + filesystem reads. This
// also lets the underlying query cache be shared with the featured
// showcase on the home page.
const allAppIds = useMemo(() => apps.map((a) => a.id), [apps]);
const thumbnailByAppId = useAppThumbnails(allAppIds);
const handleGoBack = () => {
if (router.history.length > 1) {
router.history.back();
} else {
navigate({ to: "/" });
}
};
return (
<div className="min-h-screen w-full px-8 py-4">
<div className="max-w-6xl mx-auto pb-12">
<Button
onClick={handleGoBack}
variant="outline"
size="sm"
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
>
<ArrowLeft className="h-4 w-4" />
Go Back
</Button>
<header className="mb-6 text-left">
<h1 className="text-3xl font-bold mb-2">Apps</h1>
</header>
<div className="mb-6">
<div
className={cn(
"relative flex items-center border border-border rounded-2xl bg-(--background-lighter) transition-colors duration-200",
"hover:border-primary/30",
"focus-within:border-primary/30 focus-within:ring-1 focus-within:ring-primary/20",
)}
>
<Search className="absolute left-4 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search apps..."
aria-label="Search apps"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent py-3 pl-11 pr-4 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
{loading ? (
<div className="text-muted-foreground text-center py-12">
Loading apps...
</div>
) : filteredApps.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<p className="text-muted-foreground text-center">
{searchQuery
? "No apps match your search."
: "You haven't created any apps yet."}
</p>
{!searchQuery && (
<Button onClick={() => navigate({ to: "/" })} size="sm">
Create your first app
</Button>
)}
</div>
) : (
<div
data-testid="apps-grid"
className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4"
>
{filteredApps.map((app) => (
<AppShowcaseCard
key={app.id}
app={app}
thumbnailUrl={thumbnailByAppId.get(app.id) ?? null}
onClick={openApp}
/>
))}
</div>
)}
</div>
</div>
);
}
......@@ -32,6 +32,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { ForceCloseDialog } from "@/components/ForceCloseDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
import { FeaturedAppShowcase } from "@/components/FeaturedAppShowcase";
import type { FileAttachment } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
......@@ -225,6 +226,7 @@ export default function HomePage() {
streamMessage({
prompt: inputValue,
chatId,
appId,
attachments,
requestedChatMode: initialChatMode,
});
......@@ -282,119 +284,124 @@ export default function HomePage() {
// Main Home Page Content
return (
<div className="flex flex-col items-center justify-center max-w-3xl w-full m-auto p-8 relative">
<div className="fixed top-16 right-8 z-50">
{settings && hasDyadProKey(settings) ? (
<ManageDyadProButton className="mt-0 w-auto h-9 px-3 text-base shadow-sm bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm hover:bg-white dark:hover:bg-gray-800" />
) : (
<SetupDyadProButton />
)}
</div>
<ForceCloseDialog
isOpen={forceCloseDialogOpen}
onClose={() => setForceCloseDialogOpen(false)}
performanceData={performanceData}
/>
<SetupBanner />
<div className="w-full">
<div className="flex items-center justify-center gap-4 mb-4">
<ImportAppButton className="px-0 pb-0 flex-none" />
<div className="flex flex-col w-full">
<div className="flex flex-col items-center justify-center max-w-3xl w-full m-auto p-8 relative">
<div className="fixed top-16 right-8 z-50">
{settings && hasDyadProKey(settings) ? (
<ManageDyadProButton className="mt-0 w-auto h-9 px-3 text-base shadow-sm bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm hover:bg-white dark:hover:bg-gray-800" />
) : (
<SetupDyadProButton />
)}
</div>
<HomeChatInput onSubmit={handleSubmit} />
<ForceCloseDialog
isOpen={forceCloseDialogOpen}
onClose={() => setForceCloseDialogOpen(false)}
performanceData={performanceData}
/>
<SetupBanner />
<div className="flex flex-col gap-4 mt-2">
<div className="flex flex-wrap gap-4 justify-center">
{randomPrompts.map((item, index) => (
<button
type="button"
key={index}
onClick={() =>
setInputValue(t("buildMeA", { label: item.label }))
}
className="flex items-center gap-3 px-4 py-2 rounded-xl border border-gray-200
<div className="w-full">
<div className="flex items-center justify-center gap-4 mb-4">
<ImportAppButton className="px-0 pb-0 flex-none" />
</div>
<HomeChatInput onSubmit={handleSubmit} />
<div className="flex flex-col gap-4 mt-2">
<div className="flex flex-wrap gap-4 justify-center">
{randomPrompts.map((item, index) => (
<button
type="button"
key={index}
onClick={() =>
setInputValue(t("buildMeA", { label: item.label }))
}
className="flex items-center gap-3 px-4 py-2 rounded-xl border border-gray-200
bg-white/50 backdrop-blur-sm
transition-all duration-200
hover:bg-white hover:shadow-md hover:border-gray-300
active:scale-[0.98]
dark:bg-gray-800/50 dark:border-gray-700
dark:hover:bg-gray-800 dark:hover:border-gray-600"
>
<span className="text-gray-700 dark:text-gray-300">
{item.icon}
</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{item.label}
</span>
</button>
))}
</div>
>
<span className="text-gray-700 dark:text-gray-300">
{item.icon}
</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{item.label}
</span>
</button>
))}
</div>
<button
type="button"
onClick={() => setRandomPrompts(getRandomPrompts())}
className="self-center flex items-center gap-2 px-4 py-2 rounded-xl border border-gray-200
<button
type="button"
onClick={() => setRandomPrompts(getRandomPrompts())}
className="self-center flex items-center gap-2 px-4 py-2 rounded-xl border border-gray-200
bg-white/50 backdrop-blur-sm
transition-all duration-200
hover:bg-white hover:shadow-md hover:border-gray-300
active:scale-[0.98]
dark:bg-gray-800/50 dark:border-gray-700
dark:hover:bg-gray-800 dark:hover:border-gray-600"
>
<svg
className="w-5 h-5 text-gray-700 dark:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("moreIdeas")}
</span>
</button>
</div>
<ProBanner />
</div>
<PrivacyBanner />
{/* Release Notes Dialog */}
<Dialog open={releaseNotesOpen} onOpenChange={setReleaseNotesOpen}>
<DialogContent className="max-w-4xl bg-(--docs-bg) pr-0 pt-4 pl-4 gap-1">
<DialogHeader>
<DialogTitle>{t("whatsNew", { version: appVersion })}</DialogTitle>
<Button
variant="ghost"
size="sm"
className="absolute right-10 top-2 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() =>
window.open(
releaseUrl.replace("?hideHeader=true&theme=" + theme, ""),
"_blank",
)
}
>
<ExternalLink className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="overflow-auto h-[70vh] flex flex-col ">
{releaseUrl && (
<div className="flex-1">
<iframe
src={releaseUrl}
className="w-full h-full border-0 rounded-lg"
title={t("releaseNotesTitle", { version: appVersion })}
<svg
className="w-5 h-5 text-gray-700 dark:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</div>
)}
</svg>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t("moreIdeas")}
</span>
</button>
</div>
</DialogContent>
</Dialog>
<ProBanner />
</div>
<PrivacyBanner />
{/* Release Notes Dialog */}
<Dialog open={releaseNotesOpen} onOpenChange={setReleaseNotesOpen}>
<DialogContent className="max-w-4xl bg-(--docs-bg) pr-0 pt-4 pl-4 gap-1">
<DialogHeader>
<DialogTitle>
{t("whatsNew", { version: appVersion })}
</DialogTitle>
<Button
variant="ghost"
size="sm"
className="absolute right-10 top-2 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() =>
window.open(
releaseUrl.replace("?hideHeader=true&theme=" + theme, ""),
"_blank",
)
}
>
<ExternalLink className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="overflow-auto h-[70vh] flex flex-col ">
{releaseUrl && (
<div className="flex-1">
<iframe
src={releaseUrl}
className="w-full h-full border-0 rounded-lg"
title={t("releaseNotesTitle", { version: appVersion })}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
<FeaturedAppShowcase />
</div>
);
}
......@@ -7,6 +7,7 @@ import { providerSettingsRoute } from "./routes/settings/providers/$provider";
import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub";
import { libraryRoute } from "./routes/library";
import { appsRoute } from "./routes/apps";
import { themesRoute } from "./routes/themes";
import { promptsRoute } from "./routes/prompts";
import { mediaRoute } from "./routes/media";
......@@ -15,6 +16,7 @@ const routeTree = rootRoute.addChildren([
homeRoute,
hubRoute,
libraryRoute,
appsRoute,
themesRoute,
promptsRoute,
mediaRoute,
......
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "./root";
import AppsPage from "../pages/apps";
export const appsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/apps",
component: AppsPage,
});
......@@ -295,6 +295,53 @@ body[data-scroll-locked] .app-region-drag {
}
}
.scrollbar-on-hover {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.35s ease;
}
.scrollbar-on-hover:hover,
.scrollbar-on-hover:focus-within {
scrollbar-color: rgba(128, 134, 139, 0.45) transparent;
}
.scrollbar-on-hover::-webkit-scrollbar {
height: 10px;
width: 10px;
background: transparent;
}
.scrollbar-on-hover::-webkit-scrollbar-track {
background: transparent;
margin: 0 8px;
}
.scrollbar-on-hover::-webkit-scrollbar-thumb {
background-color: transparent;
border: 3px solid transparent;
border-radius: 9999px;
background-clip: padding-box;
min-width: 40px;
min-height: 40px;
transition:
background-color 0.35s ease,
border-color 0.35s ease;
}
.scrollbar-on-hover:hover::-webkit-scrollbar-thumb,
.scrollbar-on-hover:focus-within::-webkit-scrollbar-thumb {
background-color: rgba(128, 134, 139, 0.45);
}
.scrollbar-on-hover::-webkit-scrollbar-thumb:hover {
background-color: rgba(128, 134, 139, 0.75);
}
.scrollbar-on-hover::-webkit-scrollbar-thumb:active {
background-color: rgba(95, 99, 104, 0.9);
}
.dark .scrollbar-on-hover:hover::-webkit-scrollbar-thumb,
.dark .scrollbar-on-hover:focus-within::-webkit-scrollbar-thumb {
background-color: rgba(200, 205, 210, 0.35);
}
.dark .scrollbar-on-hover::-webkit-scrollbar-thumb:hover {
background-color: rgba(220, 225, 230, 0.6);
}
@keyframes marquee {
0% {
transform: translateX(-100%);
......
......@@ -14,7 +14,7 @@
throw error;
}
}
async function handleScreenshotRequest() {
async function handleScreenshotRequest(requestId) {
try {
console.debug("[dyad-screenshot] Capturing screenshot...");
......@@ -26,6 +26,7 @@
window.parent.postMessage(
{
type: "dyad-screenshot-response",
requestId,
success: true,
dataUrl: dataUrl,
},
......@@ -38,6 +39,7 @@
window.parent.postMessage(
{
type: "dyad-screenshot-response",
requestId,
success: false,
error: error.message,
},
......@@ -50,7 +52,7 @@
if (event.source !== window.parent) return;
if (event.data.type === "dyad-take-screenshot") {
handleScreenshotRequest();
handleScreenshotRequest(event.data.requestId);
}
});
})();
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论