refactor: 只保留 Web,移除 Capacitor 原生壳
删除 android/、ios/ 两个 Capacitor 原生工程与 capacitor.config.ts、 ionic.config.json,以及 scripts/android、scripts/ios.sh 构建脚本; 根与 apps/web 的 @capacitor/* 依赖和相关 npm scripts 一并移除。 Web 侧去掉原生分支:登录只走云乐坊容器授权面板或顶层重定向, 暗色模式不再改原生状态栏,移除 Android 物理返回键处理。 保留 @nuxtjs/ionic(Web UI 框架)、云乐坊 window.ylf 桥接与 PWA。 决策记录见 docs/decisions/0004-web-only.md。
This commit is contained in:
@@ -8,7 +8,7 @@ describe('clarity configuration', () => {
|
||||
expect(resolveClarityConfig(config, 'https://cook.yunle.fun/', false)).toEqual({ projectId: config.projectId })
|
||||
expect(resolveClarityConfig(config, 'https://cook.yunyoujun.cn/', true)).toBeNull()
|
||||
expect(resolveClarityConfig(config, 'https://preview.example.com/', false)).toBeNull()
|
||||
expect(resolveClarityConfig(config, 'capacitor://cook.yunle.fun/', false)).toBeNull()
|
||||
expect(resolveClarityConfig(config, 'app://cook.yunle.fun/', false)).toBeNull()
|
||||
expect(resolveClarityConfig({ ...config, projectId: '' }, 'https://cook.yunle.fun/', false)).toBeNull()
|
||||
expect(resolveClarityConfig({ ...config, projectId: '../invalid' }, 'https://cook.yunle.fun/', false)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -18,40 +18,25 @@ function readPngMetadata(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('native app metadata', () => {
|
||||
it('uses 食用手册 as the installed app name on iOS and Android', () => {
|
||||
const capacitorConfig = readFileSync(resolve(root, 'capacitor.config.ts'), 'utf8')
|
||||
const iosInfo = readFileSync(resolve(root, 'ios/App/App/Info.plist'), 'utf8')
|
||||
const androidStrings = readFileSync(
|
||||
resolve(root, 'android/app/src/main/res/values/strings.xml'),
|
||||
'utf8',
|
||||
)
|
||||
describe('web app metadata', () => {
|
||||
it('uses 食用手册 as the app name', () => {
|
||||
const constants = readFileSync(resolve(root, 'apps/web/app/constants/index.ts'), 'utf8')
|
||||
|
||||
expect(capacitorConfig).toContain('appName: \'食用手册\'')
|
||||
expect(iosInfo).toContain('<string>食用手册</string>')
|
||||
expect(androidStrings).toContain('<string name="app_name">食用手册</string>')
|
||||
expect(androidStrings).toContain('<string name="title_activity_main">食用手册</string>')
|
||||
expect(constants).toContain('export const appName = \'食用手册\'')
|
||||
})
|
||||
|
||||
it('ships the selected Cook icon across web, iOS, and Android', () => {
|
||||
it('ships the selected Cook icon across web surfaces', () => {
|
||||
const appIcon = readFileSync(resolve(root, 'apps/web/public/brand/cook-app-icon.svg'), 'utf8')
|
||||
const mark = readFileSync(resolve(root, 'apps/web/public/brand/cook-mark.svg'), 'utf8')
|
||||
const androidBackground = readFileSync(
|
||||
resolve(root, 'android/app/src/main/res/values/ic_launcher_background.xml'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(appIcon).toContain('viewBox="0 0 1254 1254"')
|
||||
expect(appIcon).toContain('<path fill="#07855F" d="M0 0h1254v1254H0z"/>')
|
||||
expect(appIcon).toContain('<g transform="translate(0 16)">')
|
||||
expect(mark).not.toContain('M0 0h1254v1254H0z')
|
||||
expect(mark).toContain('<g transform="translate(0 16)">')
|
||||
expect(androidBackground).toContain('#07855F')
|
||||
|
||||
expect(readPngMetadata('apps/web/public/pwa-192x192.png')).toMatchObject({ width: 192, height: 192, colorType: 2 })
|
||||
expect(readPngMetadata('apps/web/public/pwa-512x512.png')).toMatchObject({ width: 512, height: 512, colorType: 2 })
|
||||
expect(readPngMetadata('apps/web/public/apple-touch-icon.png')).toMatchObject({ width: 180, height: 180, colorType: 2 })
|
||||
expect(readPngMetadata('ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png')).toMatchObject({ width: 1024, height: 1024, colorType: 2 })
|
||||
expect(readPngMetadata('android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png')).toMatchObject({ width: 108, height: 108, colorType: 6 })
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
import type {
|
||||
NativeSsoTransaction,
|
||||
NativeSsoTransactionStore,
|
||||
} from '../apps/web/app/utils/nativeSso'
|
||||
import { encodeSsoRedirectResult } from '@yunlefun/sso'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginNativeSsoRedirect,
|
||||
consumeNativeSsoCallback,
|
||||
openNativeSsoRedirect,
|
||||
stripNativeSsoCallbackHash,
|
||||
} from '../apps/web/app/utils/nativeSso'
|
||||
|
||||
class MemoryTransactionStore implements NativeSsoTransactionStore {
|
||||
transaction: NativeSsoTransaction | null = null
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.transaction = null
|
||||
}
|
||||
|
||||
async load(): Promise<NativeSsoTransaction | null> {
|
||||
return this.transaction
|
||||
}
|
||||
|
||||
async save(transaction: NativeSsoTransaction): Promise<void> {
|
||||
this.transaction = transaction
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
clientId: 'cook-mobile',
|
||||
redirectUri: 'https://cook.yunyoujun.cn/auth/callback?platform=native',
|
||||
scope: ['identity:bootstrap'],
|
||||
ssoOrigin: 'https://www.yunle.fun',
|
||||
} as const
|
||||
|
||||
describe('native SSO redirect', () => {
|
||||
it('retains only valid optional acquisition through the native PKCE callback', async () => {
|
||||
const touch = {
|
||||
capturedAt: 1000,
|
||||
landingHost: 'cook.yunle.fun',
|
||||
landingPath: '/random',
|
||||
referrerHost: 'www.baidu.com',
|
||||
utmSource: '',
|
||||
utmMedium: '',
|
||||
utmCampaign: '',
|
||||
}
|
||||
const acquisition = { version: 1 as const, firstTouch: touch, registerTouch: touch }
|
||||
const store = new MemoryTransactionStore()
|
||||
const url = await beginNativeSsoRedirect({ ...options, acquisition }, store, {
|
||||
now: () => 1000,
|
||||
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
|
||||
})
|
||||
expect(JSON.parse(new URL(url).searchParams.get('acquisition')!)).toEqual(acquisition)
|
||||
const result = await consumeNativeSsoCallback(callbackUrl({
|
||||
code: 'c'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
}), store, { now: () => 2000 })
|
||||
expect(result).toMatchObject({ ok: true, acquisition })
|
||||
})
|
||||
|
||||
it('accepts a matching claimed HTTPS callback exactly once', async () => {
|
||||
const store = new MemoryTransactionStore()
|
||||
const authorizationUrl = await beginNativeSsoRedirect(options, store, {
|
||||
now: () => 1_000,
|
||||
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
|
||||
})
|
||||
const request = new URL(authorizationUrl)
|
||||
|
||||
expect(request.origin + request.pathname).toBe('https://www.yunle.fun/auth/sso')
|
||||
expect(request.searchParams.get('client_id')).toBe('cook-mobile')
|
||||
expect(request.searchParams.get('redirect_uri')).toBe(options.redirectUri)
|
||||
expect(request.searchParams.get('nonce')).toBe('n'.repeat(43))
|
||||
expect(request.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
|
||||
const callback = callbackUrl({
|
||||
code: 'c'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
})
|
||||
|
||||
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toEqual({
|
||||
ok: true,
|
||||
clientId: 'cook-mobile',
|
||||
code: 'c'.repeat(43),
|
||||
codeVerifier: 'v'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
redirectUri: options.redirectUri,
|
||||
scope: ['identity:bootstrap'],
|
||||
})
|
||||
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an altered callback path and consumes the pending transaction', async () => {
|
||||
const store = new MemoryTransactionStore()
|
||||
await beginNativeSsoRedirect(options, store, {
|
||||
now: () => 1_000,
|
||||
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
|
||||
})
|
||||
const callback = callbackUrl({
|
||||
code: 'c'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
}).replace('/auth/callback', '/evil/callback')
|
||||
|
||||
await expect(consumeNativeSsoCallback(callback, store, { now: () => 2_000 })).resolves.toBeNull()
|
||||
await expect(consumeNativeSsoCallback(callbackUrl({
|
||||
code: 'c'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
}), store, { now: () => 2_000 })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an expired transaction', async () => {
|
||||
const store = new MemoryTransactionStore()
|
||||
await beginNativeSsoRedirect(options, store, {
|
||||
now: () => 1_000,
|
||||
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
|
||||
})
|
||||
|
||||
await expect(consumeNativeSsoCallback(callbackUrl({
|
||||
code: 'c'.repeat(43),
|
||||
issuer: 'https://www.yunle.fun',
|
||||
nonce: 'n'.repeat(43),
|
||||
}), store, { now: () => 601_001 })).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('clears the pending transaction when the system browser cannot open', async () => {
|
||||
const store = new MemoryTransactionStore()
|
||||
|
||||
await expect(openNativeSsoRedirect(options, store, async () => {
|
||||
throw new Error('browser unavailable')
|
||||
}, {
|
||||
now: () => 1_000,
|
||||
randomBase64Url: sequence('n'.repeat(43), 'v'.repeat(43)),
|
||||
})).rejects.toThrow('browser unavailable')
|
||||
|
||||
expect(store.transaction).toBeNull()
|
||||
})
|
||||
|
||||
it('removes authorization data from a browser fallback URL', () => {
|
||||
expect(stripNativeSsoCallbackHash(
|
||||
`${options.redirectUri}#ylf_sso=sensitive-authorization-result`,
|
||||
)).toBe('/auth/callback?platform=native')
|
||||
})
|
||||
})
|
||||
|
||||
function callbackUrl(input: { code: string, issuer: string, nonce: string }): string {
|
||||
const result = encodeSsoRedirectResult({
|
||||
code: input.code,
|
||||
issuer: input.issuer,
|
||||
nonce: input.nonce,
|
||||
ok: true,
|
||||
})
|
||||
return `${options.redirectUri}#ylf_sso=${result}`
|
||||
}
|
||||
|
||||
function sequence(...values: string[]): () => string {
|
||||
let index = 0
|
||||
return () => values[index++] ?? ''
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import { addRecipeHistory, clearRecipeHistories, recipeHistories, removeRecipeHi
|
||||
import { exportLocalUserData, importLocalUserData, localUserData } from '../apps/web/app/composables/store/userData'
|
||||
import { userDataMeta } from '../apps/web/app/composables/store/userDataMeta'
|
||||
|
||||
vi.mock('@capacitor/browser', () => ({ Browser: {} }))
|
||||
vi.mock('@capacitor/preferences', () => ({ Preferences: {} }))
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
function recipe(name: string, id?: number): DbRecipeItem {
|
||||
|
||||
@@ -31,8 +31,6 @@ describe('yunlefun auth controller', () => {
|
||||
const identity = fakeIdentityAdapter()
|
||||
const controller = createYunlefunAuthController({
|
||||
identity,
|
||||
native: fakeAuthorizationAdapter(),
|
||||
platform: 'web',
|
||||
web,
|
||||
})
|
||||
|
||||
@@ -46,29 +44,17 @@ describe('yunlefun auth controller', () => {
|
||||
expect(identity.adopted).toEqual([authorization])
|
||||
})
|
||||
|
||||
it('opens the platform-appropriate authorization surface', async () => {
|
||||
it('opens the web authorization surface', async () => {
|
||||
const web = fakeAuthorizationAdapter()
|
||||
const native = fakeAuthorizationAdapter()
|
||||
const webController = createYunlefunAuthController({
|
||||
const controller = createYunlefunAuthController({
|
||||
identity: fakeIdentityAdapter(),
|
||||
native,
|
||||
platform: 'web',
|
||||
web,
|
||||
})
|
||||
const nativeController = createYunlefunAuthController({
|
||||
identity: fakeIdentityAdapter(),
|
||||
native,
|
||||
platform: 'native',
|
||||
web,
|
||||
})
|
||||
|
||||
await webController.signIn()
|
||||
await nativeController.signIn()
|
||||
await controller.signIn()
|
||||
|
||||
expect(web.started).toBe(1)
|
||||
expect(native.started).toBe(1)
|
||||
expect(webController.snapshot().status).toBe('signing-in')
|
||||
expect(nativeController.snapshot().status).toBe('signing-in')
|
||||
expect(controller.snapshot().status).toBe('signing-in')
|
||||
})
|
||||
|
||||
it('adopts a host authorization without leaving the current page', async () => {
|
||||
@@ -76,8 +62,6 @@ describe('yunlefun auth controller', () => {
|
||||
const identity = fakeIdentityAdapter()
|
||||
const controller = createYunlefunAuthController({
|
||||
identity,
|
||||
native: fakeAuthorizationAdapter(),
|
||||
platform: 'web',
|
||||
web,
|
||||
})
|
||||
|
||||
@@ -95,8 +79,6 @@ describe('yunlefun auth controller', () => {
|
||||
const identity = fakeIdentityAdapter(account)
|
||||
const controller = createYunlefunAuthController({
|
||||
identity,
|
||||
native: fakeAuthorizationAdapter(),
|
||||
platform: 'web',
|
||||
web: fakeAuthorizationAdapter(),
|
||||
})
|
||||
await controller.initialize()
|
||||
@@ -119,8 +101,6 @@ describe('yunlefun auth controller', () => {
|
||||
}
|
||||
const controller = createYunlefunAuthController({
|
||||
identity,
|
||||
native: fakeAuthorizationAdapter(),
|
||||
platform: 'web',
|
||||
web: fakeAuthorizationAdapter(),
|
||||
})
|
||||
await controller.initialize()
|
||||
@@ -133,22 +113,6 @@ describe('yunlefun auth controller', () => {
|
||||
status: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
it('adopts an authorization delivered after the native app resumes', async () => {
|
||||
const identity = fakeIdentityAdapter()
|
||||
const controller = createYunlefunAuthController({
|
||||
identity,
|
||||
native: fakeAuthorizationAdapter(),
|
||||
platform: 'native',
|
||||
web: fakeAuthorizationAdapter(),
|
||||
})
|
||||
await controller.initialize()
|
||||
|
||||
await controller.handleAuthorization(authorization)
|
||||
|
||||
expect(identity.adopted).toEqual([authorization])
|
||||
expect(controller.snapshot().status).toBe('signed-in')
|
||||
})
|
||||
})
|
||||
|
||||
function fakeAuthorizationAdapter(
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
import type { AcquisitionContext, SsoAuthorizationResult } from '@yunlefun/sso'
|
||||
import type { YunlefunSsoConfig } from '../apps/web/app/utils/yunlefunSsoConfig'
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { SsoIdentityAdoptionError } from '@yunlefun/sso/browser'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCloudbaseIdentityAdapter,
|
||||
createNativeYunlefunAuthorizationAdapter,
|
||||
createWebYunlefunAuthorizationAdapter,
|
||||
} from '../apps/web/app/adapters/yunlefunAuth'
|
||||
|
||||
vi.mock('@capacitor/browser', () => ({
|
||||
Browser: {
|
||||
close: vi.fn(),
|
||||
open: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@capacitor/preferences', () => ({
|
||||
Preferences: {
|
||||
get: vi.fn(async () => ({ value: null })),
|
||||
remove: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const config: YunlefunSsoConfig = {
|
||||
cloudbaseEnv: 'yunlefun-production',
|
||||
exchange: {
|
||||
@@ -135,41 +117,3 @@ describe('cloudbase identity adapter', () => {
|
||||
await expect(identity.signOut()).rejects.toThrow('CloudBase sign-out failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('native authorization adapter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('clears the pending transaction when authorization is cancelled', async () => {
|
||||
const nativeConfig: YunlefunSsoConfig = {
|
||||
...config,
|
||||
redirect: {
|
||||
...config.redirect,
|
||||
clientId: 'cook-mobile',
|
||||
redirectUri: 'https://cook.yunyoujun.cn/auth/callback?platform=native',
|
||||
},
|
||||
}
|
||||
const adapter = createNativeYunlefunAuthorizationAdapter(nativeConfig, async () => null)
|
||||
|
||||
await adapter.start()
|
||||
expect(Preferences.set).toHaveBeenCalledOnce()
|
||||
expect(Browser.open).toHaveBeenCalledOnce()
|
||||
|
||||
await adapter.cancel()
|
||||
expect(Preferences.remove).toHaveBeenCalledWith({
|
||||
key: 'cook:sso:v3:native-transaction',
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes acquisition into the actual native browser SSO URL', async () => {
|
||||
const adapter = createNativeYunlefunAuthorizationAdapter(config, async () => null, () => acquisition)
|
||||
await adapter.start()
|
||||
const url = new URL(vi.mocked(Browser.open).mock.calls[0]![0].url)
|
||||
expect(JSON.parse(url.searchParams.get('acquisition')!)).toEqual(acquisition)
|
||||
expect(url.searchParams.get('client_id')).toBe('cook-web')
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
const transaction = JSON.parse(vi.mocked(Preferences.set).mock.calls[0]![0].value)
|
||||
expect(transaction.acquisition).toEqual(acquisition)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,8 +5,6 @@ const validConfig = {
|
||||
clientId: 'cook-web',
|
||||
cloudbaseEnv: 'yunlefun-8g7ybcxc7345c490',
|
||||
exchangeUrl: 'https://api.yunle.fun/sso-ticket',
|
||||
nativeClientId: 'cook-mobile',
|
||||
nativeRedirectUri: 'https://cook.yunyoujun.cn/auth/callback?platform=native',
|
||||
redirectUri: 'https://cook.yunyoujun.cn/auth/callback',
|
||||
redirectUris: 'https://cook.yunyoujun.cn/auth/callback,https://cook.yunle.fun/auth/callback',
|
||||
scope: 'identity:bootstrap',
|
||||
@@ -17,7 +15,6 @@ describe('resolveYunlefunSsoConfig', () => {
|
||||
it('accepts the exact registered production web origin', () => {
|
||||
expect(resolveYunlefunSsoConfig(validConfig, {
|
||||
currentOrigin: 'https://cook.yunyoujun.cn',
|
||||
platform: 'web',
|
||||
})).toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
@@ -38,7 +35,6 @@ describe('resolveYunlefunSsoConfig', () => {
|
||||
it('selects the registered callback for the current production web origin', () => {
|
||||
expect(resolveYunlefunSsoConfig(validConfig, {
|
||||
currentOrigin: 'https://cook.yunle.fun',
|
||||
platform: 'web',
|
||||
})).toMatchObject({
|
||||
ok: true,
|
||||
config: {
|
||||
@@ -53,7 +49,6 @@ describe('resolveYunlefunSsoConfig', () => {
|
||||
it('fails closed on an unregistered HTTP development origin', () => {
|
||||
expect(resolveYunlefunSsoConfig(validConfig, {
|
||||
currentOrigin: 'http://127.0.0.1:4173',
|
||||
platform: 'web',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message: '当前域名尚未登记为登录回跳地址',
|
||||
@@ -63,27 +58,9 @@ describe('resolveYunlefunSsoConfig', () => {
|
||||
it('fails closed on an unregistered HTTPS origin', () => {
|
||||
expect(resolveYunlefunSsoConfig(validConfig, {
|
||||
currentOrigin: 'https://cook.example.com',
|
||||
platform: 'web',
|
||||
})).toEqual({
|
||||
ok: false,
|
||||
message: '当前域名尚未登记为登录回跳地址',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses only the registered HTTPS callback in a native container', () => {
|
||||
const result = resolveYunlefunSsoConfig(validConfig, {
|
||||
currentOrigin: 'capacitor://localhost',
|
||||
platform: 'native',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
config: {
|
||||
redirect: {
|
||||
clientId: 'cook-mobile',
|
||||
redirectUri: 'https://cook.yunyoujun.cn/auth/callback?platform=native',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user