mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
6 Commits
batch-disp
...
bl/gate-go
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf0d779b9 | ||
|
|
8e57bb1d7d | ||
|
|
550ee93ba7 | ||
|
|
92b24ae2a4 | ||
|
|
daea25915a | ||
|
|
e898a6e72f |
119
src/base/webviewDetection.test.ts
Normal file
119
src/base/webviewDetection.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isEmbeddedWebView } from '@/base/webviewDetection'
|
||||
|
||||
describe('isEmbeddedWebView', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Android WebView', () => {
|
||||
it('detects Android WebView with wv token', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag regular Chrome on Android', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('iOS WKWebView', () => {
|
||||
it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag regular Safari on iOS', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Chrome on iOS (CriOS)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Firefox on iOS (FxiOS)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('social app in-app browsers', () => {
|
||||
it('detects Facebook (FBAN)', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Instagram', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects TikTok', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Line', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Snapchat', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('regular desktop browsers', () => {
|
||||
it('does not flag Chrome desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Firefox desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not flag Safari desktop', () => {
|
||||
const ua =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
|
||||
expect(isEmbeddedWebView(ua)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(isEmbeddedWebView('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JS bridge detection', () => {
|
||||
it('detects webkit.messageHandlers bridge', () => {
|
||||
vi.stubGlobal('webkit', { messageHandlers: {} })
|
||||
expect(isEmbeddedWebView('')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects ReactNativeWebView bridge', () => {
|
||||
vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
|
||||
expect(isEmbeddedWebView('')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
72
src/base/webviewDetection.ts
Normal file
72
src/base/webviewDetection.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Detects whether the app is running inside an embedded webview.
|
||||
*
|
||||
* Google blocks OAuth via `signInWithPopup` in embedded webviews,
|
||||
* returning a 403 `disallowed_useragent` error (policy since 2021).
|
||||
* This utility is used to hide the Google SSO button in those contexts.
|
||||
*
|
||||
* Detection covers:
|
||||
* • Android WebView (`wv` token in UA)
|
||||
* • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
|
||||
* • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
|
||||
* • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
|
||||
*/
|
||||
|
||||
const SOCIAL_APP_PATTERNS =
|
||||
/FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
|
||||
|
||||
function isAndroidWebView(ua: string): boolean {
|
||||
return /\bwv\b/.test(ua) && /Android/.test(ua)
|
||||
}
|
||||
|
||||
function isIOSWebView(ua: string): boolean {
|
||||
if (!/AppleWebKit/i.test(ua)) return false
|
||||
if (/Safari\//i.test(ua)) return false
|
||||
if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isSocialAppBrowser(ua: string): boolean {
|
||||
return SOCIAL_APP_PATTERNS.test(ua)
|
||||
}
|
||||
|
||||
function hasWebViewBridge(): boolean {
|
||||
try {
|
||||
const win = globalThis as Record<string, unknown>
|
||||
if (
|
||||
typeof win.webkit === 'object' &&
|
||||
win.webkit !== null &&
|
||||
typeof (win.webkit as Record<string, unknown>).messageHandlers ===
|
||||
'object'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (win.ReactNativeWebView != null) return true
|
||||
} catch {
|
||||
// Access to bridge objects may throw in sandboxed contexts
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
|
||||
if (isSocialAppBrowser(ua)) return true
|
||||
if (isAndroidWebView(ua)) return true
|
||||
if (isIOSWebView(ua)) return true
|
||||
if (hasWebViewBridge()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Reason why Google SSO is blocked in the current environment, or `null` if it
|
||||
* is available. Modeled as a discriminated string so call sites read as
|
||||
* "if blocked, here's why" rather than an opaque boolean. Extend this union
|
||||
* (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
|
||||
*/
|
||||
type GoogleSsoBlockedReason = 'embedded-webview' | null
|
||||
|
||||
export function getGoogleSsoBlockedReason(
|
||||
ua: string = navigator.userAgent
|
||||
): GoogleSsoBlockedReason {
|
||||
if (isEmbeddedWebView(ua)) return 'embedded-webview'
|
||||
return null
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="ssoAllowed">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10"
|
||||
variant="secondary"
|
||||
@@ -157,6 +158,7 @@ import type { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
import ApiKeyForm from './signin/ApiKeyForm.vue'
|
||||
import SignInForm from './signin/SignInForm.vue'
|
||||
@@ -172,6 +174,7 @@ const isSecureContext = window.isSecureContext
|
||||
const isSignIn = ref(true)
|
||||
const showApiKeyForm = ref(false)
|
||||
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
const comfyPlatformBaseUrl = computed(() =>
|
||||
configValueOrDefault(
|
||||
remoteConfig.value,
|
||||
|
||||
@@ -2188,7 +2188,8 @@
|
||||
"freeTierBadge": "Eligible for Free Tier",
|
||||
"freeTierDescription": "Sign up with Google to get {credits} free credits every month. No card needed.",
|
||||
"freeTierDescriptionGeneric": "Sign up with Google to get free credits every month. No card needed.",
|
||||
"backToSocialLogin": "Sign up with Google or Github instead"
|
||||
"backToSocialLogin": "Sign up with Google or Github instead",
|
||||
"backToGithubLogin": "Sign up with Github instead"
|
||||
},
|
||||
"signup": {
|
||||
"title": "Create an account",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{ t('auth.login.title') }}
|
||||
</h1>
|
||||
<i18n-t
|
||||
v-if="isFreeTierEnabled"
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
keypath="auth.login.signUpFreeTierPromo"
|
||||
tag="p"
|
||||
class="my-0 text-base text-muted"
|
||||
@@ -39,7 +39,12 @@
|
||||
<template v-if="!showEmailForm">
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<Button
|
||||
v-if="!googleSsoBlockedReason"
|
||||
type="button"
|
||||
class="h-10 w-full"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.login.loginWithGoogle') }}
|
||||
</Button>
|
||||
@@ -116,6 +121,7 @@ import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/u
|
||||
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SignInData } from '@/schemas/signInSchema'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -126,6 +132,7 @@ const authError = ref('')
|
||||
const toastStore = useToastStore()
|
||||
const showEmailForm = ref(false)
|
||||
const { isFreeTierEnabled, freeTierCredits } = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
|
||||
function switchToEmailForm() {
|
||||
showEmailForm.value = true
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
</Message>
|
||||
|
||||
<template v-if="!showEmailForm">
|
||||
<p v-if="isFreeTierEnabled" class="mb-4 text-sm text-muted-foreground">
|
||||
<p
|
||||
v-if="isFreeTierEnabled && !googleSsoBlockedReason"
|
||||
class="mb-4 text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
freeTierCredits
|
||||
? t('auth.login.freeTierDescription', {
|
||||
@@ -35,7 +38,7 @@
|
||||
|
||||
<!-- OAuth Buttons (primary) -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative">
|
||||
<div v-if="!googleSsoBlockedReason" class="relative">
|
||||
<Button type="button" class="h-10 w-full" @click="signInWithGoogle">
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{ t('auth.signup.signUpWithGoogle') }}
|
||||
@@ -86,7 +89,11 @@
|
||||
class="text-sm underline"
|
||||
@click="switchToSocialLogin"
|
||||
>
|
||||
{{ t('auth.login.backToSocialLogin') }}
|
||||
{{
|
||||
googleSsoBlockedReason
|
||||
? t('auth.login.backToGithubLogin')
|
||||
: t('auth.login.backToSocialLogin')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,6 +148,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { getGoogleSsoBlockedReason } from '@/base/webviewDetection'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -158,6 +166,7 @@ const {
|
||||
switchToEmailForm,
|
||||
switchToSocialLogin
|
||||
} = useFreeTierOnboarding()
|
||||
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
|
||||
|
||||
const navigateToLogin = async () => {
|
||||
await router.push({ name: 'cloud-login', query: route.query })
|
||||
|
||||
Reference in New Issue
Block a user