mirror of
https://github.com/knutwurst/patchdl.git
synced 2026-10-06 07:00:26 +02:00
Add pause/resume, within-part byte-range resume; bump to 0.0.3
Split the single morphing button into a green/amber play-pause (Update → Pause → Resume) and a red stop (Cancel). Pause aborts the download but keeps the partial (resumable); Cancel aborts and deletes. Backend gets a separate pause flag distinct from cancel. Resume now continues WITHIN a part: the partially-written piece is fetched from its last byte via an HTTP byte range (with a safe fall back to re-fetching the whole piece if the CDN ignores the range), instead of re-downloading the whole part. A title is resumable as soon as any bytes are on disk. Version bumped to 0.0.3 (no release tagged).
This commit is contained in:
1 parent
66e4912485
commit
01eaef79f2
7 files changed
+164
-58
No files matched your search
@@ -79,7 +79,7 @@ http://<console-ip>:12880/
|
||||
|
||||
## Status
|
||||
|
||||
0.0.2, early. Title scan, source classification, version resolution,
|
||||
0.0.3, early. Title scan, source classification, version resolution,
|
||||
firmware-compatibility filtering, target/storage-id handling, and the local
|
||||
AppInstUtil HTTP stream have been verified on firmware 11.60. PatchDL now
|
||||
downloads PS5 update manifests as merged piece packages under `/data/patchdl`;
|
||||
|
||||
+69
-22
@@ -344,14 +344,20 @@ curl_progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
|
||||
}
|
||||
|
||||
/* Returns 0 on success, -1 on download/network failure, -2 when an expected
|
||||
SHA-256 was given and the downloaded bytes did not match it. */
|
||||
SHA-256 was given and the downloaded bytes did not match it, -3 when a byte
|
||||
range was requested (range_start>0) but the server ignored it (no HTTP 206).
|
||||
When range_start>0 the body is appended at the file's current position, so
|
||||
the caller must have it positioned at range_start and must not verify. */
|
||||
static int
|
||||
http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
progress_state_t *progress,
|
||||
const char *expected_sha256_hex) {
|
||||
const char *expected_sha256_hex,
|
||||
long long range_start) {
|
||||
CURL *curl;
|
||||
CURLcode res;
|
||||
char host[256], ip[INET_ADDRSTRLEN], rs443[512], rs80[512];
|
||||
char range_hdr[48];
|
||||
long http_code = 0;
|
||||
struct curl_slist *rl = NULL;
|
||||
struct curl_blob ca_blob;
|
||||
curl_off_t dl = 0;
|
||||
@@ -400,6 +406,10 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "patchdl/1.0");
|
||||
if (range_start > 0) {
|
||||
snprintf(range_hdr, sizeof(range_hdr), "%lld-", range_start);
|
||||
curl_easy_setopt(curl, CURLOPT_RANGE, range_hdr);
|
||||
}
|
||||
if (progress && progress->cb) {
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_progress_cb);
|
||||
@@ -407,6 +417,7 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
}
|
||||
|
||||
res = curl_easy_perform(curl);
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_getinfo(curl, CURLINFO_SIZE_DOWNLOAD_T, &dl);
|
||||
curl_easy_cleanup(curl);
|
||||
curl_slist_free_all(rl);
|
||||
@@ -415,6 +426,12 @@ http_download_to_file_progress(const char *url, FILE *fp, long long *bytes_out,
|
||||
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||
return -1;
|
||||
}
|
||||
/* Asked for a byte range but the server sent the whole file (no 206): the
|
||||
caller must drop the piece and re-fetch it whole. */
|
||||
if (range_start > 0 && http_code != 206) {
|
||||
if (sink.md) EVP_MD_CTX_free(sink.md);
|
||||
return -3;
|
||||
}
|
||||
|
||||
if (sink.md) {
|
||||
unsigned char dig[EVP_MAX_MD_SIZE];
|
||||
@@ -440,7 +457,7 @@ patchdl_http_download_progress(const char *url, const char *dest_path,
|
||||
int rc;
|
||||
|
||||
if (!fp) return -1;
|
||||
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL);
|
||||
rc = http_download_to_file_progress(url, fp, bytes_out, &progress, NULL, 0);
|
||||
fclose(fp);
|
||||
|
||||
if (rc) {
|
||||
@@ -531,9 +548,9 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
json_u64_after(manifest.data, "originalFileSize", &manifest_total);
|
||||
|
||||
/* Resume: reopen the existing partial and keep its bytes; else start clean.
|
||||
Pieces already fully on disk are skipped, and the one piece that was only
|
||||
partially written is dropped and re-fetched whole (piece-granular resume,
|
||||
no HTTP range needed). */
|
||||
Fully-downloaded pieces are skipped; the one piece that was only partially
|
||||
written continues mid-piece via an HTTP byte range (with a fall back to
|
||||
re-fetching it whole if the CDN ignores the range). */
|
||||
if (resume) {
|
||||
fp = fopen(dest_path, "r+b");
|
||||
if (fp) { fseek(fp, 0, SEEK_END); have = ftell(fp); if (have < 0) have = 0; }
|
||||
@@ -546,10 +563,11 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
while ((p = strstr(p, "\"url\"")) && (!pieces_end || p < pieces_end)) {
|
||||
char url[768];
|
||||
char hash[80] = {0};
|
||||
long long got = 0;
|
||||
long long got = 0, range_start = 0;
|
||||
unsigned long long expected = 0;
|
||||
unsigned long long offset = 0;
|
||||
int have_offset, drc;
|
||||
const char *want_hash;
|
||||
const char *obj_end = strchr(p, '}');
|
||||
|
||||
if (json_string_after(p, "url", url, sizeof(url)))
|
||||
@@ -568,39 +586,68 @@ patchdl_http_download_manifest_progress(const char *manifest_url,
|
||||
continue;
|
||||
}
|
||||
|
||||
/* First piece we must (re)download: drop any partial bytes of it so the
|
||||
appended data lines up exactly, then append from here on. */
|
||||
/* First piece to (re)download while resuming. If part of it is already
|
||||
on disk, resume WITHIN it with a byte range; otherwise drop any stray
|
||||
bytes and fetch it whole. After this, every piece is fetched whole. */
|
||||
if (!started) {
|
||||
long long start_at = have_offset ? (long long)offset : 0;
|
||||
fflush(fp);
|
||||
if (ftruncate(fileno(fp), (off_t)start_at) != 0)
|
||||
goto done; /* can't resume cleanly; keep partial */
|
||||
fseek(fp, 0, SEEK_END);
|
||||
total = start_at;
|
||||
if (have_offset && expected && have > (long long)offset &&
|
||||
have < (long long)(offset + expected)) {
|
||||
range_start = have - (long long)offset; /* this piece's bytes on disk */
|
||||
fseek(fp, 0, SEEK_END); /* append at `have` */
|
||||
total = have;
|
||||
} else {
|
||||
long long start_at = have_offset ? (long long)offset : 0;
|
||||
fflush(fp);
|
||||
if (ftruncate(fileno(fp), (off_t)start_at) != 0)
|
||||
goto done; /* can't resume cleanly; keep partial */
|
||||
fseek(fp, 0, SEEK_END);
|
||||
total = start_at;
|
||||
}
|
||||
started = 1;
|
||||
}
|
||||
|
||||
/* Pieces are concatenated in array order; each one's fileOffset must
|
||||
equal the bytes written so far, or the package would be corrupt. */
|
||||
if (have_offset && offset != (unsigned long long)total)
|
||||
/* Whole pieces are concatenated in array order; a ranged (partial) piece
|
||||
starts mid-piece, so the contiguity guard applies only to whole ones. */
|
||||
if (have_offset && range_start == 0 && offset != (unsigned long long)total)
|
||||
goto done;
|
||||
|
||||
if (verify)
|
||||
/* A ranged piece can't be hashed (only its tail is fetched). */
|
||||
want_hash = NULL;
|
||||
if (range_start == 0 && verify) {
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
want_hash = hash[0] ? hash : NULL;
|
||||
}
|
||||
|
||||
{
|
||||
progress_state_t progress = {
|
||||
cb, ctx, total, manifest_total ? (long long)manifest_total : 0
|
||||
};
|
||||
/* drc: 0 ok, -1 network/cancel, -2 SHA-256 mismatch. */
|
||||
/* drc: 0 ok, -1 network/cancel, -2 SHA-256, -3 range ignored. */
|
||||
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||
hash[0] ? hash : NULL);
|
||||
want_hash, range_start);
|
||||
if (drc == -3) {
|
||||
/* Server ignored the range: drop the piece and fetch it whole. */
|
||||
fflush(fp);
|
||||
if (ftruncate(fileno(fp), (off_t)offset) != 0)
|
||||
goto done;
|
||||
fseek(fp, 0, SEEK_END);
|
||||
total = (long long)offset;
|
||||
range_start = 0;
|
||||
if (verify) {
|
||||
json_string_after(p, "hashValue", hash, sizeof(hash));
|
||||
want_hash = hash[0] ? hash : NULL;
|
||||
}
|
||||
progress.base = total;
|
||||
drc = http_download_to_file_progress(url, fp, &got, &progress,
|
||||
want_hash, 0);
|
||||
}
|
||||
}
|
||||
if (drc) {
|
||||
if (drc == -2) rc = -2;
|
||||
goto done;
|
||||
}
|
||||
if (expected && (unsigned long long)got != expected)
|
||||
/* range_start + got = this piece's bytes now on disk. */
|
||||
if (expected && (unsigned long long)(range_start + got) != expected)
|
||||
goto done;
|
||||
|
||||
total += got;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#pragma once
|
||||
|
||||
#define PATCHDL_VERSION "0.0.2"
|
||||
#define PATCHDL_VERSION "0.0.3"
|
||||
+41
-7
@@ -43,7 +43,8 @@ static struct {
|
||||
|
||||
static struct {
|
||||
int active;
|
||||
int cancel; /* set by a cancel request; the worker aborts */
|
||||
int cancel; /* set by a cancel request; worker aborts + deletes */
|
||||
int pause; /* set by a pause request; worker aborts, keeps partial */
|
||||
char title_id[32];
|
||||
char name[128];
|
||||
char version[16];
|
||||
@@ -788,19 +789,19 @@ url_is_manifest(const char *url) {
|
||||
return n > 5 && !strcmp(url + n - 5, ".json");
|
||||
}
|
||||
|
||||
/* Returns non-zero to abort the download when a cancel has been requested. */
|
||||
/* Returns non-zero to abort the download when a cancel OR pause is requested. */
|
||||
static int
|
||||
download_progress_cb(void *ctx, long long downloaded, long long total) {
|
||||
int cancel;
|
||||
int abort_now;
|
||||
(void)ctx;
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
if (g_dl.active) {
|
||||
g_dl.downloaded = downloaded;
|
||||
g_dl.total = total;
|
||||
}
|
||||
cancel = g_dl.cancel;
|
||||
abort_now = g_dl.cancel || g_dl.pause;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
return cancel;
|
||||
return abort_now;
|
||||
}
|
||||
|
||||
/* Delete a title's internal download directory and its contents (a partial or
|
||||
@@ -857,6 +858,23 @@ do_cancel(struct MHD_Connection *conn, const char *title_id) {
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
|
||||
}
|
||||
|
||||
/* Pause an in-progress download: the worker aborts but the partial is kept on
|
||||
disk (and marked resumable), so it can be continued later. */
|
||||
static enum MHD_Result
|
||||
do_pause(struct MHD_Connection *conn, const char *title_id) {
|
||||
int active = 0;
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
if (g_dl.active && !strcmp(g_dl.title_id, title_id)) {
|
||||
g_dl.pause = 1;
|
||||
active = 1;
|
||||
}
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
if (!active)
|
||||
return queue_json(conn, MHD_HTTP_CONFLICT,
|
||||
"{\"ok\":false,\"reason\":\"not_downloading\"}");
|
||||
return queue_json(conn, MHD_HTTP_OK, "{\"ok\":true,\"paused\":true}");
|
||||
}
|
||||
|
||||
/* ---------- resume sidecar (/data/patchdl/<title>/state.json) ----------- */
|
||||
|
||||
static long long
|
||||
@@ -985,15 +1003,18 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
: patchdl_http_download_progress(patch_url, dest, &bytes,
|
||||
download_progress_cb, NULL);
|
||||
if (dlrc) {
|
||||
int was_cancel;
|
||||
int was_cancel, was_pause;
|
||||
long long have;
|
||||
pthread_mutex_lock(&g_mutex);
|
||||
was_cancel = g_dl.cancel;
|
||||
was_pause = g_dl.pause;
|
||||
g_dl.active = 0;
|
||||
g_dl.cancel = 0;
|
||||
g_dl.pause = 0;
|
||||
pthread_mutex_unlock(&g_mutex);
|
||||
|
||||
if (was_cancel) {
|
||||
/* user cancelled: drop the partial + its resume sidecar */
|
||||
/* user cancelled (stop): drop the partial + its resume sidecar */
|
||||
unlink(dest);
|
||||
remove_dl_state(title_id);
|
||||
rmdir(dir);
|
||||
@@ -1002,6 +1023,16 @@ do_download(struct MHD_Connection *conn, const char *title_id,
|
||||
"{\"ok\":false,\"cancelled\":true,"
|
||||
"\"reason\":\"download_cancelled\"}");
|
||||
}
|
||||
if (was_pause) {
|
||||
/* user paused: keep the partial + sidecar; it is now resumable. */
|
||||
have = file_size(dest);
|
||||
if (have < 0) have = 0;
|
||||
set_title_resumable(title_id, have > 0, have);
|
||||
snprintf(resp, sizeof(resp),
|
||||
"{\"ok\":false,\"paused\":true,\"reason\":\"download_paused\","
|
||||
"\"bytes\":%lld}", have);
|
||||
return queue_json_owned(conn, MHD_HTTP_OK, strdup(resp));
|
||||
}
|
||||
if (dlrc == -2) {
|
||||
/* corrupt data (failed SHA-256): don't keep it for resume */
|
||||
unlink(dest);
|
||||
@@ -1152,6 +1183,9 @@ handle_title_action(struct MHD_Connection *conn, const char *url) {
|
||||
if (!strcmp(action, "cancel"))
|
||||
return do_cancel(conn, title_id);
|
||||
|
||||
if (!strcmp(action, "pause"))
|
||||
return do_pause(conn, title_id);
|
||||
|
||||
if (!strcmp(action, "check"))
|
||||
return queue_json(conn, MHD_HTTP_ACCEPTED,
|
||||
"{\"ok\":true,\"queued\":true,\"action\":\"check\"}");
|
||||
|
||||
+44
-22
@@ -325,8 +325,8 @@ function createGameCard(game) {
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "card-actions";
|
||||
const act = tileButton(game);
|
||||
if (act) {
|
||||
[primaryButton(game), stopButton(game)].forEach((act) => {
|
||||
if (!act) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `row-button is-${act.variant}`;
|
||||
btn.textContent = act.label;
|
||||
@@ -334,16 +334,7 @@ function createGameCard(game) {
|
||||
if (act.disabled) btn.disabled = true;
|
||||
else btn.addEventListener("click", () => runTitleAction(game.title_id, act.action));
|
||||
actions.appendChild(btn);
|
||||
}
|
||||
// Delete a finished or paused (partial) download.
|
||||
if ((game.downloaded || game.resumable) && !game.installing && !game.downloading) {
|
||||
const del = document.createElement("button");
|
||||
del.className = "row-button is-ghost";
|
||||
del.textContent = "Delete";
|
||||
del.title = game.resumable ? "Delete the partial download" : "Delete the downloaded package";
|
||||
del.addEventListener("click", () => cancelDownload(game.title_id));
|
||||
actions.appendChild(del);
|
||||
}
|
||||
});
|
||||
|
||||
row.append(lead, body, actions);
|
||||
card.appendChild(row);
|
||||
@@ -394,23 +385,32 @@ function buildToggle(game) {
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// The single morphing action button: blue Update/Download/Install, or amber
|
||||
// Cancel while a download runs. Fixed width (CSS) so the label never reflows.
|
||||
function tileButton(game) {
|
||||
// Primary play/pause button (green to go, amber while downloading). Fixed width
|
||||
// (CSS) so the label never reflows. Returns null when there is nothing to do.
|
||||
function primaryButton(game) {
|
||||
if (game.installing) return { label: "Installing…", variant: "ghost", disabled: true };
|
||||
if (game.downloading) return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop the download and delete the partial file" };
|
||||
if (game.downloading) return { label: "Pause", action: "pause", variant: "pause", hint: "Pause the download (keeps what was downloaded)." };
|
||||
if (game.patch_title_match === false) return null;
|
||||
if (!isInstallAllowed(game)) return null;
|
||||
if (game.resumable)
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Continue the paused download where it stopped." };
|
||||
if (game.downloaded && game.status === "available")
|
||||
return { label: "Install", action: "install", variant: "update", hint: "Install the downloaded patch (modifies the game)." };
|
||||
if (game.resumable)
|
||||
return { label: "Resume", action: "download", variant: "update", hint: "Resume the interrupted download where it stopped." };
|
||||
if (game.status !== "available") return null;
|
||||
return state.config.install_after_download
|
||||
? { label: "Update", action: "update", variant: "update", hint: "Download and install the update." }
|
||||
: { label: "Download", action: "download", variant: "update", hint: "Download the patch internally." };
|
||||
}
|
||||
|
||||
// Red stop button: present whenever there is a download to stop or discard.
|
||||
// Cancel stops AND deletes (unlike Pause, which keeps the partial).
|
||||
function stopButton(game) {
|
||||
if (game.installing) return null;
|
||||
if (game.downloading || game.resumable || game.downloaded)
|
||||
return { label: "Cancel", action: "cancel", variant: "cancel", hint: "Stop and delete the download." };
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusPill(game) {
|
||||
if (game.installing) return `<span class="pill warn">Installing…</span>`;
|
||||
if (game.status === "checking") return `<span class="pill">Checking…</span>`;
|
||||
@@ -606,12 +606,20 @@ async function doDownload(game) {
|
||||
game._localDownloading = false;
|
||||
state.downloads = state.downloads.filter((i) => i.title_id !== game.title_id);
|
||||
|
||||
// Cancel / soft failure returns HTTP 200 with ok:false (not thrown).
|
||||
// Cancel / pause / soft failure returns HTTP 200 with ok:false (not thrown).
|
||||
if (!r || r.ok === false) {
|
||||
game.downloaded = false;
|
||||
const what = r && r.cancelled ? "cancelled" : "failed";
|
||||
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
|
||||
showToast(`${game.name}: download ${what}.`);
|
||||
if (r && r.paused) {
|
||||
// Paused: keep the partial, mark resumable so the tile shows Resume.
|
||||
game.resumable = true;
|
||||
game.partial_bytes = r.bytes || game.partial_bytes || 0;
|
||||
state.logs.push(`[${timeNow()}] Download paused: ${game.title_id} (${formatBytes(game.partial_bytes)})`);
|
||||
showToast(`${game.name}: paused at ${formatBytes(game.partial_bytes)}.`);
|
||||
} else {
|
||||
const what = r && r.cancelled ? "cancelled" : "failed";
|
||||
state.logs.push(`[${timeNow()}] Download ${what}: ${game.title_id}`);
|
||||
showToast(`${game.name}: download ${what}.`);
|
||||
}
|
||||
renderGames(); renderLogs(); stopDownloadPolling();
|
||||
return false;
|
||||
}
|
||||
@@ -674,10 +682,22 @@ async function runTitleAction(titleId, action) {
|
||||
if (!game) return;
|
||||
if (action === "download") await doDownload(game);
|
||||
else if (action === "install") await doInstall(game);
|
||||
else if (action === "pause") await doPause(game);
|
||||
else if (action === "cancel") await cancelDownload(titleId);
|
||||
else if (action === "update") { if (await doDownload(game)) await doInstall(game); }
|
||||
}
|
||||
|
||||
// Pause only sends the signal; the in-flight doDownload() request returns its
|
||||
// "paused" result and updates the card (resumable + partial bytes).
|
||||
async function doPause(game) {
|
||||
try {
|
||||
await postJson(API.action(game.title_id, "pause"), {});
|
||||
showToast(`${game.name}: pausing…`);
|
||||
} catch (error) {
|
||||
showToast(`${game.name}: ${reasonText(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGame(titleId, patch) {
|
||||
const game = state.titles.find((i) => i.title_id === titleId);
|
||||
if (!game) return;
|
||||
@@ -733,6 +753,8 @@ const REASON_TEXT = {
|
||||
download_in_progress: "Another download is already running.",
|
||||
piece_verify_failed: "A downloaded piece failed its SHA-256 check.",
|
||||
title_disabled: "This title is disabled.",
|
||||
download_paused: "Download paused.",
|
||||
not_downloading: "Nothing is downloading for this title.",
|
||||
};
|
||||
function reasonText(error) {
|
||||
const r = error && error.body && error.body.reason;
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
<div class="brand-mark">PD</div>
|
||||
<div>
|
||||
<strong>PatchDL</strong>
|
||||
<span>by Knutwurst · v0.0.2</span>
|
||||
<span>by Knutwurst · v0.0.3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+7
-4
@@ -331,10 +331,13 @@ h2 { font-size: 18px; line-height: 1.2; }
|
||||
width: 100%; /* fills the fixed-width actions column -> never reflows */
|
||||
padding: 0 12px;
|
||||
}
|
||||
.row-button.is-update { border-color: var(--blue); color: #04111f; background: var(--blue); }
|
||||
.row-button.is-update:hover { background: var(--blue-dark); }
|
||||
.row-button.is-cancel { border-color: var(--amber); color: #1c1402; background: var(--amber); }
|
||||
.row-button.is-cancel:hover { background: var(--amber-dark); }
|
||||
/* go = green (Update / Resume / Install), pause = amber, cancel/stop = red */
|
||||
.row-button.is-update { border-color: var(--green); color: #04150c; background: var(--green); }
|
||||
.row-button.is-update:hover { background: var(--green-dark); }
|
||||
.row-button.is-pause { border-color: var(--amber); color: #1c1402; background: var(--amber); }
|
||||
.row-button.is-pause:hover { background: var(--amber-dark); }
|
||||
.row-button.is-cancel { border-color: var(--red); color: #1a0606; background: var(--red); }
|
||||
.row-button.is-cancel:hover { background: #ff8a8a; }
|
||||
.row-button.is-ghost { background: transparent; color: var(--muted); }
|
||||
.row-button.is-ghost:hover { color: var(--ink); border-color: var(--muted); }
|
||||
.row-button:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
Reference in new issue
Block a user