diff --git a/browser_tests/assets/missing/missing_models_gated.json b/browser_tests/assets/missing/missing_models_gated.json new file mode 100644 index 00000000000..650e9380c45 --- /dev/null +++ b/browser_tests/assets/missing/missing_models_gated.json @@ -0,0 +1,54 @@ +{ + "last_node_id": 1, + "last_link_id": 1, + "nodes": [ + { + "id": 1, + "type": "CheckpointLoaderSimple", + "pos": [256, 256], + "size": [315, 98], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": null + }, + { + "name": "CLIP", + "type": "CLIP", + "links": null + }, + { + "name": "VAE", + "type": "VAE", + "links": null + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "models": [ + { + "name": "gated_model.safetensors", + "url": "https://huggingface.co/comfy-e2e/gated-test/resolve/main/gated_model.safetensors", + "directory": "checkpoints" + } + ] + }, + "widgets_values": ["gated_model.safetensors"] + } + ], + "links": [], + "groups": [], + "config": {}, + "extra": { + "ds": { + "offset": [0, 0], + "scale": 1 + } + }, + "version": 0.4 +} diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 541f4ffd978..66911126596 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -65,6 +65,8 @@ export const TestIds = { missingModelReferenceCount: 'missing-model-reference-count', missingModelUnsupportedSection: 'missing-model-import-not-supported-section', + missingModelGatedAccess: 'missing-model-gated-access', + missingModelGatedHint: 'missing-model-gated-hint', missingModelDownload: 'missing-model-download', missingModelActions: 'missing-model-actions', missingModelDownloadAll: 'missing-model-download-all', diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts index c41fd6145ec..0f912ce297e 100644 --- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts +++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts @@ -13,6 +13,7 @@ import { } from '@e2e/fixtures/helpers/ErrorsTabHelper' const FAKE_MODEL_NAME = 'fake_model.safetensors' +const GATED_MODEL_REPO_URL = 'https://huggingface.co/comfy-e2e/gated-test' function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) { return group.getByRole('button', { name: modelName, exact: true }) @@ -160,5 +161,71 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => { comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup) ).toBeHidden() }) + + test.describe('Gated Hugging Face model', { tag: '@ui' }, () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.page + .context() + .route('https://huggingface.co/**', async (route) => { + if (route.request().method() === 'HEAD') { + return route.fulfill({ + status: 403, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'X-Error-Code', + 'X-Error-Code': 'GatedRepo' + } + }) + } + + return route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'stub repo page' + }) + }) + await loadWorkflowAndOpenErrorsTab( + comfyPage, + 'missing/missing_models_gated' + ) + }) + + test('Should classify a 403 model as gated', async ({ comfyPage }) => { + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess) + ).toBeVisible() + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedHint) + ).toBeVisible() + }) + + test('Should keep Download available for a gated model', async ({ + comfyPage + }) => { + await expect( + comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess) + ).toBeVisible() + + const downloadButton = comfyPage.page.getByTestId( + TestIds.dialogs.missingModelDownload + ) + + await expect(downloadButton).toBeVisible() + await expect(downloadButton).toBeEnabled() + await expect(downloadButton).toHaveText('Download') + }) + + test('Should open the gated repository with the browser fallback', async ({ + comfyPage + }) => { + const pagePromise = comfyPage.page.context().waitForEvent('page') + await comfyPage.page + .getByTestId(TestIds.dialogs.missingModelGatedAccess) + .click() + const accessPage = await pagePromise + + await expect(accessPage).toHaveURL(GATED_MODEL_REPO_URL) + }) + }) }) }) diff --git a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts index a7c51e63870..51254a1f552 100644 --- a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts +++ b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts @@ -69,7 +69,12 @@ export interface ComfyDesktop2TelemetryBridge { } export interface ComfyDesktop2Bridge { + /** Reports whether the backend server is cloud/remote, not the user's location. */ isRemote(): boolean + /** Opens a model provider access page in the hosted frontend's browser session. + * Resolves `true` when the host has taken ownership of the request. + * On `false` or rejection the frontend falls back to opening a new tab. */ + openModelAccessPage?: (url: string) => Promise downloadModel?: ( url: string, filename: string, diff --git a/packages/comfyui-desktop-bridge-types/package.json b/packages/comfyui-desktop-bridge-types/package.json index c3cdbe2a5db..d61214b04f6 100644 --- a/packages/comfyui-desktop-bridge-types/package.json +++ b/packages/comfyui-desktop-bridge-types/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-desktop-bridge-types", - "version": "0.1.3", + "version": "0.1.4", "description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge", "homepage": "https://comfy.org", "license": "MIT", diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e826f593dad..3d0918a7671 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -4005,7 +4005,11 @@ "importNotSupported": "Import Not Supported", "copyModelName": "Copy model name", "copyUrl": "Copy URL", + "gatedModelsHint": "Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.", + "gatedModelTooltip": "This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.", + "gatedModelDownloadTooltip": "Download may require signing in to Hugging Face first.", "locateNode": "Locate node on canvas", + "openHuggingFaceRepo": "Open Hugging Face repo", "expandNodes": "Show referencing nodes", "collapseNodes": "Hide referencing nodes", "unknownCategory": "Unknown", diff --git a/src/platform/missingModel/components/MissingModelCard.test.ts b/src/platform/missingModel/components/MissingModelCard.test.ts index e5eccda9834..65ab57db26e 100644 --- a/src/platform/missingModel/components/MissingModelCard.test.ts +++ b/src/platform/missingModel/components/MissingModelCard.test.ts @@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import PrimeVue from 'primevue/config' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' import { createI18n } from 'vue-i18n' import enMessages from '@/locales/en/main.json' with { type: 'json' } @@ -10,6 +11,20 @@ import type { MissingModelGroup, MissingModelViewModel } from '@/platform/missingModel/types' +import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload' +import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' + +const mockDownloadModel = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/missingModel/missingModelDownload', async () => { + const actual = await vi.importActual( + '@/platform/missingModel/missingModelDownload' + ) + return { + ...actual, + downloadModel: mockDownloadModel + } +}) vi.mock('./MissingModelRow.vue', () => ({ default: { @@ -131,6 +146,7 @@ function getRowsIn(testId: string) { describe('MissingModelCard', () => { beforeEach(() => { + vi.clearAllMocks() mockIsCloud.value = true }) @@ -241,6 +257,21 @@ describe('MissingModelCard', () => { screen.queryByTestId('missing-model-actions') ).not.toBeInTheDocument() }) + + it('does not show gated model guidance in cloud', async () => { + const group = makeGroup({ withDownloadUrls: true }) + const url = + 'https://huggingface.co/comfy/test/resolve/main/model.safetensors' + mountCard({ missingModelGroups: [group] }) + + useMissingModelStore().gatedRepoUrls[url] = + 'https://huggingface.co/comfy/test' + await nextTick() + + expect( + screen.queryByTestId('missing-model-gated-hint') + ).not.toBeInTheDocument() + }) }) describe('Event Handling', () => { @@ -256,6 +287,7 @@ describe('MissingModelCard', () => { describe('MissingModelCard (OSS)', () => { beforeEach(() => { + vi.clearAllMocks() mockIsCloud.value = false }) @@ -293,6 +325,69 @@ describe('MissingModelCard (OSS)', () => { ).toBeVisible() }) + it('shows gated model guidance in OSS', async () => { + const group = makeGroup({ withDownloadUrls: true }) + const url = + 'https://huggingface.co/comfy/test/resolve/main/model.safetensors' + mountCard({ missingModelGroups: [group] }) + + useMissingModelStore().gatedRepoUrls[url] = + 'https://huggingface.co/comfy/test' + await nextTick() + + expect(screen.getByRole('note')).toHaveTextContent( + 'Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.' + ) + }) + + it('does not show gated guidance for a model that is not downloadable', async () => { + const group = makeGroup({ + modelNames: ['model.bin'], + withDownloadUrls: true + }) + const url = 'https://huggingface.co/comfy/test/resolve/main/model.bin' + mountCard({ missingModelGroups: [group] }) + + useMissingModelStore().gatedRepoUrls[url] = + 'https://huggingface.co/comfy/test' + await nextTick() + + expect( + screen.queryByTestId('missing-model-gated-hint') + ).not.toBeInTheDocument() + }) + + it('routes Download all through the shared missing-model download handler', async () => { + mountCard({ + missingModelGroups: [ + makeGroup({ + withDownloadUrls: true, + modelNames: ['first.safetensors', 'second.safetensors'] + }) + ] + }) + + await userEvent.click(screen.getByTestId('missing-model-download-all')) + + expect(mockDownloadModel).toHaveBeenCalledTimes(2) + expect(mockDownloadModel).toHaveBeenCalledWith( + { + name: 'first.safetensors', + url: 'https://huggingface.co/comfy/test/resolve/main/first.safetensors', + directory: 'checkpoints' + }, + {} + ) + expect(mockDownloadModel).toHaveBeenCalledWith( + { + name: 'second.safetensors', + url: 'https://huggingface.co/comfy/test/resolve/main/second.safetensors', + directory: 'checkpoints' + }, + {} + ) + }) + it('hides Download all when no model is downloadable', () => { mountCard() diff --git a/src/platform/missingModel/components/MissingModelCard.vue b/src/platform/missingModel/components/MissingModelCard.vue index 6e7ec5a1ffd..80c5bee2d1a 100644 --- a/src/platform/missingModel/components/MissingModelCard.vue +++ b/src/platform/missingModel/components/MissingModelCard.vue @@ -1,5 +1,20 @@