diff --git a/src/renderer/pages/backup/BackupCreateFlow.tsx b/src/renderer/pages/backup/BackupCreateFlow.tsx
index 8495059..b80081f 100644
--- a/src/renderer/pages/backup/BackupCreateFlow.tsx
+++ b/src/renderer/pages/backup/BackupCreateFlow.tsx
@@ -21,6 +21,8 @@ import { useMutation, gql, useQuery } from "@apollo/client";
import environment from "shared/environment";
import checks from "renderer/compatibility/checks";
import { useTranslation } from "react-i18next";
+import legacyDownload from "js-file-download";
+import { delay } from "shared/tools";
const notAvailable = !environment.isElectron && !checks.hasFilesystemApi;
@@ -201,7 +203,7 @@ const BackupCreateFlow: React.FC = () => {
includeLabels,
},
})
- .then((result) => {
+ .then(async (result) => {
clearInterval(interval);
setProgress(100);
@@ -211,20 +213,19 @@ const BackupCreateFlow: React.FC = () => {
base64Data: string;
}[];
- // Download each file
- files.forEach((file: { fileName: string; base64Data: string }) => {
- const blob = new Blob([Buffer.from(file.base64Data, "base64")], {
- type: "application/octet-stream",
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = file.fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- URL.revokeObjectURL(url);
- });
+ // Trigger downloads sequentially with a 200ms gap between each.
+ // Chromium blocks programmatic downloads when more than ~10 are
+ // fired simultaneously; a sequential approach avoids that entirely.
+ // eslint-disable-next-line no-restricted-syntax
+ for (const file of files) {
+ legacyDownload(
+ Buffer.from(file.base64Data, "base64"),
+ file.fileName,
+ "application/octet-stream"
+ );
+ // eslint-disable-next-line no-await-in-loop
+ await delay(200);
+ }
void message.success(
t(`{{count}} files downloaded`, { count: files.length })
diff --git a/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx b/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx
index d857348..e3dc3c7 100644
--- a/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx
+++ b/src/renderer/pages/backup/__tests__/BackupCreateFlow.spec.tsx
@@ -1,9 +1,13 @@
import React from "react";
import { render } from "test-utils/testing-library";
-import { screen, waitFor } from "@testing-library/react";
+import { screen, waitFor, fireEvent } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
import BackupCreateFlow from "renderer/pages/backup/BackupCreateFlow";
-import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import gql from "graphql-tag";
+import legacyDownload from "js-file-download";
+
+vi.mock("js-file-download");
// Mock environment
vi.mock("shared/environment", () => ({
@@ -20,29 +24,6 @@ vi.mock("renderer/compatibility/checks", () => ({
}));
describe("", () => {
- beforeAll(() => {
- // Mock document.createElement for download links
- const originalCreateElement = document.createElement.bind(document);
- vi.spyOn(document, "createElement").mockImplementation(((
- tagName: string
- ) => {
- const element = originalCreateElement(tagName);
- if (tagName === "a") {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
- (element as any).click = vi.fn();
- }
- return element;
- }) as typeof document.createElement);
-
- // Mock URL.createObjectURL and revokeObjectURL
- global.URL.createObjectURL = vi.fn(() => "mock-url");
- global.URL.revokeObjectURL = vi.fn();
- });
-
- afterAll(() => {
- vi.restoreAllMocks();
- });
-
it("should render the component", () => {
render(
@@ -199,3 +180,173 @@ describe("", () => {
});
});
});
+
+describe(" sequential individual download", () => {
+ let downloadedFiles: string[];
+ const mockLegacyDownload = vi.mocked(legacyDownload);
+
+ beforeEach(() => {
+ downloadedFiles = [];
+ mockLegacyDownload.mockImplementation((data: unknown, filename: string) => {
+ downloadedFiles.push(filename);
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should download all selected individual model files, not just the first 10", async () => {
+ const directoryId = "test-dir-id";
+ const modelCount = 11;
+ const modelNames = Array.from(
+ { length: modelCount },
+ (_, i) => `model${i + 1}`
+ );
+ const base64 = Buffer.from("content").toString("base64");
+
+ const mocks = [
+ {
+ request: {
+ query: gql`
+ mutation PickSdcardDirectory {
+ pickSdcardDirectory {
+ id
+ }
+ }
+ `,
+ },
+ result: {
+ data: {
+ pickSdcardDirectory: { id: directoryId },
+ },
+ },
+ },
+ {
+ request: {
+ query: gql`
+ query SdcardDirectoryInfo($directoryId: ID!) {
+ sdcardModelsDirectory(id: $directoryId) {
+ id
+ name
+ isValid
+ hasLabels
+ pack {
+ target
+ version
+ }
+ }
+ }
+ `,
+ variables: { directoryId },
+ },
+ result: {
+ data: {
+ sdcardModelsDirectory: {
+ id: directoryId,
+ name: "MODELS",
+ isValid: true,
+ hasLabels: false,
+ pack: null,
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: gql`
+ query SdcardModelsWithNames($directoryId: ID!) {
+ sdcardModelsWithNames(directoryId: $directoryId) {
+ fileName
+ displayName
+ }
+ }
+ `,
+ variables: { directoryId },
+ },
+ result: {
+ data: {
+ sdcardModelsWithNames: modelNames.map((name) => ({
+ fileName: name,
+ displayName: `Model ${name}`,
+ })),
+ },
+ },
+ },
+ {
+ request: {
+ query: gql`
+ mutation DownloadIndividualModels(
+ $directoryId: ID!
+ $selectedModels: [String!]!
+ $includeLabels: Boolean
+ ) {
+ downloadIndividualModels(
+ directoryId: $directoryId
+ selectedModels: $selectedModels
+ includeLabels: $includeLabels
+ ) {
+ fileName
+ base64Data
+ }
+ }
+ `,
+ variables: {
+ directoryId,
+ selectedModels: modelNames,
+ includeLabels: false,
+ },
+ },
+ result: {
+ data: {
+ downloadIndividualModels: modelNames.map((name) => ({
+ fileName: `${name}.yml`,
+ base64Data: base64,
+ })),
+ },
+ },
+ },
+ ];
+
+ render(
+
+
+
+ );
+
+ // Trigger SD card selection
+ fireEvent.click(screen.getByText("Select SD Card"));
+
+ // Wait for Apollo mutation response and models to load
+ await waitFor(
+ () => {
+ expect(screen.getByText("Select all")).toBeInTheDocument();
+ },
+ { timeout: 3000 }
+ );
+
+ // Select all models
+ fireEvent.click(screen.getByText("Select all"));
+
+ // Switch to individual .yml format
+ fireEvent.click(screen.getByText("Individual .yml files"));
+
+ // Trigger backup download
+ fireEvent.click(screen.getByRole("button", { name: /create backup/i }));
+
+ // All 11 files should be downloaded. Chromium blocks simultaneous
+ // programmatic downloads above ~10; the sequential approach (one every
+ // 200ms) avoids that entirely. 11 files → ~2200ms total.
+ await waitFor(
+ () => {
+ expect(downloadedFiles).toHaveLength(modelCount);
+ },
+ { timeout: 5000 }
+ );
+
+ // Verify each model file was included
+ modelNames.forEach((name) => {
+ expect(downloadedFiles).toContain(`${name}.yml`);
+ });
+ }, 15000); // 11 files × 200ms = ~2200ms, plus test overhead
+});
diff --git a/src/shared/backend/__tests__/backup.spec.ts b/src/shared/backend/__tests__/backup.spec.ts
index 269fa11..a9f7d1a 100644
--- a/src/shared/backend/__tests__/backup.spec.ts
+++ b/src/shared/backend/__tests__/backup.spec.ts
@@ -1395,6 +1395,92 @@ describe("Backup", () => {
expect(content).toContain("My Quad");
});
+ it("should download all models when many are selected (e.g. 43)", async () => {
+ const modelsPath = await setupSdcardDirectory(tempDir.path);
+
+ // Create 43 model files to simulate a real-world scenario
+ const modelNames: string[] = [];
+ for (let i = 1; i <= 43; i += 1) {
+ const modelName = `model${i}`;
+ modelNames.push(modelName);
+ await fs.writeFile(
+ path.join(modelsPath, `${modelName}.yml`),
+ createModelYaml(`Model ${i}`)
+ );
+ }
+
+ // Pick directory
+ const handle = await getOriginPrivateDirectory(
+ nodeAdapter,
+ tempDir.path
+ );
+ // @ts-expect-error readonly but testing
+ handle.name = tempDir.path;
+ requestWritableDirectory.mockResolvedValueOnce(handle);
+
+ const pickResult = await backend.mutate({
+ mutation: gql`
+ mutation {
+ pickSdcardDirectory {
+ id
+ }
+ }
+ `,
+ });
+
+ const directoryId = (pickResult.data?.pickSdcardDirectory as any)?.id;
+
+ // Download all 43 models
+ const { data, errors } = await backend.mutate({
+ mutation: gql`
+ mutation DownloadModels(
+ $directoryId: ID!
+ $selectedModels: [String!]!
+ ) {
+ downloadIndividualModels(
+ directoryId: $directoryId
+ selectedModels: $selectedModels
+ ) {
+ fileName
+ base64Data
+ }
+ }
+ `,
+ variables: {
+ directoryId,
+ selectedModels: modelNames,
+ },
+ });
+
+ expect(errors).toBeFalsy();
+ expect(data?.downloadIndividualModels).toHaveLength(43);
+
+ // Verify all 43 files are present
+ const downloadedFiles = (
+ data?.downloadIndividualModels as {
+ fileName: string;
+ base64Data: string;
+ }[]
+ ).map((m) => m.fileName);
+
+ for (let i = 1; i <= 43; i += 1) {
+ expect(downloadedFiles).toContain(`model${i}.yml`);
+ }
+
+ // Verify content of a few samples
+ const model25 = (
+ data?.downloadIndividualModels as {
+ fileName: string;
+ base64Data: string;
+ }[]
+ ).find((m) => m.fileName === "model25.yml");
+ const content = Buffer.from(
+ model25?.base64Data ?? "",
+ "base64"
+ ).toString("utf-8");
+ expect(content).toContain("Model 25");
+ });
+
it("should include labels file when requested", async () => {
const modelsPath = await setupSdcardDirectory(tempDir.path);