feat: expose device pairing over HTTP

This commit is contained in:
DiheChen committed 2026-11-11 00:15:01 +08:00
1 parent e95476185b
commit 1157843f50
23 files changed
+587 -75

No files matched your search

+20
View File
@@ -133,6 +133,14 @@ export interface CreateMyPlaylistRequest {
name: string
}
export type CreatePairingCodeRequest = Record<string, never>
export interface CreatePairingCodeResponse {
code: string
expiresAtMs: number
qrImage: string
}
export interface CreateRoomRequest {
description: string
name: string
@@ -227,6 +235,16 @@ export interface MoveMyPlaylistItemsRequest {
tracks: Track[]
}
export interface RedeemPairingCodeRequest {
code: string
}
export interface RedeemPairingCodeResponse {
account: Account
member: Member
memberToken: string
}
export interface RegisterRequest {
password: string
username: string
@@ -348,5 +366,7 @@ export function httpClient(send: HttpSend) {
unshareMyPlaylist: (id: string) => send<void>({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member' }),
getSharedPlaylist: (shareId: string) => send<SharedPlaylist>({ method: 'GET', path: `/api/shared-playlists/${encodeURIComponent(shareId)}`, auth: 'none' }),
copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }),
createPairingCode: () => send<CreatePairingCodeResponse>({ method: 'POST', path: '/api/account/pairing-codes', auth: 'member', body: {} }),
redeemPairingCode: (body: RedeemPairingCodeRequest) => send<RedeemPairingCodeResponse>({ method: 'POST', path: '/api/sessions/pairing', auth: 'member', body }),
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = 'cbfa923a9cbe9f0a'
export const PROTOCOL_VERSION = '0023496d94e1b43f'
export interface APIKey {
key: string
@@ -8,7 +8,8 @@ export interface APIKey {
export interface Account {
email?: string
username: string
github: boolean
username?: string
}
export type ActivityKind = 'added' | 'removed' | 'liked' | 'voted' | 'skipped' | 'failed'
@@ -464,7 +464,7 @@ describe('playlistsStore', () => {
const { playlistsStore } = await import('./playlists-store')
const { profileStore } = await import('@/features/settings')
playlistsStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
await flush()
expect(playlistsStore.state.playlists).toEqual([favorites])
window.dispatchEvent(new Event('focus'))
@@ -477,8 +477,8 @@ describe('playlistsStore', () => {
const { profileStore } = await import('@/features/settings')
holdReads = true
playlistsStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'bob' })
await profileStore.registered({ username: 'alice', github: false })
await profileStore.registered({ username: 'bob', github: false })
await flush()
release.shift()!()
await flush()
@@ -493,12 +493,12 @@ describe('playlistsStore', () => {
const { profileStore } = await import('@/features/settings')
const { notificationStore } = await import('@/shared/ui')
playlistsStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
await flush()
failAdd = true
const done = playlistsStore.setMembership(favorites, rain, true)
await flush()
await profileStore.registered({ username: 'bob' })
await profileStore.registered({ username: 'bob', github: false })
release.shift()!()
await done
expect(notificationStore.state.notifications).toEqual([])
@@ -508,12 +508,12 @@ describe('playlistsStore', () => {
const { playlistsStore } = await import('./playlists-store')
const { profileStore } = await import('@/features/settings')
playlistsStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
await flush()
holdReads = true
const reading = playlistsStore.containing(sunny)
await flush()
await profileStore.registered({ username: 'bob' })
await profileStore.registered({ username: 'bob', github: false })
release.shift()!()
await reading
expect(playlistsStore.state.memberships.size).toBe(0)
+24 -24
View File
@@ -167,15 +167,15 @@ describe('账号状态', () => {
})
it('取回资料时一并取回账号', async () => {
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } })
})
it('游客没有账号', async () => {
const { profileStore } = await load()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
await profileStore.load()
expect(profileStore.state).toMatchObject({ member, account: null })
})
@@ -183,18 +183,18 @@ describe('账号状态', () => {
it('注册之后设置账号', async () => {
const { profileStore } = await load()
await profileStore.load()
await profileStore.registered({ username: 'alice' })
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
await profileStore.registered({ username: 'alice', github: false })
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice', github: false } })
})
it('身份被拒绝或更换之后没有账号', async () => {
const { profileStore, identityEvents } = await load()
profileStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
identityEvents.emit({ kind: 'rejected' })
expect(profileStore.state).toMatchObject({ member: null, account: null })
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
identityEvents.emit({ kind: 'renewed', member: renewed })
expect(profileStore.state).toMatchObject({ member: renewed, account: null })
})
@@ -207,14 +207,14 @@ describe('邮箱', () => {
it('已注册时取自账号,不读本机', async () => {
local.setItem(emailKey, 'local@example.com')
me = { member, account: { username: 'alice', email: 'Alice@Example.COM' } }
me = { member, account: { username: 'alice', email: 'Alice@Example.COM', github: false } }
const { profileStore } = await load()
profileStore.start()
await profileStore.load()
expect(profileStore.state.email).toBe('Alice@Example.COM')
local.removeItem(emailKey)
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
await profileStore.load()
expect(profileStore.state.email).toBe('')
})
@@ -228,18 +228,18 @@ describe('邮箱', () => {
})
it('已注册时保存到账号,不写本机', async () => {
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true)
expect(updates).toEqual([{ email: ' Alice@Example.COM ' }])
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM' } })
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM', github: false } })
expect(local.getItem(emailKey)).toBeNull()
expect(await profileStore.save({ email: '' })).toBe(true)
expect(profileStore.state.email).toBe('')
expect(profileStore.state.account).toEqual({ username: 'alice' })
expect(profileStore.state.account).toEqual({ username: 'alice', github: false })
})
it('游客保存到本机', async () => {
@@ -255,17 +255,17 @@ describe('邮箱', () => {
const { profileStore } = await load()
profileStore.start()
await profileStore.load()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
expect(updates).toEqual([{ email: 'guest@example.com' }])
expect(local.getItem(emailKey)).toBeNull()
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } })
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } })
})
it('注册时本机没有邮箱就不发请求', async () => {
const { profileStore } = await load()
await profileStore.load()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
expect(updates).toEqual([])
})
@@ -274,21 +274,21 @@ describe('邮箱', () => {
patchFails = true
const { profileStore } = await load()
profileStore.start()
await profileStore.registered({ username: 'alice' })
await profileStore.registered({ username: 'alice', github: false })
expect(local.getItem(emailKey)).toBe('guest@example.com')
patchFails = false
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
await profileStore.load()
expect(updates).toEqual([{ email: 'guest@example.com' }])
expect(local.getItem(emailKey)).toBeNull()
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } })
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com', github: false } })
})
it('服务端判定本机的邮箱不合格时删除副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'legacy')
patchError = { status: 400, code: 'INVALID_ARGUMENT' }
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(local.getItem(emailKey)).toBeNull()
@@ -301,7 +301,7 @@ describe('邮箱', () => {
it('重试仍失败时保留本机的副本,下次加载再试', async () => {
local.setItem(emailKey, 'guest@example.com')
patchFails = true
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(local.getItem(emailKey)).toBe('guest@example.com')
@@ -313,26 +313,26 @@ describe('邮箱', () => {
})
it('已注册且本机没有副本,或账号已有邮箱时,加载不发更新请求', async () => {
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
const { profileStore } = await load()
await profileStore.load()
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
await profileStore.load()
expect(updates).toEqual([])
})
it('用户清空账号的邮箱时一并删除本机残留的副本,之后加载不再转存', async () => {
local.setItem(emailKey, 'guest@example.com')
me = { member, account: { username: 'alice', email: 'alice@example.com' } }
me = { member, account: { username: 'alice', email: 'alice@example.com', github: false } }
const { profileStore } = await load()
await profileStore.load()
expect(await profileStore.save({ email: '' })).toBe(true)
expect(local.getItem(emailKey)).toBeNull()
updates.length = 0
me = { member, account: { username: 'alice' } }
me = { member, account: { username: 'alice', github: false } }
await profileStore.load()
expect(updates).toEqual([])
})
+5 -1
View File
@@ -90,7 +90,11 @@ class ProfileStore extends Store<ProfileState> {
// 用户对账号邮箱的修改是最终决定,本机残留的副本不能再覆盖它。
if (changes.email !== undefined)
localStorage.removeItem(emailKey)
this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false })
const next: Account = { ...account }
delete next.email
if (email)
next.email = email
this.setState({ member, email, account: next, failed: false })
}
else {
const email = changes.email ?? this.state.email