diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index a7af401..b5ab2cb 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -5,21 +5,25 @@ name: Build & Release (Beta) # Runs automatically on every push to `testing` (code changes) and can also be # run by hand from Actions → "Build & Release (Beta)" → Run workflow. Each run # computes the next beta version, builds the Windows installer, and publishes a -# GitHub *prerelease* carrying a `beta.yml` manifest. +# GitHub *prerelease* carrying a `testing.yml` manifest. # # Isolation from the stable channel (see .github/workflows/release.yml): -# • Beta versions look like X.Y.Z-beta.N → electron-builder emits `beta.yml`. +# • Beta versions look like X.Y.Z-testing.N → electron-builder emits +# `testing.yml`. The channel is deliberately named "testing" and NOT "beta": +# electron-updater hardcodes "alpha"/"beta" as cascading channels that also +# pull stable releases, which would break isolation. "testing" is treated as +# a fully isolated channel. (See the note in src/main/updater.js.) # • The release is marked --prerelease and is NEVER --latest, so it does not # replace the stable release on the repo's main page. -# • Only installs built from this workflow (channel = beta, see updater.js) +# • Only installs built from this workflow (channel = testing, see updater.js) # detect these updates. Stable installs ignore prereleases entirely. # # Versioning: # • Base X.Y.Z = the next patch above the latest stable release (odometer: # patch 0→99 carries into minor, minor 0→9 carries into major), or the # numeric part of package.json if it is already higher. -# • N = beta counter for that base: first beta is -beta.1, then -beta.2, ... -# A brand-new base restarts the counter at 1. +# • N = beta counter for that base: first beta is -testing.1, then -testing.2, +# ... A brand-new base restarts the counter at 1. # • Manual run with "publish" unchecked → test build (installer artifact only, # no release, no tag). on: @@ -72,7 +76,7 @@ jobs: if [ -z "$BUMP" ]; then BUMP=patch; fi if [ -z "$PUBLISH" ]; then PUBLISH=true; fi - # Numeric part of package.json (drop any -beta suffix). + # Numeric part of package.json (drop any prerelease suffix). PKG=$(node -p "require('./package.json').version" | sed 's/-.*$//') # Latest STABLE release (plain X.Y.Z tags only — beta tags excluded). @@ -109,11 +113,11 @@ jobs: fi # Highest existing beta counter for this base, then +1. - LASTN=$(git tag -l "v$BASE-beta.*" | sed "s/^v$BASE-beta\.//" \ + LASTN=$(git tag -l "v$BASE-testing.*" | sed "s/^v$BASE-testing\.//" \ | grep -E '^[0-9]+$' | sort -n | tail -n1 || true) if [ -z "$LASTN" ]; then N=1; else N=$((LASTN+1)); fi - NEW="$BASE-beta.$N" + NEW="$BASE-testing.$N" echo "version=$NEW" >> "$GITHUB_OUTPUT" echo "tag=v$NEW" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 702e445..f5987e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,18 +9,23 @@ WaterDrop ships on two independent auto-update channels, one per branch: (marked `--latest`) with versions like `X.Y.Z` and a `latest.yml` manifest. - **beta** — the `testing` branch. Pushing to `testing` triggers `.github/workflows/release-beta.yml`, which publishes a GitHub *prerelease* - (never `--latest`) with versions like `X.Y.Z-beta.N` and a `beta.yml` + (never `--latest`) with versions like `X.Y.Z-testing.N` and a `testing.yml` manifest. The channels are fully isolated: `src/main/updater.js` sets -`autoUpdater.channel` from the running build's own version (a `-beta` suffix → -`beta` channel, otherwise `latest`). A beta install only ever sees new betas; a -stable install only ever sees new stable releases. There is intentionally **no** -`generateUpdatesFilesForAllChannels`, so neither channel writes the other's -`.yml`. Promoting a feature = land it on `testing` first, then merge/push to -`main` for the stable release. +`autoUpdater.channel` from the running build's own version (prerelease tag → +that channel, otherwise `latest`). A beta install only ever sees new betas; a +stable install only ever sees new stable releases. Promoting a feature = land it +on `testing` first, then merge/push to `main` for the stable release. + +**Do not rename the beta channel to "alpha" or "beta".** electron-updater's +GitHub provider hardcodes those two identifiers as *cascading* channels: a +client on `beta` is forced to also accept stable releases and fall back to +`latest.yml`, which breaks the isolation. The channel is therefore called +`testing` (any name other than alpha/beta works). The GitHub release title still +reads "(beta)" for humans. Beta versioning: base `X.Y.Z` is the next patch above the latest stable release -(or `package.json` if higher), and `N` is a per-base beta counter that restarts -at 1 for each new base. This is handled automatically by the workflow — no -manual version edits needed for betas. +(or `package.json` if higher), and `N` is a per-base counter that restarts at 1 +for each new base. This is handled automatically by the workflow — no manual +version edits needed for betas. diff --git a/package-lock.json b/package-lock.json index c0d603e..4ac0a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waterdrop", - "version": "0.1.7", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waterdrop", - "version": "0.1.7", + "version": "0.1.9", "license": "SEE LICENSE IN LICENSE", "dependencies": { "busboy": "^1.6.0", diff --git a/package.json b/package.json index d8da351..bfddd87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waterdrop", - "version": "0.1.8", + "version": "0.1.9", "description": "Private AirDrop-style file shelf for Tailscale networks.", "main": "src/main/main.js", "scripts": { diff --git a/src/main/updater.js b/src/main/updater.js index 431063b..88a7caa 100644 --- a/src/main/updater.js +++ b/src/main/updater.js @@ -84,12 +84,21 @@ function initUpdater(window) { autoUpdater.autoInstallOnAppQuit = true; // Channel isolation: the build's own version decides which release channel it - // follows. A beta build (version like `1.2.3-beta.4`) reads only `beta.yml` - // from prerelease GitHub Releases; a stable build (`1.2.3`) reads only - // `latest.yml` from full releases. Neither ever sees the other's updates. - const isBeta = app.getVersion().includes("-"); - autoUpdater.channel = isBeta ? "beta" : "latest"; - autoUpdater.allowPrerelease = isBeta; // beta must opt in to prerelease GitHub Releases + // follows. A prerelease build (e.g. `1.2.3-testing.4`) follows a custom + // channel named after its prerelease tag ("testing") and reads only + // `testing.yml` from prerelease GitHub Releases. A stable build (`1.2.3`) + // follows "latest" and reads only `latest.yml`. + // + // The channel name must NOT be "alpha"/"beta": electron-updater's GitHub + // provider hardcodes those two as cascading channels — a beta client is made + // to also accept stable releases and fall back to `latest.yml`, which would + // defeat the isolation. Any other name is treated as a fully isolated channel. + const version = app.getVersion(); + const prereleaseTag = version.includes("-") + ? version.split("-")[1].split(".")[0] + : null; + autoUpdater.channel = prereleaseTag || "latest"; + autoUpdater.allowPrerelease = prereleaseTag !== null; wireAutoUpdaterEvents(); diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index dd27fc6..432ece5 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -34,6 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import logoUrl from "./logo.png"; import { PAGE_UPLOAD_LOCK_MS, + STALE_UPLOAD_LOCK_MS, claimQueuedUpload, clearQueuedUpload, isUploadQueueSupported, @@ -41,6 +42,7 @@ import { markQueuedUploadFailed, markQueuedUploadSyncing, queueUpload, + recoverInterruptedUploads, requestBackgroundFetchUpload, requestBackgroundUploadSync, touchQueuedUploadLock, @@ -55,6 +57,7 @@ const TAILSCALE_ANDROID_URL = "https://play.google.com/store/apps/details?id=com // First-run setup guide: Welcome → This PC → Phone → Publish → Finish. const ONBOARDING_STEPS = ["Welcome", "This PC", "Phone", "Publish", "Finish"]; const LAST_ONBOARD_STEP = ONBOARDING_STEPS.length - 1; +const UPLOAD_STALL_MS = Math.max(90 * 1000, STALE_UPLOAD_LOCK_MS); const apiDefault = { getInfo: () => request("api/info"), @@ -93,6 +96,7 @@ export default function App() { const noticeTimeoutRef = useRef(null); const serviceWorkerRegistrationRef = useRef(null); const drainingUploadsRef = useRef(false); + const lastUploadPauseNoticeRef = useRef(0); const [api, setApi] = useState(apiDefault); const [appInfo, setAppInfo] = useState(null); const [info, setInfo] = useState(null); @@ -173,6 +177,13 @@ export default function App() { } }, []); + const notifyUploadPaused = useCallback((message = "Upload paused. It will retry when possible.") => { + const now = Date.now(); + if (now - lastUploadPauseNoticeRef.current < 30 * 1000) return; + lastUploadPauseNoticeRef.current = now; + notify(message, "warn"); + }, [notify]); + const startBackgroundFetchUpload = useCallback(async (record) => { const registration = serviceWorkerRegistrationRef.current; if (!registration?.backgroundFetch?.fetch) return false; @@ -202,6 +213,7 @@ export default function App() { if (drainingUploadsRef.current || !api.rawUploadUrl || !isUploadQueueSupported()) return; drainingUploadsRef.current = true; try { + await recoverInterruptedUploads("Upload interrupted by app suspend"); const records = await listQueuedUploads(); for (const record of records) { const activeBackgroundFetch = await serviceWorkerRegistrationRef.current?.backgroundFetch @@ -213,7 +225,7 @@ export default function App() { await uploadQueuedRecord({ api, record: claimed, - notify, + notifyUploadPaused, refreshFiles, registerBackgroundUpload, setUploads, @@ -223,7 +235,7 @@ export default function App() { drainingUploadsRef.current = false; await refreshQueuedUploads(); } - }, [api, notify, refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); + }, [api, notifyUploadPaused, refreshFiles, refreshQueuedUploads, registerBackgroundUpload]); useEffect(() => { if (!("serviceWorker" in navigator) || !window.isSecureContext) { @@ -307,7 +319,12 @@ export default function App() { useEffect(() => { if (busy) return undefined; const refreshQuietly = () => refreshFiles({ silent: true }); - const interval = window.setInterval(refreshQuietly, window.EventSource ? 15000 : 3000); + const retryUploadsQuietly = () => { + registerBackgroundUpload(); + refreshQueuedUploads().then(drainQueuedUploads); + }; + const refreshInterval = window.setInterval(refreshQuietly, window.EventSource ? 15000 : 3000); + const uploadRetryInterval = window.setInterval(retryUploadsQuietly, 15000); let events = null; if (window.EventSource && api.eventsUrl) { events = new EventSource(api.eventsUrl); @@ -317,18 +334,18 @@ export default function App() { if (document.visibilityState === "visible") { refreshQuietly(); refreshInfo({ silent: true }); - refreshQueuedUploads().then(drainQueuedUploads); + retryUploadsQuietly(); } }; const onOnline = () => { - registerBackgroundUpload(); - refreshQueuedUploads().then(drainQueuedUploads); + retryUploadsQuietly(); }; window.addEventListener("online", onOnline); window.addEventListener("focus", onOnline); document.addEventListener("visibilitychange", onVisible); return () => { - window.clearInterval(interval); + window.clearInterval(refreshInterval); + window.clearInterval(uploadRetryInterval); events?.close(); window.removeEventListener("online", onOnline); window.removeEventListener("focus", onOnline); @@ -546,6 +563,19 @@ export default function App() { } } + async function dismissUpload(upload) { + if (!upload) return; + if (upload.queued) { + try { + await clearQueuedUpload(upload.id); + } catch (err) { + notify(err.message || "Could not remove upload", "warn"); + return; + } + } + setUploads((items) => items.filter((item) => item.id !== upload.id)); + } + function uploadOneLegacy(file) { const id = crypto.randomUUID(); setUploads((items) => [ @@ -866,6 +896,11 @@ export default function App() { {uploadStatusText(upload)} + {canDismissUpload(upload) && ( + + )}
@@ -1573,7 +1608,7 @@ async function request(url, options = {}) { async function uploadQueuedRecord({ api, record, - notify, + notifyUploadPaused, refreshFiles, registerBackgroundUpload, setUploads, @@ -1590,12 +1625,53 @@ async function uploadQueuedRecord({ try { await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); + let settled = false; + let lastActivityAt = Date.now(); + + const cleanup = () => { + window.clearInterval(stallTimer); + window.removeEventListener("offline", onOffline); + window.removeEventListener("pagehide", onPageHide); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + const settle = (handler, value) => { + if (settled) return; + settled = true; + cleanup(); + handler(value); + }; + const fail = (message) => { + if (settled) return; + try { + xhr.abort(); + } catch {} + settle(reject, new Error(message)); + }; + const markActivity = () => { + lastActivityAt = Date.now(); + }; + const onOffline = () => fail("Upload paused while offline"); + const onPageHide = () => fail("Upload paused while app was backgrounded"); + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") fail("Upload paused while app was backgrounded"); + }; + const stallTimer = window.setInterval(() => { + if (Date.now() - lastActivityAt > UPLOAD_STALL_MS) { + fail("Upload paused after the connection stopped responding"); + } + }, 5000); + + window.addEventListener("offline", onOffline); + window.addEventListener("pagehide", onPageHide); + document.addEventListener("visibilitychange", onVisibilityChange); + xhr.open("POST", uploadUrl, true); xhr.setRequestHeader("Content-Type", record.mimeType || "application/octet-stream"); xhr.setRequestHeader("X-WaterDrop-File-Name", encodeURIComponent(record.name || "unnamed-file")); xhr.setRequestHeader("X-WaterDrop-Mime-Type", record.mimeType || "application/octet-stream"); xhr.setRequestHeader("X-WaterDrop-Upload-Id", record.id); xhr.upload.onprogress = (event) => { + markActivity(); if (!event.lengthComputable) return; const progress = Math.round((event.loaded / event.total) * 100); setUploads((items) => upsertUpload(items, { ...view, status: "uploading", progress })); @@ -1604,15 +1680,16 @@ async function uploadQueuedRecord({ updateQueuedUploadProgress(record.id, progress).catch(() => {}); } }; + xhr.upload.onload = markActivity; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - resolve(); + settle(resolve); return; } - reject(new Error(`HTTP ${xhr.status}`)); + settle(reject, new Error(`HTTP ${xhr.status}`)); }; - xhr.onerror = () => reject(new Error("Network error")); - xhr.onabort = () => reject(new Error("Upload aborted")); + xhr.onerror = () => settle(reject, new Error("Network error")); + xhr.onabort = () => settle(reject, new Error("Upload aborted")); xhr.send(record.blob); }); @@ -1626,7 +1703,7 @@ async function uploadQueuedRecord({ const queued = await markQueuedUploadFailed(record.id, err.message || "Upload paused"); if (queued) setUploads((items) => upsertUpload(items, uploadRecordToView(queued))); await registerBackgroundUpload(); - notify("Upload paused. It will retry when possible.", "warn"); + notifyUploadPaused?.(); } finally { window.clearInterval(lockTimer); } @@ -1650,6 +1727,10 @@ function uploadStatusText(upload) { return `${upload.progress || 0}%`; } +function canDismissUpload(upload) { + return upload.status === "queued" || upload.status === "error"; +} + function isWaterDropDrag(event) { return Array.from(event.dataTransfer?.types || []).includes("application/x-waterdrop-file"); } @@ -1735,7 +1816,17 @@ function shortHash(hash) { function formatAppVersion(version) { const raw = String(version || ""); - const match = raw.match(/^(\d+)\.(\d+)\.(\d+)$/); - if (!match) return raw; - return `${Number(match[1])}.${Number(match[2])}.${String(Number(match[3])).padStart(2, "0")}`; + // Stable release: pad the patch to two digits (e.g. 0.1.2 -> 0.1.02). + const stable = raw.match(/^(\d+)\.(\d+)\.(\d+)$/); + if (stable) { + return `${Number(stable[1])}.${Number(stable[2])}.${String(Number(stable[3])).padStart(2, "0")}`; + } + // Beta channel: its internal id is "-testing.N" (electron-updater reserves + // "beta" for cascading updates), but users see it labelled as "beta". + const beta = raw.match(/^(\d+)\.(\d+)\.(\d+)-testing\.(\d+)$/); + if (beta) { + const base = `${Number(beta[1])}.${Number(beta[2])}.${String(Number(beta[3])).padStart(2, "0")}`; + return `${base}-beta.${Number(beta[4])}`; + } + return raw; } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index abab4b5..a227e6d 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -604,6 +604,12 @@ body::before { .upload-status.queued { color: var(--faint); } +.upload-dismiss { + width: 26px; + height: 26px; + border-color: transparent; + background: transparent; +} .upload-tail { display: flex; align-items: center; diff --git a/src/renderer/src/uploadQueue.js b/src/renderer/src/uploadQueue.js index ac969e5..cacd45d 100644 --- a/src/renderer/src/uploadQueue.js +++ b/src/renderer/src/uploadQueue.js @@ -3,6 +3,7 @@ const DB_VERSION = 1; const STORE_NAME = "uploads"; export const PAGE_UPLOAD_LOCK_MS = 45 * 1000; +export const STALE_UPLOAD_LOCK_MS = 2 * PAGE_UPLOAD_LOCK_MS; export const UPLOAD_SYNC_TAG = "waterdrop-upload-queue"; let dbPromise = null; @@ -40,6 +41,24 @@ export async function listQueuedUploads() { .then((items) => items.sort((a, b) => Number(a.createdAt || 0) - Number(b.createdAt || 0))); } +export async function recoverInterruptedUploads(message = "Upload interrupted") { + if (!isUploadQueueSupported()) return 0; + const records = await listQueuedUploads(); + const now = Date.now(); + let recovered = 0; + + for (const record of records) { + if (record.status !== "uploading") continue; + const updatedAt = Number(record.updatedAt || record.createdAt || 0); + const lockedUntil = Number(record.lockedUntil || 0); + if (lockedUntil > now && now - updatedAt <= STALE_UPLOAD_LOCK_MS) continue; + await markQueuedUploadFailed(record.id, message); + recovered += 1; + } + + return recovered; +} + export async function claimQueuedUpload(id) { const record = await getUploadRecord(id); if (!record) return null;