A store with paid titles had nothing to tell this client and no way for it to listen: the catalog carried no price, no entitlement and no sign-in, so a gated download could only come back 403 and leave the window guessing why. The knowledge belongs on the server, not here. This client serves whichever catalog a registry names, so anything it knew about a particular shop would be a rule that breaks every other one. WarpEngine 0.5 answers GET /api/service with what it offers and puts an `access` block on every entry; this reads both. There is no store name anywhere in the diff. - **0.5 is a dialect of its own**, the older shape with `access` added. The version list is exhaustive over the selector, so adding it was a compile error until somebody said what it reads like — which is what that switch is for. - **A card shows a price and a Buy button** when a title is not yours, opening the store's own page. Buying stays in a browser: a checkout rebuilt here would be a second place to get card handling wrong. - **Signing in is the device grant**: a short code, the person's own browser, and no password crossing this window. The token goes in the OS keychain through safeStorage — one per store — and where no keychain exists it is not stored at all rather than written out in the clear. - **Owned / To buy** join the categories, since owning something is not the same as having installed it. Three things worth stating about the shape: The bearer token stops at the origin that issued it. A gated download redirects to signed storage — often somebody else's host — and some object stores refuse a request outright when an Authorization header arrives alongside the signature. An absent access block is not "free". It is an engine too old to have an opinion, and only one of those two is a reason to offer somebody a sign-in, so the three states are kept apart all the way to the card. state.json does not carry entitlement. Whether somebody may download a title is the server's answer to a question asked now; a copy on disk would go stale on the next purchase or refund, and a stale yes is the dangerous direction. A store with no sign-in shows none, and every WarpEngine before 0.5 is such a store: no Account block, no prices, no new categories. The smoke test against the live catalog reports exactly that — `sign-in: not offered`, `access: open:13`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { AccountDto, SignInFinishedDto, SignInPromptDto } from './dto/AccountDto'
|
||||
import type { AppStateDto } from './dto/AppStateDto'
|
||||
import type { CatalogListingDto } from './dto/CatalogListingDto'
|
||||
import type { InstalledStoreDto } from './dto/InstalledStoreDto'
|
||||
@@ -30,6 +31,12 @@ export interface BridgeApi {
|
||||
removeGame: (name: string) => Promise<void>
|
||||
launchGame: (name: string) => Promise<boolean>
|
||||
|
||||
readAccount: () => Promise<AccountDto>
|
||||
/** Answers with the code to show; how it ended arrives on `onSignInFinished`. */
|
||||
beginSignIn: () => Promise<SignInPromptDto>
|
||||
cancelSignIn: () => Promise<void>
|
||||
signOut: () => Promise<AccountDto>
|
||||
|
||||
listRegistryStores: () => Promise<RegistryResultDto>
|
||||
installStore: (store: RegistryStoreDto) => Promise<InstalledStoreDto>
|
||||
selectStore: (home: string) => Promise<StoreSelectionDto>
|
||||
@@ -40,6 +47,7 @@ export interface BridgeApi {
|
||||
onLog: (listener: StreamListener<string>) => void
|
||||
onSyncEvent: (listener: StreamListener<SyncEventDto>) => void
|
||||
onBusyChanged: (listener: StreamListener<boolean>) => void
|
||||
onSignInFinished: (listener: StreamListener<SignInFinishedDto>) => void
|
||||
}
|
||||
|
||||
/** The name the bridge is published under on `window`. */
|
||||
|
||||
@@ -19,6 +19,11 @@ export const IPC_CHANNELS = {
|
||||
catalogRemoveGame: 'catalog:removeGame',
|
||||
catalogLaunchGame: 'catalog:launchGame',
|
||||
|
||||
accountRead: 'account:read',
|
||||
accountBeginSignIn: 'account:beginSignIn',
|
||||
accountCancelSignIn: 'account:cancelSignIn',
|
||||
accountSignOut: 'account:signOut',
|
||||
|
||||
storeListRegistry: 'store:listRegistry',
|
||||
storeInstallStore: 'store:installStore',
|
||||
storeSelectStore: 'store:selectStore',
|
||||
@@ -26,7 +31,9 @@ export const IPC_CHANNELS = {
|
||||
/** Main to renderer, one way. */
|
||||
streamLog: 'stream:log',
|
||||
streamSyncEvent: 'stream:syncEvent',
|
||||
streamBusyChanged: 'stream:busyChanged'
|
||||
streamBusyChanged: 'stream:busyChanged',
|
||||
/** How a sign-in ended, once the browser half is done. */
|
||||
streamSignInFinished: 'stream:signInFinished'
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Where this machine stands with one store. */
|
||||
export interface AccountDto {
|
||||
readonly signInAvailable: boolean
|
||||
readonly signedIn: boolean
|
||||
}
|
||||
|
||||
/** What to show while somebody finishes signing in in their browser. */
|
||||
export interface SignInPromptDto {
|
||||
/** The short code, read off this screen and typed into a browser. */
|
||||
readonly userCode: string
|
||||
readonly verificationUrl: string
|
||||
readonly expiresInSeconds: number
|
||||
}
|
||||
|
||||
export type SignInOutcomeDto = 'signedIn' | 'denied' | 'expired' | 'cancelled'
|
||||
|
||||
/** The end of a sign-in, pushed to the window when the waiting is over. */
|
||||
export interface SignInFinishedDto {
|
||||
readonly outcome: SignInOutcomeDto
|
||||
readonly account: AccountDto
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AccountDto } from './AccountDto'
|
||||
import type { GameDto } from './GameDto'
|
||||
import type { StorePathsDto } from './StorePathsDto'
|
||||
|
||||
@@ -6,4 +7,12 @@ export interface CatalogListingDto {
|
||||
readonly games: readonly GameDto[]
|
||||
readonly skipped: readonly string[]
|
||||
readonly paths: StorePathsDto | null
|
||||
/**
|
||||
* Where this machine stood with the store when the catalog was read.
|
||||
*
|
||||
* Carried with the listing rather than asked for separately, because the entitlement
|
||||
* each entry reports is only meaningful next to whether anybody was signed in when
|
||||
* the catalog answered.
|
||||
*/
|
||||
readonly account: AccountDto
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
/** How a title runs: unpacked on this machine, or served as a web build. */
|
||||
export type GameModeDto = 'app' | 'web'
|
||||
|
||||
/**
|
||||
* Whether this person can install this title.
|
||||
*
|
||||
* Separate from `installable`, which is about the machine. A title can have a perfectly
|
||||
* good build for this architecture and still not be yours.
|
||||
*
|
||||
* - `open` — nothing to own; install it
|
||||
* - `entitled` — owned; install it
|
||||
* - `purchasable` — not owned, and here is the price
|
||||
* - `signInRequired` — the catalog would say, if it knew who was asking
|
||||
*/
|
||||
export type AccessVerdictDto = 'open' | 'entitled' | 'purchasable' | 'signInRequired'
|
||||
|
||||
/** Why a title cannot be installed on this machine. */
|
||||
export type UnavailableReasonDto = 'platformOff' | 'hostAsset' | 'noAsset' | 'vetoed'
|
||||
|
||||
@@ -30,4 +43,9 @@ export interface GameDto {
|
||||
readonly installable: boolean
|
||||
readonly unavailableReason: UnavailableReasonDto | null
|
||||
readonly unavailableDetail: string | null
|
||||
readonly accessVerdict: AccessVerdictDto
|
||||
/** Already formatted for the window's locale; null where there is no price to show. */
|
||||
readonly priceLabel: string | null
|
||||
/** Opened in the person's own browser. Null where the catalog named none. */
|
||||
readonly purchaseUrl: string | null
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export const ENGLISH_MESSAGES = {
|
||||
catInstalled: 'Installed',
|
||||
catUpdates: 'Updates',
|
||||
catAvailable: 'Not installed',
|
||||
catOwned: 'Owned',
|
||||
catPurchasable: 'To buy',
|
||||
catUnsupported: 'Not for this machine',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Kind',
|
||||
@@ -52,7 +54,27 @@ export const ENGLISH_MESSAGES = {
|
||||
failed: 'failed',
|
||||
removed: 'removed',
|
||||
upToDate: 'Everything is up to date.',
|
||||
of: 'of'
|
||||
of: 'of',
|
||||
owned: 'owned',
|
||||
ownedHint: 'This account owns it — install it on this machine',
|
||||
purchase: 'Buy',
|
||||
purchaseHint: 'Opens the store page in your browser',
|
||||
signInToInstall: 'Sign in to install',
|
||||
signInToInstallHint: 'This title needs an account; the catalog will say whether you own it',
|
||||
account: 'Account',
|
||||
signIn: 'Sign in…',
|
||||
signOut: 'Sign out',
|
||||
signedIn: 'Signed in',
|
||||
signInTitle: 'Sign in to %{store}',
|
||||
signInBody: 'Your browser is opening the sign-in page. Type this code there:',
|
||||
signInOpenAgain: 'Open the page again',
|
||||
signInWaiting: 'Waiting for you to approve it…',
|
||||
signInCancel: 'Cancel',
|
||||
signInDone: 'Signed in.',
|
||||
signInDenied: 'That sign-in was refused.',
|
||||
signInExpired: 'That code expired. Try again.',
|
||||
signInCancelled: 'Sign-in cancelled.',
|
||||
signInFailed: 'Signing in did not work.'
|
||||
} as const
|
||||
|
||||
/** Every string the window can show, by key. */
|
||||
|
||||
@@ -31,6 +31,8 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
catInstalled: 'Telepítve',
|
||||
catUpdates: 'Frissítés',
|
||||
catAvailable: 'Nincs telepítve',
|
||||
catOwned: 'Birtokolt',
|
||||
catPurchasable: 'Megvehető',
|
||||
catUnsupported: 'Erre a gépre nem',
|
||||
catPlatform: 'Platform',
|
||||
catMode: 'Fajta',
|
||||
@@ -52,5 +54,25 @@ export const HUNGARIAN_MESSAGES: MessageBundle = {
|
||||
failed: 'hiba',
|
||||
removed: 'eltávolítva',
|
||||
upToDate: 'Minden naprakész.',
|
||||
of: '/'
|
||||
of: '/',
|
||||
owned: 'birtokolt',
|
||||
ownedHint: 'Ez a fiók birtokolja — telepítheted erre a gépre',
|
||||
purchase: 'Megvásárlás',
|
||||
purchaseHint: 'Megnyitja a bolt oldalát a böngésződben',
|
||||
signInToInstall: 'Belépés a telepítéshez',
|
||||
signInToInstallHint: 'Ehhez a címhez fiók kell; a katalógus akkor mondja meg, birtoklod-e',
|
||||
account: 'Fiók',
|
||||
signIn: 'Belépés…',
|
||||
signOut: 'Kilépés',
|
||||
signedIn: 'Belépve',
|
||||
signInTitle: 'Belépés ide: %{store}',
|
||||
signInBody: 'Megnyílik a böngésződ a belépő oldallal. Írd be ott ezt a kódot:',
|
||||
signInOpenAgain: 'Oldal újranyitása',
|
||||
signInWaiting: 'Várunk a jóváhagyásra…',
|
||||
signInCancel: 'Mégsem',
|
||||
signInDone: 'Beléptél.',
|
||||
signInDenied: 'A belépést elutasították.',
|
||||
signInExpired: 'A kód lejárt. Próbáld újra.',
|
||||
signInCancelled: 'Belépés megszakítva.',
|
||||
signInFailed: 'A belépés nem sikerült.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user