From 615b50ab4cc3694f384b4f2453d8f49d6fec4c6e Mon Sep 17 00:00:00 2001 From: phantomptr Date: Tue, 16 Jun 2026 16:37:11 -0700 Subject: [PATCH 1/4] feat(queue): cancel a single in-flight upload without stopping the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-item Cancel control to the upload queue. Until now the actively- uploading row had move/remove disabled, so the only way to stop one item was Stop (which halts the whole console). New cancelItem(id): - pending/finished item → just dropped from the queue - running item → aborts its engine job (transfer bails at the next shard boundary, partial tx stays resumable), drops it, and resumes the console's remaining pending work — siblings on other consoles are untouched UI: the active row shows a Cancel (ban) button in place of the locked Remove. Stop-all already existed (the Stop button) and is unchanged. Adds queue_cancel_item to the en.ts catalog + allowlist, and 4 store tests. --- client/src/i18n/locales/en.ts | 1 + client/src/screens/Upload/QueuePanel.tsx | 45 ++++++++-- client/src/state/uploadQueue.test.ts | 101 +++++++++++++++++++++++ client/src/state/uploadQueue.ts | 32 +++++++ scripts/i18n-known-missing.json | 17 ++++ 5 files changed, 187 insertions(+), 9 deletions(-) diff --git a/client/src/i18n/locales/en.ts b/client/src/i18n/locales/en.ts index 2489008c..cf28d8e4 100644 --- a/client/src/i18n/locales/en.ts +++ b/client/src/i18n/locales/en.ts @@ -538,6 +538,7 @@ queue_clear: "Clear all", queue_move_up: "Move up", queue_move_down: "Move down", queue_remove: "Remove from queue", +queue_cancel_item: "Cancel this upload (keeps the rest of the queue going)", activity_clear_running: "Clear running", fs_download_stop: "Stop watching", diff --git a/client/src/screens/Upload/QueuePanel.tsx b/client/src/screens/Upload/QueuePanel.tsx index 6ecb8843..617781da 100644 --- a/client/src/screens/Upload/QueuePanel.tsx +++ b/client/src/screens/Upload/QueuePanel.tsx @@ -13,6 +13,7 @@ import { RotateCcw, ListOrdered, X, + Ban, } from "lucide-react"; import { Button } from "../../components"; @@ -85,6 +86,7 @@ export function QueuePanel() { const stopHost = useUploadQueueStore((s) => s.stopHost); const clear = useUploadQueueStore((s) => s.clear); const remove = useUploadQueueStore((s) => s.remove); + const cancelItem = useUploadQueueStore((s) => s.cancelItem); const moveUp = useUploadQueueStore((s) => s.moveUp); const moveDown = useUploadQueueStore((s) => s.moveDown); const retryFailed = useUploadQueueStore((s) => s.retryFailed); @@ -243,6 +245,7 @@ export function QueuePanel() { onMoveUp={moveUp} onMoveDown={moveDown} onRemove={remove} + onCancel={cancelItem} /> ))} @@ -263,6 +266,7 @@ function ConsoleGroup({ onMoveUp, onMoveDown, onRemove, + onCancel, }: { host: string; items: QueueItem[]; @@ -273,6 +277,7 @@ function ConsoleGroup({ onMoveUp: (id: string) => void; onMoveDown: (id: string) => void; onRemove: (id: string) => void; + onCancel: (id: string) => void; }) { const tr = useTr(); const label = useConsoleLabel(host); @@ -312,6 +317,7 @@ function ConsoleGroup({ onMoveUp={() => onMoveUp(item.id)} onMoveDown={() => onMoveDown(item.id)} onRemove={() => onRemove(item.id)} + onCancel={() => onCancel(item.id)} /> ))} @@ -415,11 +421,13 @@ function QueueRow({ onMoveUp, onMoveDown, onRemove, + onCancel, }: { item: QueueItem; onMoveUp: () => void; onMoveDown: () => void; onRemove: () => void; + onCancel: () => void; }) { const tr = useTr(); // Game identity for the row — so you can tell what's what at a glance. @@ -584,15 +592,34 @@ function QueueRow({ > - + {isActive ? ( + // The actively-uploading row: move/remove are locked (mutating the + // array under the runner is unsafe), so Cancel is the only per-item + // control here. It aborts THIS upload (partial transfer stays + // resumable) and lets the console's other pending jobs keep going — + // unlike Stop, which halts the whole console. + + ) : ( + + )} diff --git a/client/src/state/uploadQueue.test.ts b/client/src/state/uploadQueue.test.ts index 74c5a38f..6481bc9f 100644 --- a/client/src/state/uploadQueue.test.ts +++ b/client/src/state/uploadQueue.test.ts @@ -295,6 +295,107 @@ describe("upload runner concurrency (per-console, parallel)", () => { }); }); +// ── Per-item cancel ────────────────────────────────────────────────────────── + +describe("cancelItem (per-item cancel)", () => { + beforeEach(() => { + installLocalStorageStub(); + vi.useFakeTimers(); + mockedJobStatus.mockReset().mockResolvedValue({ + status: "running", + } as Awaited>); + mockedStartFile.mockReset().mockResolvedValue("job"); + useUploadQueueStore.setState({ + items: [], + running: false, + runningHosts: {}, + continueOnFailure: true, + loaded: true, + }); + }); + afterEach(() => { + useUploadQueueStore.getState().stop(); + vi.useRealTimers(); + }); + + const byId = (name: string) => + useUploadQueueStore.getState().items.find((i) => i.displayName === name)!; + + it("removes a PENDING item without disturbing the running one", async () => { + addItem("192.168.1.10:9113", "A1"); + addItem("192.168.1.10:9113", "A2"); // pending behind A1 (same console) + + void useUploadQueueStore.getState().startHost("192.168.1.10"); + await vi.advanceTimersByTimeAsync(50); + expect(itemsByStatus("running").map((i) => i.displayName)).toEqual(["A1"]); + + useUploadQueueStore.getState().cancelItem(byId("A2").id); + await vi.advanceTimersByTimeAsync(10); + + // A2 gone, A1 still uploading. + expect( + useUploadQueueStore.getState().items.map((i) => i.displayName), + ).toEqual(["A1"]); + expect(itemsByStatus("running").map((i) => i.displayName)).toEqual(["A1"]); + }); + + it("cancels the RUNNING item and resumes the console's next pending", async () => { + addItem("192.168.1.10:9113", "A1"); + addItem("192.168.1.10:9113", "A2"); + + void useUploadQueueStore.getState().startHost("192.168.1.10"); + await vi.advanceTimersByTimeAsync(50); + expect(byId("A1").status).toBe("running"); + + useUploadQueueStore.getState().cancelItem(byId("A1").id); + await vi.advanceTimersByTimeAsync(200); + + // A1 dropped; A2 now the running item; console still active. + expect( + useUploadQueueStore.getState().items.map((i) => i.displayName), + ).toEqual(["A2"]); + expect(itemsByStatus("running").map((i) => i.displayName)).toEqual(["A2"]); + expect(useUploadQueueStore.getState().runningHosts).toEqual({ + "192.168.1.10": true, + }); + }); + + it("cancelling the only running item leaves the console idle", async () => { + addItem("192.168.1.10:9113", "A1"); + + void useUploadQueueStore.getState().startHost("192.168.1.10"); + await vi.advanceTimersByTimeAsync(50); + + useUploadQueueStore.getState().cancelItem(byId("A1").id); + await vi.advanceTimersByTimeAsync(100); + + expect(useUploadQueueStore.getState().items).toHaveLength(0); + expect(useUploadQueueStore.getState().running).toBe(false); + expect(useUploadQueueStore.getState().runningHosts).toEqual({}); + }); + + it("does not touch a sibling console when cancelling a running item", async () => { + addItem("192.168.1.10:9113", "A1"); + addItem("192.168.1.20:9113", "B1"); + + void useUploadQueueStore.getState().start(); + await vi.advanceTimersByTimeAsync(50); + expect(itemsByStatus("running")).toHaveLength(2); + + useUploadQueueStore.getState().cancelItem(byId("A1").id); + await vi.advanceTimersByTimeAsync(100); + + // A1 gone, B1 keeps uploading untouched. + expect( + useUploadQueueStore.getState().items.map((i) => i.displayName), + ).toEqual(["B1"]); + expect(itemsByStatus("running").map((i) => i.displayName)).toEqual(["B1"]); + expect(useUploadQueueStore.getState().runningHosts).toEqual({ + "192.168.1.20": true, + }); + }); +}); + // ── Runner: auto-resume after failure ──────────────────────────────────────── describe("upload runner auto-resume", () => { diff --git a/client/src/state/uploadQueue.ts b/client/src/state/uploadQueue.ts index c587fd1e..c8e2783c 100644 --- a/client/src/state/uploadQueue.ts +++ b/client/src/state/uploadQueue.ts @@ -263,6 +263,12 @@ interface QueueState { hydrate: () => Promise; add: (item: AddQueueItem) => void; remove: (id: string) => void; + /** Cancel a single item and drop it from the queue. If it's the one + * actively uploading, its in-flight engine job is aborted (at the next + * shard boundary, partial tx left resumable) and that console's remaining + * pending work keeps draining — unlike Stop, which halts the whole console. + * A pending/finished item is just removed. */ + cancelItem: (id: string) => void; moveUp: (id: string) => void; moveDown: (id: string) => void; clear: () => void; @@ -1159,6 +1165,32 @@ export const useUploadQueueStore = create((set, get) => { scheduleSave(); }, + cancelItem(id) { + const item = get().items.find((it) => it.id === id); + if (!item) return; + const h = hostOf(item.addr); + // A pending / done / failed item isn't touching the wire — just drop it. + // (Pending removal also keeps the drain loop from ever claiming it.) + if (item.status !== "running") { + set((s) => ({ items: removeItem(s.items, id) })); + scheduleSave(); + return; + } + // The actively-uploading item. The transfer port is single-client, so + // there is exactly one running item per console. Tear this console's loop + // down the same way Stop does (aborts the in-flight job, re-stamps the + // generation so runOne bails at its next await), drop the cancelled item, + // then resume the console so its OTHER pending jobs aren't held hostage by + // cancelling this one. + const wasRunning = !!get().runningHosts[h]; + get().stopHost(h); + set((s) => ({ items: removeItem(s.items, id) })); + scheduleSave(); + if (wasRunning && nextPendingForHost(get().items, h)) { + void get().startHost(h); + } + }, + moveUp(id) { // Reorder within the item's OWN console group — the grouped queue // renders each console's rows together, so "up" means "earlier among diff --git a/scripts/i18n-known-missing.json b/scripts/i18n-known-missing.json index f1b3b8a0..2572c030 100644 --- a/scripts/i18n-known-missing.json +++ b/scripts/i18n-known-missing.json @@ -129,6 +129,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -313,6 +314,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -497,6 +499,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -681,6 +684,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -865,6 +869,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1049,6 +1054,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1233,6 +1239,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1417,6 +1424,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1601,6 +1609,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1785,6 +1794,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -1969,6 +1979,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -2153,6 +2164,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -2337,6 +2349,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -2521,6 +2534,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -2705,6 +2719,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -2889,6 +2904,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", @@ -3073,6 +3089,7 @@ "profile.username.slot", "profile.username.slotsTitle", "profile.username.title", + "queue_cancel_item", "queue_installed", "queue_installed_warn", "queue_will_install", From 3e9aafe159aecccb3af2d1da65c31989b6ebf1b4 Mon Sep 17 00:00:00 2001 From: Twice6804 <260297042+Twice6804@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:31:22 -0700 Subject: [PATCH 2/4] ci: publish engine as multi-arch GHCR image on release tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .github/workflows/docker-engine.yml — fires on v* tags and workflow_dispatch. Three jobs: verify – PR-only amd64 build (no push), keeps Dockerfile green build – native amd64 (ubuntu-24.04) + arm64 (ubuntu-24.04-arm) push by digest so arches can run independently merge – stitches digests into a multi-arch manifest tagged :, :, and :latest Image: ghcr.io/phantomptr/ps5upload-engine Note in PR: after first publish, set the GHCR package to Public. Also updates engine/Dockerfile header, README.md, and FAQ.md to reference the published image. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-engine.yml | 162 ++++++++++++++++++++++++++++ FAQ.md | 6 +- README.md | 14 +-- engine/Dockerfile | 1 + 4 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/docker-engine.yml diff --git a/.github/workflows/docker-engine.yml b/.github/workflows/docker-engine.yml new file mode 100644 index 00000000..968040d2 --- /dev/null +++ b/.github/workflows/docker-engine.yml @@ -0,0 +1,162 @@ +name: docker-engine + +# Builds and publishes the ps5upload-engine Docker image to GHCR. +# Tag pushes (v*) publish a multi-arch (amd64 + arm64) manifest. +# PRs that touch engine/ get a build-only smoke-check (no push). +# +# Manual re-run (e.g. after an Actions outage wedges a tag-push run): +# gh workflow run docker-engine.yml -f tag=v3.3.16 +on: + push: + tags: ['v*'] + pull_request: + paths: ['engine/**', '.github/workflows/docker-engine.yml'] + workflow_dispatch: + inputs: + tag: + description: "Release tag to publish (e.g. v3.3.16). Must already exist." + required: true + +permissions: + contents: read + packages: write + +concurrency: + group: docker-engine-${{ github.event.inputs.tag || github.ref }} + cancel-in-progress: true + +jobs: + # ── PR gate: build amd64, no push ────────────────────────────────────────── + verify: + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: engine + file: engine/Dockerfile + platforms: linux/amd64 + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── Per-arch native build, push by digest (no tag yet) ───────────────────── + build: + if: github.event_name != 'pull_request' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, platform: linux/amd64 } + - { os: ubuntu-24.04-arm, platform: linux/arm64 } + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref_name }} + + - name: Prepare environment + run: | + owner="${{ github.repository_owner }}" + echo "IMAGE=ghcr.io/${owner,,}/ps5upload-engine" >> "$GITHUB_ENV" + echo "PLATFORM_PAIR=$(echo '${{ matrix.platform }}' | tr '/' '-')" >> "$GITHUB_ENV" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE }} + + - name: Build and push by digest + id: push + uses: docker/build-push-action@v6 + with: + context: engine + file: engine/Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }} + cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - uses: actions/upload-artifact@v7 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # ── Merge per-arch digests into a multi-arch manifest ────────────────────── + merge: + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + steps: + - name: Resolve tag + id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + raw_tag="$INPUT_TAG" + if ! echo "$raw_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+._A-Za-z0-9]*)?$'; then + echo "::error::Refusing to proceed: '$raw_tag' is not a valid version tag" + exit 1 + fi + version="${raw_tag#v}" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "major_minor=${version%.*}" >> "$GITHUB_OUTPUT" + + - name: Set image name + run: | + owner="${{ github.repository_owner }}" + echo "IMAGE=ghcr.io/${owner,,}/ps5upload-engine" >> "$GITHUB_ENV" + + - uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/metadata-action@v5 + id: meta + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=${{ steps.tag.outputs.version }} + type=raw,value=${{ steps.tag.outputs.major_minor }} + type=raw,value=latest + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect manifest + run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.tag.outputs.version }} diff --git a/FAQ.md b/FAQ.md index 4f7c7ccb..0620faee 100644 --- a/FAQ.md +++ b/FAQ.md @@ -617,8 +617,10 @@ person uploading, another browsing"), no coordination is needed. self-hosted engine)? (3.3.7)** Yes. The app normally launches its transfer engine as a bundled background process on `127.0.0.1:19113`, but you can run that engine somewhere else — a -home server, a NAS, or the tiny Docker image in `engine/` — and point the app at -it in **Settings → Engine URL**. When the URL isn't loopback, the app skips the +home server, a NAS — and point the app at it in **Settings → Engine URL**. +An official multi-arch image (`linux/amd64` + `linux/arm64`) is published at +`ghcr.io/phantomptr/ps5upload-engine` — use `:latest` for the newest release +or `:` to pin (e.g. `docker pull ghcr.io/phantomptr/ps5upload-engine:3.3.16`). When the URL isn't loopback, the app skips the bundled engine and talks to your remote one (cover art included). Two things to know: (1) the engine's API has **no password**, so to let a remote machine reach it you set `PS5UPLOAD_ALLOW_IP` to that machine's IP — do this **only on a diff --git a/README.md b/README.md index 88bb8beb..d31516f0 100644 --- a/README.md +++ b/README.md @@ -444,13 +444,13 @@ below 4.x is obscure and above 12.70 is future work. opening the desktop client. **Q: Can the engine run on a different machine (remote / self-hosted)?** -* Yes (3.3.7+). Host the engine elsewhere — including the tiny scratch - image in `engine/Dockerfile` — and point the desktop app at it via - **Settings → Engine URL**; the app then talks to your engine instead of - the bundled one. To let a remote box reach it, set `PS5UPLOAD_ALLOW_IP` - to that box's IP. **Security:** the engine's API is unauthenticated (it - can read/write/delete PS5 files), so only do this on a trusted LAN — - never expose the engine to the internet. +* Yes (3.3.7+). Host the engine elsewhere and point the desktop app at it via + **Settings → Engine URL**. An official multi-arch image is published at + `ghcr.io/phantomptr/ps5upload-engine` (`:latest` or `:`); you can + also build from `engine/Dockerfile`. To let a remote box reach it, set + `PS5UPLOAD_ALLOW_IP` to that box's IP. **Security:** the engine's API is + unauthenticated (it can read/write/delete PS5 files), so only do this on a + trusted LAN — never expose the engine to the internet. ## Contributing diff --git a/engine/Dockerfile b/engine/Dockerfile index b3d31178..41dbdc72 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -1,4 +1,5 @@ # Minimal scratch image for ps5upload-engine. Build context is engine/. +# Official multi-arch image: docker pull ghcr.io/phantomptr/ps5upload-engine:latest # docker build -t ps5upload-engine engine/ # docker run --rm -p 19113:19113 -e PS5_ADDR=192.168.1.50:9113 ps5upload-engine # Env: PS5UPLOAD_ENGINE_PORT (19113), PS5_ADDR (192.168.137.2:9113), From 9ad5dbcbe9f8d5d3dc040bdb907fe39517e29a11 Mon Sep 17 00:00:00 2001 From: phantomptr Date: Tue, 16 Jun 2026 16:41:42 -0700 Subject: [PATCH 3/4] ci: harden docker-engine workflow + add Makefile docker targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardening of the GHCR publish workflow (#134): - Least privilege: workflow token now read-only by default; packages:write is granted only on the build + merge jobs that actually push. The PR verify job can no longer receive a write-capable token. - New resolve job validates the version tag with a strict regex ONCE and passes trusted outputs (tag, version, image) to build/merge — so the tag never reaches a checkout ref: unvalidated (ref-injection guard), and the lowercased image name is derived via the safe env pattern instead of interpolating github.repository_owner into a shell. - Quote untrusted interpolations through env vars; document the one intentional word-split (manifest create) with a shellcheck directive. actionlint clean. Makefile: add docker-engine / docker-engine-run targets (+ help entry) so the self-hosted image has first-class local build/run parity with what CI ships. --- .github/workflows/docker-engine.yml | 102 +++++++++++++++++++--------- Makefile | 31 +++++++++ 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.github/workflows/docker-engine.yml b/.github/workflows/docker-engine.yml index 968040d2..c38c6c12 100644 --- a/.github/workflows/docker-engine.yml +++ b/.github/workflows/docker-engine.yml @@ -17,9 +17,11 @@ on: description: "Release tag to publish (e.g. v3.3.16). Must already exist." required: true +# Least privilege at the top: read-only by default. Only the jobs that actually +# push to GHCR (build, merge) opt into packages:write below — the PR `verify` +# job inherits read-only and never gets a write-capable token. permissions: contents: read - packages: write concurrency: group: docker-engine-${{ github.event.inputs.tag || github.ref }} @@ -44,26 +46,65 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # ── Resolve + validate the tag ONCE, hand the trusted values to build/merge ─ + # Centralizing this means the tag is regex-validated before it ever reaches a + # checkout `ref:` (no ref injection from a hand-typed workflow_dispatch input), + # and the lowercased image name is computed once via the safe env pattern + # instead of interpolating ${{ github.repository_owner }} into shell. + resolve: + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.t.outputs.tag }} + version: ${{ steps.t.outputs.version }} + major_minor: ${{ steps.t.outputs.major_minor }} + image: ${{ steps.t.outputs.image }} + steps: + - name: Validate tag + derive image name + id: t + env: + INPUT_TAG: ${{ github.event.inputs.tag || github.ref_name }} + OWNER: ${{ github.repository_owner }} + run: | + raw_tag="$INPUT_TAG" + if ! printf '%s' "$raw_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+._A-Za-z0-9]*)?$'; then + echo "::error::Refusing to proceed: '$raw_tag' is not a valid version tag" + exit 1 + fi + version="${raw_tag#v}" + owner_lc="$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')" + { + echo "tag=$raw_tag" + echo "version=$version" + echo "major_minor=${version%.*}" + echo "image=ghcr.io/${owner_lc}/ps5upload-engine" + } >> "$GITHUB_OUTPUT" + # ── Per-arch native build, push by digest (no tag yet) ───────────────────── build: + needs: resolve if: github.event_name != 'pull_request' runs-on: ${{ matrix.os }} + permissions: + contents: read + packages: write strategy: fail-fast: false matrix: include: - { os: ubuntu-24.04, platform: linux/amd64 } - { os: ubuntu-24.04-arm, platform: linux/arm64 } + env: + IMAGE: ${{ needs.resolve.outputs.image }} steps: - uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.tag || github.ref_name }} + ref: ${{ needs.resolve.outputs.tag }} - name: Prepare environment - run: | - owner="${{ github.repository_owner }}" - echo "IMAGE=ghcr.io/${owner,,}/ps5upload-engine" >> "$GITHUB_ENV" - echo "PLATFORM_PAIR=$(echo '${{ matrix.platform }}' | tr '/' '-')" >> "$GITHUB_ENV" + env: + PLATFORM: ${{ matrix.platform }} + run: echo "PLATFORM_PAIR=$(printf '%s' "$PLATFORM" | tr '/' '-')" >> "$GITHUB_ENV" - uses: docker/setup-buildx-action@v3 @@ -91,10 +132,11 @@ jobs: cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }} - name: Export digest + env: + DIGEST: ${{ steps.push.outputs.digest }} run: | mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" + touch "/tmp/digests/${DIGEST#sha256:}" - uses: actions/upload-artifact@v7 with: @@ -105,29 +147,17 @@ jobs: # ── Merge per-arch digests into a multi-arch manifest ────────────────────── merge: - needs: build + needs: [resolve, build] if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + env: + IMAGE: ${{ needs.resolve.outputs.image }} + VERSION: ${{ needs.resolve.outputs.version }} + MAJOR_MINOR: ${{ needs.resolve.outputs.major_minor }} steps: - - name: Resolve tag - id: tag - env: - INPUT_TAG: ${{ github.event.inputs.tag || github.ref_name }} - run: | - raw_tag="$INPUT_TAG" - if ! echo "$raw_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([-+._A-Za-z0-9]*)?$'; then - echo "::error::Refusing to proceed: '$raw_tag' is not a valid version tag" - exit 1 - fi - version="${raw_tag#v}" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "major_minor=${version%.*}" >> "$GITHUB_OUTPUT" - - - name: Set image name - run: | - owner="${{ github.repository_owner }}" - echo "IMAGE=ghcr.io/${owner,,}/ps5upload-engine" >> "$GITHUB_ENV" - - uses: actions/download-artifact@v8 with: path: /tmp/digests @@ -147,16 +177,22 @@ jobs: with: images: ${{ env.IMAGE }} tags: | - type=raw,value=${{ steps.tag.outputs.version }} - type=raw,value=${{ steps.tag.outputs.major_minor }} + type=raw,value=${{ env.VERSION }} + type=raw,value=${{ env.MAJOR_MINOR }} type=raw,value=latest - name: Create and push multi-arch manifest working-directory: /tmp/digests + env: + META_JSON: ${{ steps.meta.outputs.json }} run: | + # Word-splitting here is intentional: the jq output expands to + # `-t tagA -t tagB ...` and the printf to space-separated + # `image@sha256:` operands, each a distinct argv entry. + # shellcheck disable=SC2046 docker buildx imagetools create \ - $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \ - $(printf '${{ env.IMAGE }}@sha256:%s ' *) + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$META_JSON") \ + $(printf "${IMAGE}@sha256:%s " *) - name: Inspect manifest - run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.tag.outputs.version }} + run: docker buildx imagetools inspect "${IMAGE}:${VERSION}" diff --git a/Makefile b/Makefile index 7fd2196d..c877dba2 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,7 @@ ADB ?= $(ANDROID_HOME)/platform-tools/adb .PHONY: android-deps android-init android-build android-deploy _android-install-if-device run-android .PHONY: send-payload gen-fixtures sweep validate validate-xl .PHONY: sync-version sync-version-check +.PHONY: docker-engine docker-engine-run # Default target all: build @@ -162,6 +163,10 @@ help: @echo " make install-engine - Register systemd/launchd/Task Scheduler job" @echo " make uninstall-engine - Remove the auto-launch registration" @echo "" + @echo "Docker (self-hosted engine; CI publishes the same image to GHCR):" + @echo " make docker-engine - Build the engine image locally (scratch + static binary)" + @echo " make docker-engine-run - Run it (PS5_HOST=$(PS5_HOST)); exposes :19113" + @echo "" @echo "Environment overrides (defaults shown):" @echo " PS5_HOST=$(PS5_HOST) PS5 IP address" @echo " PS5_LOADER_PORT=$(PS5_LOADER_PORT) PS5 payload loader port" @@ -542,6 +547,32 @@ run-android: android-deps payload setup-client @echo " Attach one first — check with: adb devices" @cd $(CLIENT_DIR) && $(ANDROID_ENV) npx tauri android dev +#────────────────────────────────────────────────────────────────────────────── +# Docker — self-hosted engine image. Mirrors what .github/workflows/ +# docker-engine.yml publishes to ghcr.io//ps5upload-engine on each +# release tag (multi-arch there; single-arch host build here). `engine/` is the +# build context; the Dockerfile is a scratch image wrapping the static binary. +# +# SECURITY: the engine API is unauthenticated. Bind to a LAN interface and set +# PS5UPLOAD_ALLOW_IP only on a trusted network — never expose it to the +# internet. See the header of engine/Dockerfile. +#────────────────────────────────────────────────────────────────────────────── + +DOCKER ?= docker +DOCKER_ENGINE_IMAGE ?= ps5upload-engine + +docker-engine: + @command -v $(DOCKER) >/dev/null 2>&1 || { echo "ERROR: docker not found on PATH."; exit 1; } + @echo "Building $(DOCKER_ENGINE_IMAGE) image (context: $(ENGINE_DIR)/)..." + @$(DOCKER) build -t $(DOCKER_ENGINE_IMAGE) $(ENGINE_DIR) + @echo "✓ Built image $(DOCKER_ENGINE_IMAGE) — run with: make docker-engine-run" + +# Run the locally-built engine image. Binds the published port and points it at +# the PS5's transfer port. Override PS5_HOST / the bind address as needed. +docker-engine-run: docker-engine + @echo "Running $(DOCKER_ENGINE_IMAGE) — engine on :19113, PS5 at $(PS5_HOST):9113 ..." + @$(DOCKER) run --rm -p 19113:19113 -e PS5_ADDR=$(PS5_HOST):9113 $(DOCKER_ENGINE_IMAGE) + #────────────────────────────────────────────────────────────────────────────── # Testing #────────────────────────────────────────────────────────────────────────────── From 75fd4cb4e29c0c17778d51590395d69b5f373115 Mon Sep 17 00:00:00 2001 From: phantomptr Date: Tue, 16 Jun 2026 16:42:57 -0700 Subject: [PATCH 4/4] Release v3.3.18 - Per-item upload cancel (queue) - Official multi-arch engine Docker image on GHCR (thanks @Twice6804) - Hardened the docker publish workflow; Makefile docker targets --- CHANGELOG.md | 14 ++++++++++++++ VERSION | 2 +- client/package-lock.json | 4 ++-- client/package.json | 2 +- client/src-tauri/Cargo.lock | 10 +++++----- client/src-tauri/Cargo.toml | 2 +- client/src-tauri/tauri.conf.json | 2 +- engine/Cargo.lock | 14 +++++++------- engine/Cargo.toml | 2 +- payload/include/config.h | 2 +- 10 files changed, 34 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c96daee3..6754d8ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ What's new in ps5upload, written for humans. --- +## 3.3.18 + +- **Cancel a single upload without stopping the whole queue.** The item that's + actively uploading now has a Cancel button. It stops just that transfer (the + partial upload stays resumable) and the rest of that console's queue keeps + going — handy when one big item is hogging the line. ("Stop" still halts + everything as before.) +- **A ready-to-run engine Docker image.** The self-hosted transfer engine is + now published as an official multi-arch image at + `ghcr.io/phantomptr/ps5upload-engine` (`:latest` or pin `:`), so you + can run it on a NAS or home server without building from source. As always: + the engine has no password — keep it on a trusted LAN, never the internet. + Thanks to @Twice6804 for the contribution. + ## 3.3.17 UI polish pass — tighter on phones and small windows. diff --git a/VERSION b/VERSION index e9f0bde5..42c4a762 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.3.17 +3.3.18 diff --git a/client/package-lock.json b/client/package-lock.json index 3876b22b..a1f7c286 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ps5upload-client", - "version": "3.3.17", + "version": "3.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ps5upload-client", - "version": "3.3.17", + "version": "3.3.18", "dependencies": { "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/client/package.json b/client/package.json index c6cdbb48..e3d25295 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "ps5upload-client", "private": true, - "version": "3.3.17", + "version": "3.3.18", "description": "The all-in-one PS5 companion app.", "homepage": "https://github.com/phantomptr/ps5upload", "author": "PhantomPtr ", diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 3be5b497..8cb4107c 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "ftx2-proto" -version = "3.3.17" +version = "3.3.18" dependencies = [ "serde", "thiserror 2.0.18", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "ps5upload-core" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "base64 0.22.1", @@ -3336,7 +3336,7 @@ dependencies = [ [[package]] name = "ps5upload-desktop" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "base64 0.22.1", @@ -3369,7 +3369,7 @@ dependencies = [ [[package]] name = "ps5upload-engine" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "axum", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "ps5upload-pkg" -version = "3.3.17" +version = "3.3.18" dependencies = [ "serde", "thiserror 2.0.18", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index ac5fcdd8..deaeab5a 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ps5upload-desktop" -version = "3.3.17" +version = "3.3.18" description = "The all-in-one PS5 companion app." edition = "2021" rust-version = "1.77" diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 7fab6d1f..5c6624a1 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "PS5Upload", - "version": "3.3.17", + "version": "3.3.18", "identifier": "com.phantomptr.ps5upload", "build": { "beforeDevCommand": "npm run dev:vite", diff --git a/engine/Cargo.lock b/engine/Cargo.lock index dfeedbe2..18d93fbb 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -494,7 +494,7 @@ dependencies = [ [[package]] name = "ftx2-proto" -version = "3.3.17" +version = "3.3.18" dependencies = [ "serde", "thiserror", @@ -954,7 +954,7 @@ dependencies = [ [[package]] name = "ps5upload-bench" -version = "3.3.17" +version = "3.3.18" dependencies = [ "criterion", "ftx2-proto", @@ -964,7 +964,7 @@ dependencies = [ [[package]] name = "ps5upload-core" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "base64", @@ -981,7 +981,7 @@ dependencies = [ [[package]] name = "ps5upload-engine" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "axum", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "ps5upload-lab" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "ftx2-proto", @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "ps5upload-pkg" -version = "3.3.17" +version = "3.3.18" dependencies = [ "serde", "serde_json", @@ -1017,7 +1017,7 @@ dependencies = [ [[package]] name = "ps5upload-tests" -version = "3.3.17" +version = "3.3.18" dependencies = [ "anyhow", "ftx2-proto", diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 6510cf07..1c0c6824 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" [workspace.package] edition = "2021" license = "GPL-3.0-or-later" -version = "3.3.17" +version = "3.3.18" [workspace.dependencies] anyhow = "1.0" diff --git a/payload/include/config.h b/payload/include/config.h index 50257e2c..1e9b36b0 100644 --- a/payload/include/config.h +++ b/payload/include/config.h @@ -5,7 +5,7 @@ * UI tell apart an old payload still running from a build that includes * a particular fix, without having to boot the console. Keep in sync * with the desktop app's package.json during releases. */ -#define PS5UPLOAD2_VERSION "3.3.17" +#define PS5UPLOAD2_VERSION "3.3.18" /* Author credit — embedded in the startup toast so anyone looking at * the console screen knows who wrote the software that just loaded. * Kept separate from VERSION so release scripts can bump the version