Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ bun run site:build # store/site/dist, which the Wor

A merge to `main` that touches the store deploys it, migrations first (`.github/workflows/store.yml`); `main` takes changes through pull requests only.

An app's page there has one way to install it, a button that opens Pocket Hub; that hand-off is not open yet, and the button says so. [docs/applets.md](docs/applets.md) has the store's routes, the website, the publishing rules, the sample data format and what an installed app can reach.
An app's page there has one way to install it, a button that opens Pocket Hub on the app (a `pocket-hub://store/app/<id>` link); Hub installs it from there. [docs/applets.md](docs/applets.md) has the store's routes, the website, the publishing rules, the sample data format and what an installed app can reach.

## License

Expand Down
2 changes: 1 addition & 1 deletion docs/applets.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ bun run typecheck:site

`wrangler dev` and `wrangler deploy` in `store/` run the build themselves (`build` in `store/wrangler.jsonc`), so `store/site/dist` is never committed. What the build copies as it is lives in `store/site/public/`: the response headers (`_headers`), the favicons and the applet player.

**An app's page says what the app is and nothing about which device it fits.** It has the icon, title, summary and publisher, a button that installs it, the app running on its sample data when it has some, and its version, size and date. **Installing is Pocket Hub's**: the button opens Hub, and Hub decides whether a device can run the app and installs it. That hand-off is not open yet, so `hubInstallLink` in `store/site/src/hub.ts` returns no link and the button reads "Not open yet". The page gives no install steps of its own.
**An app's page says what the app is and nothing about which device it fits.** It has the icon, title, summary and publisher, a button that installs it, the app running on its sample data when it has some, and its version, size and date. **Installing is Pocket Hub's**: the button opens Hub, and Hub decides whether a device can run the app and installs it. The button is a link, `pocket-hub://store/app/<id>`, that the system opens Pocket Hub's Mac app with; `hubInstallLink` in `store/site/src/hub.ts` is the one place that builds it, and Pocket Hub's `docs/DEEP-LINKS.md` is the contract for it. The link carries the app's id and nothing else, and following it only shows the app in Hub: the install is a click there, on a device Hub chose. A browser does not tell a page whether such a link opened anything, so once a visitor has followed it the page says what should have happened and what is missing when nothing did: Pocket Hub, which is not released yet. The page gives no install steps of its own.

**What a package says about itself is one level down**, in a sheet the page's "more" menu opens (`?details=compatibility`, `access`, `versions` or `package`), read from `/v1/manifests` and `/v1/apps/<id>/versions` when it opens: the device of each variant (`rg-ds-plus` is Omarchy Pocket, `tab5-home` the M5Stack Tab5, `3ds-dev` the Nintendo 3DS) with its host ABI, the capabilities the app needs and the ones it uses where a device has them, each with what it lets the app do, and the surface it lays out for; the hosts and services it reaches; every listed version with its package to download; and the id, publisher key, package hash, framework, applet API version and, for each build, its sections' sizes, build hash and profile hash.

Expand Down
43 changes: 9 additions & 34 deletions store/site/src/components/InstallButton.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
<script setup lang="ts">
// The way an app reaches a device: this button opens Pocket Hub, and Hub does
// the rest. Until that hand-off opens (hub.ts) the button holds its place and
// says it is not open yet.
// The way an app reaches a device: this button is a link that opens Pocket
// Hub on the app (hub.ts), and Hub does the rest. The browser follows it like
// any link to another program, asking the visitor first, and tells the page
// nothing of what came of it, so the page is only told that it was followed.
import { computed } from "vue";
import { useLanguage } from "../i18n/index.ts";
import type { StoreApp } from "../api.ts";
import { hubInstallLink } from "../hub.ts";
import UiIcon from "./UiIcon.vue";

const props = defineProps<{ app: StoreApp }>();
defineEmits<{ follow: [] }>();
const link = computed(() => hubInstallLink(props.app));
const { t } = useLanguage();
</script>

<template>
<a v-if="link" class="install" :href="link">
<a class="install" :href="link" @click="$emit('follow')">
<UiIcon name="download" :size="22" />
<span class="words"><strong>{{ t("install.withHub") }}</strong></span>
<strong>{{ t("install.withHub") }}</strong>
</a>
<button v-else class="install" type="button" disabled>
<UiIcon name="download" :size="22" />
<span class="words"><small>{{ t("install.notOpen") }}</small><strong>{{ t("install.withHub") }}</strong></span>
</button>
</template>

<style scoped>
Expand All @@ -34,40 +32,17 @@ const { t } = useLanguage();
border-radius: 16px;
background: var(--accent);
color: var(--on-accent);
text-align: left;
transition: background 0.15s, transform 0.15s var(--ease);
}

