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) && ( + + )}