diff --git a/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx
new file mode 100644
index 00000000000..f532e11a05f
--- /dev/null
+++ b/src/renderer/src/components/cloud-workspaces/CloudWorkspaceOpenFeedback.fixture.tsx
@@ -0,0 +1,70 @@
+// EP-6-FLAKE: the three states of a cloud workspace open that does not finish quickly: still opening
+// (loading), taking longer than usual (warning, Try again), and gone (error, Show cloud workspaces).
+import { useEffect } from 'react'
+import { toast } from 'sonner'
+import { defineFixture } from '../../component-fixtures/component-fixture'
+import { Toaster } from '@/components/ui/sonner'
+import {
+ beginCloudWorkspaceOpenFeedback,
+ endCloudWorkspaceOpenFeedback,
+ resetCloudWorkspaceOpenFeedbackForTests,
+ type OpenFeedbackFailure
+} from './cloud-workspace-open-feedback'
+
+const noActions = { retry: () => undefined, showWorkspaces: () => undefined }
+
+function OpenToast({ failure }: { failure: OpenFeedbackFailure | null }): React.JSX.Element {
+ useEffect(() => {
+ // After the Toaster has mounted: a toast raised before it subscribes is not shown.
+ const timer = window.setTimeout(() => {
+ beginCloudWorkspaceOpenFeedback('fixture', 'web')
+ if (failure) {
+ endCloudWorkspaceOpenFeedback('fixture', { ok: false, failure }, noActions)
+ }
+ }, 100)
+ return () => {
+ window.clearTimeout(timer)
+ resetCloudWorkspaceOpenFeedbackForTests()
+ toast.dismiss()
+ }
+ }, [failure])
+ return (
+ <>
+
+
+ >
+ )
+}
+
+async function toastShown(): Promise {
+ const deadline = performance.now() + 6000
+ while (document.querySelector('[data-sonner-toast][data-mounted="true"]') === null) {
+ if (performance.now() > deadline) {
+ throw new Error('Timed out waiting for the open toast')
+ }
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+ // Let the toast finish its entrance before the shot.
+ await new Promise((resolve) => setTimeout(resolve, 500))
+}
+
+/** Still opening after the delay (loading). */
+export const opening = defineFixture({
+ width: 1280,
+ render: () => ,
+ interact: toastShown
+})
+
+/** The open timed out while the workspace is still starting (warning, Try again). */
+export const takingLonger = defineFixture({
+ width: 1280,
+ render: () => ,
+ interact: toastShown
+})
+
+/** The workspace is gone (error, Show cloud workspaces). */
+export const gone = defineFixture({
+ width: 1280,
+ render: () => ,
+ interact: toastShown
+})
diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts
index ce383f6ba66..529fdf24323 100644
--- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts
+++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-links.ts
@@ -36,7 +36,9 @@ export async function handleCloudWorkspaceLink(link: string): Promise {
rememberCloudWorkspaces([workspace])
const outcome = await openCloudWorkspace(workspace)
if (!outcome.ok) {
- toast.error(outcome.message)
+ if (!outcome.notified) {
+ toast.error(outcome.message)
+ }
} else if (resolved.value.chatId) {
await focusCloudChat(outcome.worktreeId, resolved.value.chatId)
}
diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts
new file mode 100644
index 00000000000..e9d0046c1ac
--- /dev/null
+++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.test.ts
@@ -0,0 +1,105 @@
+// EP-6-FLAKE: a slow open says so after a few seconds; a timeout says it is still starting (Try
+// again); a workspace the server no longer has fails at once (Couldn't find it).
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const toast = vi.hoisted(() => ({
+ loading: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ dismiss: vi.fn()
+}))
+vi.mock('sonner', () => ({ toast }))
+
+import {
+ OPEN_FEEDBACK_DELAY_MS,
+ beginCloudWorkspaceOpenFeedback,
+ endCloudWorkspaceOpenFeedback,
+ resetCloudWorkspaceOpenFeedbackForTests,
+ showCloudWorkspaceOpenInPicker
+} from './cloud-workspace-open-feedback'
+import { openFailureKind } from './open-cloud-workspace'
+
+const actions = { retry: vi.fn(), showWorkspaces: vi.fn() }
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ for (const fn of Object.values(toast)) {
+ fn.mockReset()
+ }
+})
+afterEach(() => {
+ resetCloudWorkspaceOpenFeedbackForTests()
+ vi.useRealTimers()
+})
+
+describe('cloud workspace open feedback', () => {
+ it('a quick open shows nothing', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS - 1)
+ expect(endCloudWorkspaceOpenFeedback('ws', { ok: true }, actions)).toBe(false)
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
+ expect(toast.loading).not.toHaveBeenCalled()
+ expect(toast.dismiss).not.toHaveBeenCalled()
+ })
+
+ it('a slow open says so after the delay, and the toast closes when it opens', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
+ expect(toast.loading).toHaveBeenCalledWith('Opening web…', expect.anything())
+ endCloudWorkspaceOpenFeedback('ws', { ok: true }, actions)
+ expect(toast.dismiss).toHaveBeenCalledWith('cloud-workspace-open:ws')
+ })
+
+ it('a timeout is a warning with Try again, and the caller shows nothing more', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
+ expect(endCloudWorkspaceOpenFeedback('ws', { ok: false, failure: 'timeout' }, actions)).toBe(
+ true
+ )
+ const [title, options] = toast.warning.mock.calls[0]!
+ expect(title).toBe('web is taking longer than usual')
+ expect(options.action.label).toBe('Try again')
+ options.action.onClick()
+ expect(actions.retry).toHaveBeenCalled()
+ })
+
+ it('a workspace that is gone fails at once with Show cloud workspaces', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ expect(endCloudWorkspaceOpenFeedback('ws', { ok: false, failure: 'gone' }, actions)).toBe(true)
+ const [title, options] = toast.error.mock.calls[0]!
+ expect(title).toBe('Couldn’t find web')
+ expect(options.action.label).toBe('Show cloud workspaces')
+ })
+
+ it('any other failure leaves the message to the caller', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ expect(endCloudWorkspaceOpenFeedback('ws', { ok: false }, actions)).toBe(false)
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('waits while the picker shows the open, and shows at once when the picker closes', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ const release = showCloudWorkspaceOpenInPicker('ws')
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS * 2)
+ expect(toast.loading).not.toHaveBeenCalled()
+ release()
+ expect(toast.loading).toHaveBeenCalledTimes(1)
+ })
+
+ it('the picker closing before the delay shows it at once too, without restarting the wait', () => {
+ beginCloudWorkspaceOpenFeedback('ws', 'web')
+ const release = showCloudWorkspaceOpenInPicker('ws')
+ vi.advanceTimersByTime(1_000)
+ release()
+ expect(toast.loading).toHaveBeenCalledTimes(1)
+ vi.advanceTimersByTime(OPEN_FEEDBACK_DELAY_MS)
+ expect(toast.loading).toHaveBeenCalledTimes(1)
+ })
+
+ it('classifies a grant or wake refusal: timeout is still starting, not_found is gone', () => {
+ expect(openFailureKind('timeout')).toBe('timeout')
+ expect(openFailureKind('not_found')).toBe('gone')
+ expect(openFailureKind('entitlement_denied')).toBeUndefined()
+ expect(openFailureKind(undefined)).toBeUndefined()
+ })
+})
diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts
new file mode 100644
index 00000000000..ca82aecd6ac
--- /dev/null
+++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-open-feedback.ts
@@ -0,0 +1,179 @@
+// EP-6-FLAKE: opening a cloud workspace can wait a long while (a wake, a just-created VM, a grant
+// that times out) with nothing on screen, then drop the person on Home. While an open runs, a
+// loading toast says so after a few seconds; a timeout says it is still starting (Try again); a
+// workspace the server no longer has fails at once (Couldn't find it). The picker shows its own
+// spinner on the row, so the toast waits while that row is on screen and shows at once if the
+// picker closes mid-open.
+import { toast } from 'sonner'
+import { translate } from '@/i18n/i18n'
+
+export const OPEN_FEEDBACK_DELAY_MS = 3_000
+
+export type OpenFeedbackFailure = 'timeout' | 'gone'
+
+type Flight = {
+ name: string
+ timer: ReturnType | null
+ shown: boolean
+}
+
+const flights = new Map()
+// Workspaces whose open the picker is showing (its row spinner), counted per mount.
+const pickerShown = new Map()
+
+function toastId(workspaceId: string): string {
+ return `cloud-workspace-open:${workspaceId}`
+}
+
+function showLoading(workspaceId: string, flight: Flight): void {
+ if (flight.shown) {
+ return
+ }
+ flight.shown = true
+ toast.loading(
+ translate('cloudWorkspaceOpen.opening', 'Opening {{name}}…', { name: flight.name }),
+ {
+ id: toastId(workspaceId),
+ duration: Infinity,
+ description: translate(
+ 'cloudWorkspaceOpen.openingDescription',
+ 'Workspaces that were asleep or just created can take a minute or two.'
+ )
+ }
+ )
+}
+
+/** An open began: after the delay, a loading toast unless the picker row shows the open. */
+export function beginCloudWorkspaceOpenFeedback(workspaceId: string, name: string): void {
+ endFlight(workspaceId)
+ const flight: Flight = { name, timer: null, shown: false }
+ flight.timer = setTimeout(() => {
+ flight.timer = null
+ if (!pickerShown.get(workspaceId)) {
+ showLoading(workspaceId, flight)
+ }
+ }, OPEN_FEEDBACK_DELAY_MS)
+ flights.set(workspaceId, flight)
+}
+
+function endFlight(workspaceId: string): Flight | undefined {
+ const flight = flights.get(workspaceId)
+ if (flight?.timer) {
+ clearTimeout(flight.timer)
+ }
+ flights.delete(workspaceId)
+ return flight
+}
+
+/**
+ * The open settled. A timeout or a missing workspace gets its own toast (true: the caller shows
+ * nothing more); anything else only clears the loading toast and leaves the message to the caller.
+ */
+export function endCloudWorkspaceOpenFeedback(
+ workspaceId: string,
+ outcome: { ok: true } | { ok: false; failure?: OpenFeedbackFailure },
+ actions: { retry: () => void; showWorkspaces: () => void }
+): boolean {
+ const flight = endFlight(workspaceId)
+ const name = flight?.name ?? ''
+ const id = toastId(workspaceId)
+ if (outcome.ok || !outcome.failure) {
+ if (flight?.shown) {
+ toast.dismiss(id)
+ }
+ return false
+ }
+ if (outcome.failure === 'timeout') {
+ // Still starting, not failed: the warning tone.
+ toast.warning(
+ translate('cloudWorkspaceOpen.slowTitle', '{{name}} is taking longer than usual', { name }),
+ {
+ id,
+ duration: Infinity,
+ description: translate(
+ 'cloudWorkspaceOpen.slowDescription',
+ 'It’s still starting. Try again in a moment.'
+ ),
+ action: {
+ label: translate('cloudWorkspaceOpen.tryAgain', 'Try again'),
+ onClick: actions.retry
+ }
+ }
+ )
+ return true
+ }
+ toast.error(translate('cloudWorkspaceOpen.goneTitle', 'Couldn’t find {{name}}', { name }), {
+ id,
+ description: translate('cloudWorkspaceOpen.goneDescription', 'It may have been deleted.'),
+ action: {
+ label: translate(
+ 'auto.components.cloudWorkspaces.showCloudWorkspaces',
+ 'Show cloud workspaces'
+ ),
+ onClick: actions.showWorkspaces
+ }
+ })
+ return true
+}
+
+/** Runs an open with its feedback; a failure a toast already explained comes back `notified`. */
+export async function withCloudWorkspaceOpenFeedback<
+ T extends { ok: true } | { ok: false; failure?: OpenFeedbackFailure; notified?: true }
+>(
+ workspaceId: string,
+ name: string,
+ run: () => Promise,
+ actions: { retry: () => void; showWorkspaces: () => void }
+): Promise {
+ beginCloudWorkspaceOpenFeedback(workspaceId, name)
+ let outcome: T
+ try {
+ outcome = await run()
+ } catch (error) {
+ endCloudWorkspaceOpenFeedback(workspaceId, { ok: false }, actions)
+ throw error
+ }
+ const notified = endCloudWorkspaceOpenFeedback(workspaceId, outcome, actions)
+ return notified && !outcome.ok ? { ...outcome, notified: true } : outcome
+}
+
+/**
+ * The picker shows this workspace's open on its row while mounted. Returns the release: when the
+ * picker closes mid-open past the delay, the loading toast shows at once.
+ */
+export function showCloudWorkspaceOpenInPicker(workspaceId: string): () => void {
+ pickerShown.set(workspaceId, (pickerShown.get(workspaceId) ?? 0) + 1)
+ let released = false
+ return () => {
+ if (released) {
+ return
+ }
+ released = true
+ const left = (pickerShown.get(workspaceId) ?? 1) - 1
+ if (left > 0) {
+ pickerShown.set(workspaceId, left)
+ return
+ }
+ pickerShown.delete(workspaceId)
+ const flight = flights.get(workspaceId)
+ if (flight) {
+ // Closed mid-open: say so now, without restarting the delay.
+ if (flight.timer) {
+ clearTimeout(flight.timer)
+ flight.timer = null
+ }
+ showLoading(workspaceId, flight)
+ }
+ }
+}
+
+/** Tests only. */
+export function resetCloudWorkspaceOpenFeedbackForTests(): void {
+ for (const flight of flights.values()) {
+ if (flight.timer) {
+ clearTimeout(flight.timer)
+ }
+ }
+ flights.clear()
+ pickerShown.clear()
+}
diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts
index 956c30f6e47..290a3728456 100644
--- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts
+++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts
@@ -18,12 +18,34 @@ import {
} from '../../../../shared/execution-host'
import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender'
import { describeError, isUnknownServerFailure } from './cloud-workspace-errors'
+import {
+ type OpenFeedbackFailure,
+ withCloudWorkspaceOpenFeedback
+} from './cloud-workspace-open-feedback'
+import { openCloudWorkspacePicker } from './cloud-workspace-picker-store'
/** On success, the worktree that was revealed (null when its projects are still syncing). */
export type OpenOutcome =
| { ok: true; worktreeId: string | null; hostId?: string }
- /** `unknownServerError`: the wake failed on the server for no known reason (shared copy). */
- | { ok: false; message: string; unknownServerError?: boolean }
+ /**
+ * `unknownServerError`: the wake failed on the server for no known reason (shared copy).
+ * `notified`: a toast already said what happened (EP-6-FLAKE); the caller shows nothing more.
+ */
+ | {
+ ok: false
+ message: string
+ unknownServerError?: boolean
+ failure?: OpenFeedbackFailure
+ notified?: true
+ }
+
+/** EP-6-FLAKE: a refusal that has its own toast: still starting (timeout) or gone (not found). */
+export function openFailureKind(error: string | undefined): OpenFeedbackFailure | undefined {
+ if (error === 'timeout') {
+ return 'timeout'
+ }
+ return error === 'not_found' ? 'gone' : undefined
+}
// Rejections that mean this device's pairing is no longer accepted (revoked grant, rebuilt VM).
const PAIRING_REJECTED_CODES = new Set(['unauthorized', 'device_revoked', 'forbidden'])
@@ -76,6 +98,8 @@ export type OpenCloudWorkspaceOptions = {
* hides that server's rows until it hands off to them, so its pending row never doubles up.
*/
onHost?: (hostId: string) => void
+ /** False: the caller shows the open's progress itself (a GitHub-only create's pending row). */
+ feedback?: false
}
async function connectAndReveal(
@@ -159,10 +183,12 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise opening.delete(workspace.id))
+ const run = (): Promise => openOnce(workspace, options)
+ const attempt = (
+ options.feedback === false
+ ? run()
+ : withCloudWorkspaceOpenFeedback(workspace.id, cloudWorkspaceTitle(workspace), run, {
+ retry: () => void openCloudWorkspace(workspace, options),
+ showWorkspaces: () => openCloudWorkspacePicker()
+ })
+ ).finally(() => opening.delete(workspace.id))
opening.set(workspace.id, attempt)
return attempt
}
diff --git a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx
index 7c4b3ead91d..9d08ab51220 100644
--- a/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx
+++ b/src/renderer/src/components/cloud-workspaces/use-cloud-workspace-row-actions.tsx
@@ -1,7 +1,7 @@
// What a cloud workspace row can do from "Open cloud workspace…": open (or read an Asleep one's
// saved copy), copy its link, share or hand it over, sleep, archive and delete. Opening goes through
// openCloudWorkspace, the same path as a workspace link and a teammate's profile.
-import React, { useCallback, useMemo, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useTranslate } from '@/i18n/use-translate'
import { refreshCloudMessageSenders } from '@/lib/cloud-message-sender'
@@ -17,6 +17,7 @@ import { focusCloudChat } from './cloud-chat-focus'
import * as archive from './cloud-workspace-home-archive'
import { focusedKind, frontCloudChat } from './cloud-workspace-links'
import { copyCloudWorkspaceLink, openCloudWorkspace } from './open-cloud-workspace'
+import { showCloudWorkspaceOpenInPicker } from './cloud-workspace-open-feedback'
import type { RowActions } from './CloudWorkspaceRowMenu'
import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog'
import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet'
@@ -77,6 +78,9 @@ export function useCloudWorkspaceRowActions({
const [openingId, setOpeningId] = useState(null)
const [confirm, setConfirm] = useState(null)
const { readingId, readingRef, setReadingId, readWake, setReadWake } = useCloudReadPanel()
+ // EP-6-FLAKE: the row's spinner shows this open; the open's toast waits while the picker is up and
+ // shows at once if it closes mid-open.
+ useEffect(() => (openingId ? showCloudWorkspaceOpenInPicker(openingId) : undefined), [openingId])
/** Opens the workspace, then (going to a teammate's chat) the chat they have in front. */
const open = useCallback(
@@ -92,7 +96,7 @@ export function useCloudWorkspaceRowActions({
if (fromPanel && readingRef.current === w.id) {
// The panel shows the failure in place (with the reason) instead of a toast.
setReadWake({ ...outcome, phase: 'failed' })
- } else {
+ } else if (!outcome.notified) {
toast.error(outcome.message)
}
return
diff --git a/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx b/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx
index 0b330812538..55bc20629d9 100644
--- a/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx
+++ b/src/renderer/src/components/settings/cloud-workspaces/TeamWorkspacesSettings.tsx
@@ -84,7 +84,7 @@ export function TeamWorkspacesSettings({ orgId }: { orgId: string | null }): Rea
)
const open = (w: CloudWorkspace): void => {
void openCloudWorkspace(w).then((outcome) => {
- if (!outcome.ok) {
+ if (!outcome.ok && !outcome.notified) {
toast.error(outcome.message)
}
})
diff --git a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx
index ccd68a28629..648d2046d4f 100644
--- a/src/renderer/src/components/teammates/TeammateProfileDialog.tsx
+++ b/src/renderer/src/components/teammates/TeammateProfileDialog.tsx
@@ -100,7 +100,7 @@ function ProfileBody({ userId }: { userId: string }): React.JSX.Element {
onOpenWorkspace={(workspace) => {
closeTeammateProfile()
void openCloudWorkspace(workspace).then((outcome) => {
- if (!outcome.ok) {
+ if (!outcome.ok && !outcome.notified) {
toast.error(outcome.message)
}
})
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c2fd8a81981..f0fab10dc95 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -29807,5 +29807,14 @@
"title": "You can’t open this cloud workspace anymore",
"description": "Its owner or a team admin changed who can open it. Ask them for access, or remove it from this computer. This doesn’t delete the workspace.",
"remove": "Remove from this computer"
+ },
+ "cloudWorkspaceOpen": {
+ "opening": "Opening {{name}}…",
+ "openingDescription": "Workspaces that were asleep or just created can take a minute or two.",
+ "slowTitle": "{{name}} is taking longer than usual",
+ "slowDescription": "It’s still starting. Try again in a moment.",
+ "tryAgain": "Try again",
+ "goneTitle": "Couldn’t find {{name}}",
+ "goneDescription": "It may have been deleted."
}
}
diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts
index b71097c3f5c..b578ab68b8d 100644
--- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts
+++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts
@@ -208,6 +208,8 @@ async function runGitHubCloudCreate(creationId: string): Promise {
const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null)
const opened = workspace?.ok
? await openCloudWorkspace(workspace.value, {
+ // Its pending row shows the open's progress and failure (no open toasts).
+ feedback: false,
// GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off.
onHost: (hostId) =>
useAppStore