a.install:hover {
.install:hover {
background: var(--accent-hover);
}

a.install:active {
.install:active {
transform: scale(0.98);
}

.install:disabled {
background: var(--fill);
color: var(--text-2);
}

.install:disabled .glyph {
color: var(--text-3);
}

.words {
display: grid;
}

small {
font-size: 11px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
}

strong {
font-size: 15.5px;
font-weight: 600;
Expand Down
22 changes: 15 additions & 7 deletions store/site/src/hub.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
// Installing is Pocket Hub's work, not this page's: the page hands Hub the
// app, and Hub decides whether a device can run it and installs it. That
// hand-off is not open yet, so there is no link to give and the install button
// (components/InstallButton.vue) says so. When it opens, this is the one
// place that changes.
// app, and Hub decides whether a device can run it and installs it. The
// hand-off is a link the system opens Pocket Hub with, and this is the one
// place that builds it.
//
// The link and what Hub does with it are Pocket Hub's docs/DEEP-LINKS.md
// (§1 the link, §2 what Hub does): pocket-hub://store/app/<id>. It carries
// the app's id and nothing else, since Hub reads everything about the app out
// of its own catalog, and following it only shows the app in Hub: installing
// stays the owner's click there.
import type { StoreApp } from "./api.ts";

/** The link that opens Pocket Hub on this app, or null while there is none. */
export function hubInstallLink(_app: StoreApp): string | null {
return null;
/** The link that opens Pocket Hub on this app. */
export function hubInstallLink(app: StoreApp): string {
// An id the store lists needs no encoding (store/src/shared.ts ID). One that
// somehow held a "?" or a "#" must not read as another app's id with a
// query, which Hub ignores: encoded, Hub refuses the link instead.
return `pocket-hub://store/app/${encodeURIComponent(app.id)}`;
}
2 changes: 1 addition & 1 deletion store/site/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default {
},
install: {
withHub: "Install with Pocket Hub",
notOpen: "Not open yet",
followed: "Pocket Hub should open on this applet now. If nothing happens, this computer has no Pocket Hub: it is a Mac app, and it is not released yet.",
},
player: {
firstScreen: "{title}'s first screen",
Expand Down
2 changes: 1 addition & 1 deletion store/site/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const zhCN: Wording = {
},
install: {
withHub: "通过 Pocket Hub 安装",
notOpen: "暂未开放",
followed: "Pocket Hub 现在应该会打开并显示这个 Applet。如果没有反应,说明这台电脑上还没有 Pocket Hub:它是一款 Mac 应用,目前尚未发布。",
},
player: {
firstScreen: "{title} 的首屏",
Expand Down
38 changes: 37 additions & 1 deletion store/site/src/pages/AppPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
// Nothing here says which devices it fits or what it asks of them: Pocket Hub
// settles that at install, and the curious find it one level down, in the
// details sheet the "more" menu opens (?details=<section>).
//
// The button opens Pocket Hub on the app (hub.ts). A browser does not say
// whether a link to another program opened one, so once the visitor has
// followed it the page says what should have happened, and what is missing
// when nothing did.
import { computed, onBeforeUnmount, ref } from "vue";
import { useLanguage } from "../i18n/index.ts";
import { RouterLink, useRoute, useRouter } from "vue-router";
Expand Down Expand Up @@ -63,6 +68,9 @@ function shut() {
else void router.replace({ query: {} });
}

/** The visitor followed the button's link into Pocket Hub. */
const followed = ref(false);

const copied = ref(false);
let fading: ReturnType<typeof setTimeout> | undefined;
async function copyLink() {
Expand Down Expand Up @@ -116,10 +124,11 @@ onBeforeUnmount(() => clearTimeout(fading));
<span v-if="app.official" class="visually-hidden">{{ t("app.official") }}</span>
</RouterLink>
<div class="actions">
<InstallButton :app="app" />
<InstallButton :app="app" @follow="followed = true" />
<MenuButton :label="t('app.more')" :items="menu" @choose="choose" />
</div>
</div>
<p v-if="followed" class="followed" role="status">{{ t("install.followed") }}</p>
<AppPlayer v-if="app.mock" :app="app" />
</section>

Expand Down Expand Up @@ -275,6 +284,26 @@ h1 {
margin-top: 30px;
}

/* What the page says once the button was followed goes in the row under the
screen, like what the player opens there: the name above it does not move. */
.followed {
grid-area: 2 / 1;
align-self: start;
max-width: 31em;
font-size: 13.5px;
line-height: 1.45;
color: var(--text-2);
text-wrap: pretty;
animation: followed-in 0.3s var(--ease) both;
}

@keyframes followed-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}

.facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
Expand Down Expand Up @@ -433,6 +462,13 @@ h1 {
justify-content: center;
}

/* one column: under the buttons, nearer to them than to the screen */
.followed {
grid-area: auto;
max-width: none;
margin: -14px 0 16px;
}

.ghost-title,
.ghost-line {
margin-top: 0;
Expand Down
30 changes: 30 additions & 0 deletions store/test/hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// The link the website's install button carries into Pocket Hub. Its grammar
// is Pocket Hub's docs/DEEP-LINKS.md §1, and Hub refuses anything outside it,
// so this holds the website to exactly that shape.
import { expect, test } from "bun:test";
import type { StoreApp } from "../site/src/api.ts";
import { hubInstallLink } from "../site/src/hub.ts";
import { ID } from "../src/shared.ts";

const app = (id: string) => ({ id }) as StoreApp;

/** What Hub takes for a link: the scheme, the host `store`, `/app/` and an id of the store's shape. */
const LINK = new RegExp(`^pocket-hub://store/app/(${ID.source.slice(1, -1)})$`);

test("an app's link into Pocket Hub is its id under pocket-hub://store/app", () => {
expect(hubInstallLink(app("dev.pocket-nexus.hn"))).toBe("pocket-hub://store/app/dev.pocket-nexus.hn");
for (const id of ["dev.pocket-nexus.hn", "io.example.pixels", "a1.b2.c3.d4.e5.f6"]) {
expect(ID.test(id)).toBe(true);
expect(hubInstallLink(app(id)).match(LINK)?.[1]).toBe(id);
}
});

test("an id outside the store's shape never reads as another app's link", () => {
// Hub ignores a query and a fragment: unencoded, these would open dev.pocket-nexus.hn.
for (const id of ["dev.pocket-nexus.hn?x=1", "dev.pocket-nexus.hn#top", "dev.pocket-nexus.hn/../x"]) {
const link = hubInstallLink(app(id));
expect(LINK.test(link)).toBe(false);
expect(new URL(link).search + new URL(link).hash).toBe("");
expect(new URL(link).pathname.split("/").length).toBe(3);
}
});
Loading