From 466aeac6caeb4bf1796ebdc70fe0015892a648c1 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 18 Aug 2026 16:53:33 +0200 Subject: [PATCH] A store registry record needs no repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 25 +++++---- apps/api/app/admin/stores.rb | 12 +++-- .../app/controllers/api/stores_controller.rb | 12 +++-- apps/api/app/models/store.rb | 13 +++-- apps/api/app/serializers/store_serializer.rb | 4 +- ...20000_allow_stores_without_a_repository.rb | 10 ++++ apps/api/db/schema.rb | 4 +- .../controllers/stores_controller_spec.rb | 8 +++ apps/api/spec/models/store_spec.rb | 8 ++- apps/frontend/src/i18n/locales/en.ts | 7 +++ apps/frontend/src/i18n/locales/hu.ts | 7 +++ .../src/page/stores/StoresIndexPage.vue | 53 ++++++++++++++++++- 12 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 apps/api/db/migrate/20260818120000_allow_stores_without_a_repository.rb diff --git a/README.md b/README.md index acfe86e..d1cf389 100644 --- a/README.md +++ b/README.md @@ -32,19 +32,22 @@ nobody to log in as. ```json [ - { - "name": "Teletype Games", - "catalogUrl": "https://teletypegames.org", - "storeRepositoryUrl": "https://git.teletypegames.org/stores/ttg-desktop-store" - } + { "name": "Teletype Games", "catalogUrl": "https://teletypegames.org", "storeRepositoryUrl": null } ] ``` -A `Store` row is three fields — `name`, `catalog_url`, `store_repository_url` — -maintained from **ActiveAdmin ▸ 🛒 Stores**, and `db/seeds.rb` creates our own. -The client derives everything else: it reads `config.json` from the store -repository, points it at `catalog_url`, and falls back to the engine's defaults if -that repository has no config file. +A `Store` row has two required fields — `name` and `catalog_url` — plus an +**optional** `store_repository_url`, maintained from **ActiveAdmin ▸ 🛒 Stores**; +`db/seeds.rb` creates our own. + +**A store does not need a repository.** The store engine's own defaults already +cover the host-to-asset mapping, the install modes, the platforms and the +behaviour; what they cannot know is identity — a slug, a name and a catalog — and +that is what this row carries. With no repository the client derives the slug from +the catalog host, writes a small config and installs. Given one, it reads that +repository's `config.json` and points it at `catalog_url`, and that file remains the +authority on how the store behaves; a repository without a config file is treated +as no repository at all. This lives in the host app **on purpose, not in WarpEngine**. The engine serves one catalog and has no business knowing which stores exist for it; who ships a @@ -55,7 +58,7 @@ store for a catalog is a property of the site. | Model | `apps/api/app/models/store.rb` | | Endpoint | `apps/api/app/controllers/api/stores_controller.rb` | | Admin | `apps/api/app/admin/stores.rb` | -| Client | [`warp-engine-desktop-gui`](https://git.teletypegames.org/stores/warp-engine-desktop-gui) | +| Client | [`warp-engine-client`](https://git.teletypegames.org/stores/warp-engine-client) | ## Development environment diff --git a/apps/api/app/admin/stores.rb b/apps/api/app/admin/stores.rb index 66922fe..3e0804f 100644 --- a/apps/api/app/admin/stores.rb +++ b/apps/api/app/admin/stores.rb @@ -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 diff --git a/apps/api/app/controllers/api/stores_controller.rb b/apps/api/app/controllers/api/stores_controller.rb index b1b38c1..5663d22 100644 --- a/apps/api/app/controllers/api/stores_controller.rb +++ b/apps/api/app/controllers/api/stores_controller.rb @@ -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 diff --git a/apps/api/app/models/store.rb b/apps/api/app/models/store.rb index 3f207a4..2ac9d5d 100644 --- a/apps/api/app/models/store.rb +++ b/apps/api/app/models/store.rb @@ -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) } diff --git a/apps/api/app/serializers/store_serializer.rb b/apps/api/app/serializers/store_serializer.rb index 1d95252..9023ce8 100644 --- a/apps/api/app/serializers/store_serializer.rb +++ b/apps/api/app/serializers/store_serializer.rb @@ -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 diff --git a/apps/api/db/migrate/20260818120000_allow_stores_without_a_repository.rb b/apps/api/db/migrate/20260818120000_allow_stores_without_a_repository.rb new file mode 100644 index 0000000..bbaaf76 --- /dev/null +++ b/apps/api/db/migrate/20260818120000_allow_stores_without_a_repository.rb @@ -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 diff --git a/apps/api/db/schema.rb b/apps/api/db/schema.rb index 1b4b1a9..53e7d62 100644 --- a/apps/api/db/schema.rb +++ b/apps/api/db/schema.rb @@ -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" diff --git a/apps/api/spec/controllers/stores_controller_spec.rb b/apps/api/spec/controllers/stores_controller_spec.rb index 21bf238..0af44dd 100644 --- a/apps/api/spec/controllers/stores_controller_spec.rb +++ b/apps/api/spec/controllers/stores_controller_spec.rb @@ -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) diff --git a/apps/api/spec/models/store_spec.rb b/apps/api/spec/models/store_spec.rb index f3bc387..38f781b 100644 --- a/apps/api/spec/models/store_spec.rb +++ b/apps/api/spec/models/store_spec.rb @@ -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") diff --git a/apps/frontend/src/i18n/locales/en.ts b/apps/frontend/src/i18n/locales/en.ts index ad073f3..e5b4cbf 100644 --- a/apps/frontend/src/i18n/locales/en.ts +++ b/apps/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/apps/frontend/src/i18n/locales/hu.ts b/apps/frontend/src/i18n/locales/hu.ts index e8d12e4..8c79f31 100644 --- a/apps/frontend/src/i18n/locales/hu.ts +++ b/apps/frontend/src/i18n/locales/hu.ts @@ -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ó', diff --git a/apps/frontend/src/page/stores/StoresIndexPage.vue b/apps/frontend/src/page/stores/StoresIndexPage.vue index 605445d..e01d03c 100644 --- a/apps/frontend/src/page/stores/StoresIndexPage.vue +++ b/apps/frontend/src/page/stores/StoresIndexPage.vue @@ -49,6 +49,33 @@ + +
+

+ {{ t('stores.clientTitle') }} +

+

{{ t('stores.clientDesc') }}

+
    +
  • {{ t('stores.clientPointSetup') }}
  • +
  • {{ t('stores.clientPointBrowse') }}
  • +
  • {{ t('stores.clientPointSame') }}
  • +
+ +

{{ t('stores.clientNote') }}

+
+

1 {{ 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(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; }