feat: add download toast, e2e permission tests, and update favicons
- Add download toast component with progress indicator and auto-dismiss - Add download store for managing file download state - Add e2e tests for permission prompts (allow/deny flows) - Add global setup for e2e tests with isolated daemon instance - Update favicon assets with new design - Refactor agent-stream-view to use shared tool-call-details component - Simplify file-explorer-pane component - Clean up unused screenshots and PRODUCTION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import { create } from "zustand";
|
||||
import { Platform } from "react-native";
|
||||
import { File as FSFile, Paths } from "expo-file-system";
|
||||
import * as LegacyFileSystem from "expo-file-system/legacy";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
|
||||
interface DownloadProgress {
|
||||
percent: number;
|
||||
bytesWritten: number;
|
||||
totalBytes: number;
|
||||
speed: number;
|
||||
eta: number;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
id: string;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
fileName: string;
|
||||
status: "downloading" | "complete" | "error";
|
||||
message?: string;
|
||||
progress?: DownloadProgress;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface DownloadState {
|
||||
downloads: Map<string, Download>;
|
||||
activeDownloadId: string | null;
|
||||
|
||||
startDownload: (params: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
fileName: string;
|
||||
path: string;
|
||||
daemonProfile: DaemonProfile | undefined;
|
||||
requestFileDownloadToken: (
|
||||
agentId: string,
|
||||
path: string
|
||||
) => Promise<{
|
||||
token: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
}) => Promise<void>;
|
||||
|
||||
updateProgress: (id: string, progress: DownloadProgress) => void;
|
||||
completeDownload: (id: string) => void;
|
||||
failDownload: (id: string, message: string) => void;
|
||||
dismissDownload: (id: string) => void;
|
||||
dismissAllCompleted: () => void;
|
||||
}
|
||||
|
||||
function generateDownloadId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
downloads: new Map(),
|
||||
activeDownloadId: null,
|
||||
|
||||
startDownload: async ({
|
||||
serverId,
|
||||
agentId,
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
requestFileDownloadToken,
|
||||
}) => {
|
||||
const id = generateDownloadId();
|
||||
const download: Download = {
|
||||
id,
|
||||
serverId,
|
||||
agentId,
|
||||
fileName,
|
||||
status: "downloading",
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
downloads: new Map(state.downloads).set(id, download),
|
||||
activeDownloadId: id,
|
||||
}));
|
||||
|
||||
try {
|
||||
const tokenResponse = await requestFileDownloadToken(agentId, path);
|
||||
if (tokenResponse.error || !tokenResponse.token) {
|
||||
throw new Error(tokenResponse.error ?? "Failed to request download token.");
|
||||
}
|
||||
|
||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
|
||||
if (!downloadTarget.baseUrl) {
|
||||
throw new Error("Download host is unavailable.");
|
||||
}
|
||||
|
||||
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||
const downloadUrl = buildDownloadUrl(
|
||||
downloadTarget.baseUrl,
|
||||
tokenResponse.token,
|
||||
Platform.OS === "web" ? downloadTarget.authCredentials : null
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
triggerBrowserDownload(downloadUrl, resolvedFileName);
|
||||
get().completeDownload(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadStartTime = Date.now();
|
||||
const targetFile = resolveDownloadTargetFile(resolvedFileName);
|
||||
const downloadResumable = LegacyFileSystem.createDownloadResumable(
|
||||
downloadUrl,
|
||||
targetFile.uri,
|
||||
downloadTarget.authHeader
|
||||
? { headers: { Authorization: downloadTarget.authHeader } }
|
||||
: undefined,
|
||||
(data) => {
|
||||
const now = Date.now();
|
||||
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
||||
|
||||
if (totalBytesExpectedToWrite <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const percent = totalBytesWritten / totalBytesExpectedToWrite;
|
||||
const elapsed = (now - downloadStartTime) / 1000;
|
||||
const speed = elapsed > 0 ? totalBytesWritten / elapsed : 0;
|
||||
const remaining = totalBytesExpectedToWrite - totalBytesWritten;
|
||||
const eta = speed > 0 ? remaining / speed : 0;
|
||||
|
||||
get().updateProgress(id, {
|
||||
percent,
|
||||
bytesWritten: totalBytesWritten,
|
||||
totalBytes: totalBytesExpectedToWrite,
|
||||
speed,
|
||||
eta,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
if (!result) {
|
||||
throw new Error("Download was cancelled.");
|
||||
}
|
||||
|
||||
get().completeDownload(id);
|
||||
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(result.uri, {
|
||||
mimeType: tokenResponse.mimeType ?? undefined,
|
||||
dialogTitle: resolvedFileName ? `Share ${resolvedFileName}` : "Share file",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to download file.";
|
||||
if (Platform.OS === "web") {
|
||||
console.warn("[DownloadStore] Download failed:", message);
|
||||
get().failDownload(id, message);
|
||||
return;
|
||||
}
|
||||
get().failDownload(id, message);
|
||||
}
|
||||
},
|
||||
|
||||
updateProgress: (id, progress) => {
|
||||
set((state) => {
|
||||
const download = state.downloads.get(id);
|
||||
if (!download || download.status !== "downloading") {
|
||||
return state;
|
||||
}
|
||||
const updated = new Map(state.downloads);
|
||||
updated.set(id, { ...download, progress });
|
||||
return { downloads: updated };
|
||||
});
|
||||
},
|
||||
|
||||
completeDownload: (id) => {
|
||||
set((state) => {
|
||||
const download = state.downloads.get(id);
|
||||
if (!download) {
|
||||
return state;
|
||||
}
|
||||
const updated = new Map(state.downloads);
|
||||
updated.set(id, { ...download, status: "complete" });
|
||||
return { downloads: updated };
|
||||
});
|
||||
},
|
||||
|
||||
failDownload: (id, message) => {
|
||||
set((state) => {
|
||||
const download = state.downloads.get(id);
|
||||
if (!download) {
|
||||
return state;
|
||||
}
|
||||
const updated = new Map(state.downloads);
|
||||
updated.set(id, { ...download, status: "error", message });
|
||||
return { downloads: updated };
|
||||
});
|
||||
},
|
||||
|
||||
dismissDownload: (id) => {
|
||||
set((state) => {
|
||||
const updated = new Map(state.downloads);
|
||||
updated.delete(id);
|
||||
const newActiveId =
|
||||
state.activeDownloadId === id
|
||||
? findMostRecentDownloadId(updated)
|
||||
: state.activeDownloadId;
|
||||
return { downloads: updated, activeDownloadId: newActiveId };
|
||||
});
|
||||
},
|
||||
|
||||
dismissAllCompleted: () => {
|
||||
set((state) => {
|
||||
const updated = new Map(state.downloads);
|
||||
for (const [id, download] of updated) {
|
||||
if (download.status !== "downloading") {
|
||||
updated.delete(id);
|
||||
}
|
||||
}
|
||||
const newActiveId = state.activeDownloadId
|
||||
? updated.has(state.activeDownloadId)
|
||||
? state.activeDownloadId
|
||||
: findMostRecentDownloadId(updated)
|
||||
: null;
|
||||
return { downloads: updated, activeDownloadId: newActiveId };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
function findMostRecentDownloadId(downloads: Map<string, Download>): string | null {
|
||||
let mostRecent: Download | null = null;
|
||||
for (const download of downloads.values()) {
|
||||
if (!mostRecent || download.startedAt > mostRecent.startedAt) {
|
||||
mostRecent = download;
|
||||
}
|
||||
}
|
||||
return mostRecent?.id ?? null;
|
||||
}
|
||||
|
||||
type DownloadTarget = {
|
||||
baseUrl: string | null;
|
||||
authHeader: string | null;
|
||||
authCredentials: { username: string; password: string } | null;
|
||||
};
|
||||
|
||||
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
|
||||
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
|
||||
if (!rawUrl) {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
if (parsed.protocol === "ws:") {
|
||||
parsed.protocol = "http:";
|
||||
} else if (parsed.protocol === "wss:") {
|
||||
parsed.protocol = "https:";
|
||||
}
|
||||
|
||||
let authCredentials: { username: string; password: string } | null = null;
|
||||
if (parsed.username || parsed.password) {
|
||||
authCredentials = {
|
||||
username: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
}
|
||||
|
||||
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
|
||||
|
||||
const baseUrl = parsed.origin;
|
||||
const authHeader = authCredentials
|
||||
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
|
||||
: null;
|
||||
|
||||
return { baseUrl, authHeader, authCredentials };
|
||||
}
|
||||
|
||||
function buildDownloadUrl(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
authCredentials: { username: string; password: string } | null
|
||||
): string {
|
||||
const url = new URL("/api/files/download", baseUrl);
|
||||
url.searchParams.set("token", token);
|
||||
if (authCredentials) {
|
||||
url.username = authCredentials.username;
|
||||
url.password = authCredentials.password;
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(url: string, fileName: string) {
|
||||
if (typeof document === "undefined") {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(url, "_blank", "noopener");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function resolveDownloadTargetFile(fileName: string): FSFile {
|
||||
const directory = Paths.cache ?? Paths.document;
|
||||
if (!directory) {
|
||||
throw new Error("No download directory available.");
|
||||
}
|
||||
|
||||
const safeName = sanitizeDownloadFileName(fileName);
|
||||
const split = splitFileName(safeName);
|
||||
let targetFile = new FSFile(directory, safeName);
|
||||
let suffix = 1;
|
||||
|
||||
while (targetFile.exists) {
|
||||
targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`);
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
function sanitizeDownloadFileName(fileName: string): string {
|
||||
const trimmed = fileName.trim();
|
||||
if (!trimmed) {
|
||||
return "download";
|
||||
}
|
||||
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
|
||||
}
|
||||
|
||||
function splitFileName(fileName: string): { base: string; ext: string } {
|
||||
const lastDot = fileName.lastIndexOf(".");
|
||||
if (lastDot <= 0) {
|
||||
return { base: fileName, ext: "" };
|
||||
}
|
||||
return {
|
||||
base: fileName.slice(0, lastDot),
|
||||
ext: fileName.slice(lastDot),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSecond: number): string {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${Math.round(bytesPerSecond)} B/s`;
|
||||
}
|
||||
if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
|
||||
}
|
||||
return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds < 1) {
|
||||
return "< 1s";
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
Reference in New Issue
Block a user