377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
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 { HostProfile } from "@/contexts/daemon-registry-context";
|
|
import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
|
|
interface DownloadProgress {
|
|
percent: number;
|
|
bytesWritten: number;
|
|
totalBytes: number;
|
|
speed: number;
|
|
eta: number;
|
|
}
|
|
|
|
export interface Download {
|
|
id: string;
|
|
serverId: string;
|
|
scopeId: 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;
|
|
scopeId: string;
|
|
fileName: string;
|
|
path: string;
|
|
daemonProfile: HostProfile | undefined;
|
|
requestFileDownloadToken: (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,
|
|
scopeId,
|
|
fileName,
|
|
path,
|
|
daemonProfile,
|
|
requestFileDownloadToken,
|
|
}) => {
|
|
const id = generateDownloadId();
|
|
const download: Download = {
|
|
id,
|
|
serverId,
|
|
scopeId,
|
|
fileName,
|
|
status: "downloading",
|
|
startedAt: Date.now(),
|
|
};
|
|
|
|
set((state) => ({
|
|
downloads: new Map(state.downloads).set(id, download),
|
|
activeDownloadId: id,
|
|
}));
|
|
|
|
try {
|
|
const tokenResponse = await requestFileDownloadToken(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?: HostProfile): DownloadTarget {
|
|
const endpoint =
|
|
daemon?.connections.find((conn) => conn.type === "directTcp")?.endpoint ?? null;
|
|
if (!endpoint) {
|
|
return { baseUrl: null, authHeader: null, authCredentials: null };
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(buildDaemonWebSocketUrl(endpoint));
|
|
} 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") {
|
|
void openExternalUrl(url);
|
|
}
|
|
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`;
|
|
}
|