A store registry record needs no repository
The client only ever took identity from a store repository — a slug, a name, a catalog — and the store engine's own defaults cover everything else: the host-to-asset mapping, the install modes, the platforms, the behaviour. So `store_repository_url` is now optional: nullable in the schema, no presence validation, the format check only when a value is given, and the serializer answers null rather than an empty string, because the client branches on its absence. Adding a store is therefore a row with two fields filled in. Given a repository the client still reads its config.json, and that file remains the authority on how the store behaves — the admin form and the endpoint's documentation say so. The frontend's /stores page gains a section of its own for the graphical client on the desktop tab: what it does, that it sets the store up itself, that it is the way in on Windows where `curl … | sh` does not exist, and links to the releases, the repository and the documentation — now under stores/warp-engine-client, which is where that repository lives after the rename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,8 +35,10 @@ ActiveAdmin.register Store do
|
||||
row :updated_at
|
||||
end
|
||||
para do
|
||||
"Listed by GET /api/stores, which the desktop client reads on first run: it " \
|
||||
"takes the config from the store repository and points it at the catalog above."
|
||||
"Listed by GET /api/stores, which the desktop client reads on first run. With a " \
|
||||
"store repository the client takes that config and points it at the catalog " \
|
||||
"above; without one it uses the store engine's defaults, which need nothing " \
|
||||
"but the name and the catalog."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,8 +47,10 @@ ActiveAdmin.register Store do
|
||||
f.input :name, hint: "What the client shows in its store picker"
|
||||
f.input :catalog_url, hint: "Base URL of the WarpEngine catalog, e.g. https://teletypegames.org"
|
||||
f.input :store_repository_url,
|
||||
hint: "Repository holding the store's config.json, e.g. " \
|
||||
"https://git.teletypegames.org/stores/ttg-desktop-store"
|
||||
hint: "Optional. A repository holding the store's config.json, e.g. " \
|
||||
"https://git.teletypegames.org/stores/ttg-desktop-store. Leave it " \
|
||||
"empty and the client uses the engine's defaults with the name and " \
|
||||
"catalog above"
|
||||
end
|
||||
f.actions
|
||||
end
|
||||
|
||||
@@ -6,13 +6,19 @@ class Api::StoresController < ApiController
|
||||
api :GET, "/api/stores", "List the stores a client can install from"
|
||||
desc <<~DESC
|
||||
The registry the graphical desktop client reads on first run: which catalogs
|
||||
exist, and where each one's store configuration lives. Public on purpose —
|
||||
a client has nobody to log in as.
|
||||
exist, and — where there is one — the repository holding a store's own
|
||||
configuration. Public on purpose: a client has nobody to log in as.
|
||||
|
||||
A record needs a name and a catalog URL. `storeRepositoryUrl` is optional and
|
||||
answers `null` when there is none; a client then takes identity from this
|
||||
record and everything else from the store engine's built-in defaults, so no
|
||||
repository has to exist for a store to be installable.
|
||||
DESC
|
||||
returns code: 200, desc: "Array of stores" do
|
||||
property :name, String, desc: "Display name of the store"
|
||||
property :catalogUrl, String, desc: "Base URL of the WarpEngine catalog it serves"
|
||||
property :storeRepositoryUrl, String, desc: "Repository holding the store's config.json"
|
||||
property :storeRepositoryUrl, [String, nil],
|
||||
desc: "Repository holding the store's config.json, or null when it has none"
|
||||
end
|
||||
def index
|
||||
render json: StoreService.new.index
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
# A store a client can install from: a WarpEngine catalog, and the repository
|
||||
# holding the store's own configuration.
|
||||
# A store a client can install from: a WarpEngine catalog, and — optionally — a
|
||||
# repository holding that store's own configuration.
|
||||
#
|
||||
# This is deliberately not part of WarpEngine. The engine serves one catalog and
|
||||
# has no business knowing which stores exist for it; the registry is a property of
|
||||
# this site, which is what the graphical client asks.
|
||||
#
|
||||
# The repository is optional because a store does not need one. A client takes
|
||||
# identity from this record — the name, the catalog and a slug derived from it —
|
||||
# and everything else from the store engine's built-in defaults. A repository is
|
||||
# still honoured when given: it remains the authority on how that store behaves,
|
||||
# which platforms it offers and where things land.
|
||||
class Store < ApplicationRecord
|
||||
URL = %r{\Ahttps?://\S+\z}
|
||||
|
||||
validates :name, presence: true
|
||||
validates :catalog_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
|
||||
validates :store_repository_url, presence: true, format: { with: URL, message: "must be an http(s) URL" }
|
||||
validates :store_repository_url, format: { with: URL, message: "must be an http(s) URL" },
|
||||
allow_blank: true
|
||||
|
||||
default_scope { where(deleted_at: nil) }
|
||||
|
||||
|
||||
@@ -5,5 +5,7 @@ class StoreSerializer < Blueprinter::Base
|
||||
# camelCase, as the catalog's own payloads use — one convention for a client
|
||||
# that reads both.
|
||||
field(:catalogUrl) { |store| store.catalog_url }
|
||||
field(:storeRepositoryUrl) { |store| store.store_repository_url }
|
||||
# Null rather than an empty string when there is no repository: the client
|
||||
# branches on its absence, and "" is not an absence a JSON reader can trust.
|
||||
field(:storeRepositoryUrl) { |store| store.store_repository_url.presence }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class AllowStoresWithoutARepository < ActiveRecord::Migration[8.1]
|
||||
# A store repository is now optional. The client only ever needed identity from
|
||||
# it — a name, a catalog and a slug — and the engine's own defaults cover
|
||||
# everything else, so a record with a catalog URL is a complete store. A
|
||||
# repository is still honoured when there is one: it stays the authority on how
|
||||
# that store behaves.
|
||||
def change
|
||||
change_column_null :stores, :store_repository_url, true
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_18_000001) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_08_18_120000) do
|
||||
create_table "admin_users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
@@ -286,7 +286,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_18_000001) do
|
||||
t.datetime "created_at", precision: 3, null: false
|
||||
t.datetime "deleted_at", precision: 3
|
||||
t.string "name", null: false
|
||||
t.string "store_repository_url", null: false
|
||||
t.string "store_repository_url"
|
||||
t.datetime "updated_at", precision: 3, null: false
|
||||
t.index ["deleted_at"], name: "idx_stores_deleted_at"
|
||||
t.index ["name"], name: "idx_stores_name"
|
||||
|
||||
@@ -24,6 +24,14 @@ RSpec.describe Api::StoresController, type: :request do
|
||||
expect(store["storeRepositoryUrl"]).to end_with("/stores/ttg-desktop-store")
|
||||
end
|
||||
|
||||
it "answers null for a store with no repository" do
|
||||
create(:store, store_repository_url: nil)
|
||||
|
||||
get "/api/stores"
|
||||
|
||||
expect(JSON.parse(response.body).first["storeRepositoryUrl"]).to be_nil
|
||||
end
|
||||
|
||||
it "leaves out soft-deleted stores" do
|
||||
create(:store, name: "Gone").update!(deleted_at: Time.current)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ require "rails_helper"
|
||||
RSpec.describe Store, type: :model do
|
||||
it { should validate_presence_of(:name) }
|
||||
it { should validate_presence_of(:catalog_url) }
|
||||
it { should validate_presence_of(:store_repository_url) }
|
||||
|
||||
it "rejects a catalog url that is not http(s)" do
|
||||
store = build(:store, catalog_url: "git@example.org:thing.git")
|
||||
@@ -15,6 +14,13 @@ RSpec.describe Store, type: :model do
|
||||
expect(build(:store, store_repository_url: "not a url")).not_to be_valid
|
||||
end
|
||||
|
||||
# A store without a repository is a complete store: the client takes identity
|
||||
# from the record and everything else from the engine's defaults.
|
||||
it "accepts a store with no repository at all" do
|
||||
expect(build(:store, store_repository_url: nil)).to be_valid
|
||||
expect(build(:store, store_repository_url: "")).to be_valid
|
||||
end
|
||||
|
||||
describe ".ordered" do
|
||||
it "lists stores by name" do
|
||||
later = create(:store, name: "Zed Games")
|
||||
|
||||
@@ -203,6 +203,13 @@ export default {
|
||||
subtitle: 'Our games on your own machine, kept up to date.',
|
||||
lead: 'Two stores put our catalog on the two things that play it: a Batocera box, and RetroArch on anything else — desktop Linux, Windows, macOS, Android, a Steam Deck or a handheld. Both download the games straight from this site with box art, add them to the device\'s own library, and can be re-run any time to pick up new releases. Python 3 standard library only — nothing to install alongside them.',
|
||||
gui: 'Graphical client',
|
||||
clientTitle: 'A window, if you would rather not type',
|
||||
clientDesc: 'WarpEngine Store is a small desktop application that does everything the command line does: the catalog as a grid of cards, one click to install a game into your own application menu, one to play it, one to remove it. It is also the way in on Windows, where `curl … | sh` does not exist.',
|
||||
clientPointSetup: 'Sets the store up itself on first run — nothing to install beforehand.',
|
||||
clientPointBrowse: 'Filter by platform, by what is installed, or by what has an update.',
|
||||
clientPointSame: 'The same store underneath, so the command line keeps working on the same games.',
|
||||
clientDownload: 'Download the app',
|
||||
clientNote: 'Python 3 has to be on the machine, as it does for the command line — the store itself is a Python program and the window drives it. The macOS build is signed but not notarised, so macOS asks before opening it the first time; the download page says how.',
|
||||
chooseTitle: 'Pick your device',
|
||||
repo: 'Git repository',
|
||||
docs: 'Documentation',
|
||||
|
||||
@@ -203,6 +203,13 @@ export default {
|
||||
subtitle: 'A játékaink a saját gépeden, mindig frissen.',
|
||||
lead: 'Két store teszi fel a katalógusunkat arra a kettőre, ami le is játssza: egy Batocera gépre, és RetroArch-ra minden máson — asztali Linuxon, Windowson, macOS-en, Androidon, Steam Decken vagy egy kézikonzolon. Mindkettő közvetlenül erről az oldalról tölti le a játékokat borítóképpel, felveszi őket a gép saját könyvtárába, és bármikor újrafuttatható az új kiadásokért. Csak a Python 3 alapkönyvtárát használják — nem kell mellé semmit telepíteni.',
|
||||
gui: 'Grafikus kliens',
|
||||
clientTitle: 'Ablak, ha nem szeretnél gépelni',
|
||||
clientDesc: 'A WarpEngine Store egy kis asztali alkalmazás, ami mindent tud, amit a parancssor: a katalógus kártyákban, egy kattintás a telepítés a saját alkalmazásmenüdbe, egy az indítás, egy az eltávolítás. Windowson ez az egyetlen út is, mert ott a `curl … | sh` nem létezik.',
|
||||
clientPointSetup: 'Első indításkor magát a store-t is beállítja — előre semmit nem kell telepíteni.',
|
||||
clientPointBrowse: 'Szűrhetsz platformra, a telepítettekre vagy arra, amihez frissítés van.',
|
||||
clientPointSame: 'Alatta ugyanaz a store, tehát a parancssor ugyanazokon a játékokon dolgozik tovább.',
|
||||
clientDownload: 'Alkalmazás letöltése',
|
||||
clientNote: 'Python 3 kell a gépre, ugyanúgy, mint a parancssorhoz — a store maga egy Python program, az ablak csak vezérli. A macOS-csomag aláírt, de nem notarizált, ezért a rendszer az első megnyitás előtt rákérdez; a letöltési oldal leírja, hogyan.',
|
||||
chooseTitle: 'Válaszd ki a gépedet',
|
||||
repo: 'Git tároló',
|
||||
docs: 'Dokumentáció',
|
||||
|
||||
@@ -49,6 +49,33 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The window is a product of its own, not a footnote to the install command:
|
||||
on Windows it is the only way in, because `curl … | sh` does not exist there. -->
|
||||
<section v-if="client" class="st-section st-client">
|
||||
<h2 class="st-section-title">
|
||||
<i class="fa-solid fa-window-maximize st-engine-icon"></i> {{ t('stores.clientTitle') }}
|
||||
</h2>
|
||||
<p class="st-section-desc">{{ t('stores.clientDesc') }}</p>
|
||||
<ul class="st-client-points">
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('stores.clientPointSetup') }}</li>
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('stores.clientPointBrowse') }}</li>
|
||||
<li><i class="fa-solid fa-check"></i> {{ t('stores.clientPointSame') }}</li>
|
||||
</ul>
|
||||
<div class="st-links">
|
||||
<a :href="client.releasesUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="st-link st-link-emerald">
|
||||
<i class="fa-solid fa-download"></i> {{ t('stores.clientDownload') }}
|
||||
</a>
|
||||
<a :href="client.repoUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-dark">
|
||||
<i class="fa-solid fa-code-branch"></i> {{ t('stores.repo') }}
|
||||
</a>
|
||||
<a :href="client.wikiUrl" target="_blank" rel="noopener noreferrer" class="st-link st-link-indigo">
|
||||
<i class="fa-solid fa-book"></i> {{ t('stores.docs') }}
|
||||
</a>
|
||||
</div>
|
||||
<p class="st-note">{{ t('stores.clientNote') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="st-section">
|
||||
<h2 class="st-section-title">
|
||||
<span class="st-step">1</span> {{ t('stores.installTitle') }}
|
||||
@@ -128,6 +155,15 @@ const router = useRouter()
|
||||
const FORGE = 'https://git.teletypegames.org/stores'
|
||||
const ENGINES_FORGE = 'https://git.teletypegames.org/engines'
|
||||
|
||||
// The graphical client. Its own thing rather than a link on the desktop store: it
|
||||
// drives any WarpEngine store the site's registry offers, and on Windows it is the
|
||||
// only way in — there is no `curl … | sh` there.
|
||||
const CLIENT = {
|
||||
repoUrl: `${FORGE}/warp-engine-client`,
|
||||
releasesUrl: `${FORGE}/warp-engine-client/releases`,
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/warp-engine-client`,
|
||||
}
|
||||
|
||||
type DeviceId = 'desktop' | 'batocera' | 'retroarch'
|
||||
|
||||
const BATOCERA_CLI = '/userdata/system/batocera-store/ttg-store'
|
||||
@@ -160,7 +196,7 @@ const devices = [
|
||||
wikiUrl: `${CONFIG.wikiBase}/stores/ttg-desktop-store`,
|
||||
installCmd: `curl -fsSL ${FORGE}/ttg-desktop-store/raw/branch/master/install.sh | sh`,
|
||||
afterInstallCmd: '',
|
||||
guiUrl: `${FORGE}/warp-engine-desktop-gui/releases`,
|
||||
guiUrl: CLIENT.releasesUrl,
|
||||
uninstallCmd: `curl -fsSL ${FORGE}/ttg-desktop-store/raw/branch/master/uninstall.sh | sh`,
|
||||
cliSnippet: [
|
||||
`${DESKTOP_CLI} paths # where things go on this machine`,
|
||||
@@ -194,6 +230,9 @@ const initial = devices.some((d) => d.id === route.query.device)
|
||||
: 'desktop'
|
||||
const device = ref<DeviceId>(initial)
|
||||
const current = computed(() => devices.find((d) => d.id === device.value) ?? devices[0])
|
||||
// Only the desktop store has a window; the other two run on devices with no desktop
|
||||
// to put one on.
|
||||
const client = computed(() => (device.value === 'desktop' ? CLIENT : null))
|
||||
|
||||
function select(id: DeviceId) {
|
||||
device.value = id
|
||||
@@ -271,6 +310,18 @@ const engines = [
|
||||
.st-engine {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.st-client {
|
||||
@apply bg-emerald-50/60 border-emerald-100;
|
||||
}
|
||||
.st-client-points {
|
||||
@apply space-y-2 mb-5;
|
||||
}
|
||||
.st-client-points li {
|
||||
@apply flex items-start gap-2 text-gray-700;
|
||||
}
|
||||
.st-client-points i {
|
||||
@apply text-emerald-500 mt-1 text-sm;
|
||||
}
|
||||
.st-engine-icon {
|
||||
@apply text-gray-400 text-lg;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user