Add incoming webhooks, fix stale service worker and push re-registration
- Webhooks for automations: per-job secret, Gitea/GitHub HMAC signatures, payload appended to the instructions; settings on the automations page - Service worker gets a new file name per build (proxies that cache *.js served a stale worker, which broke updates and Android installation); /sw.js is a kill switch that removes old workers - Push: devices re-register their subscription with the server on start and before a test; the test reports why a push service rejected a message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dffcb501a
commit
10d4b64289
16 files changed
+267
-18
No files matched your search
Generated
+1
@@ -569,6 +569,7 @@ dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"html2text",
|
||||
"ignore",
|
||||
"image",
|
||||
|
||||
@@ -44,3 +44,4 @@ hkdf = "0.13"
|
||||
aes-gcm = "0.11"
|
||||
pty-process = { version = "0.5.4", features = ["async"] }
|
||||
croner = "4.0.3"
|
||||
hmac = "0.13"
|
||||
+57
-2
@@ -137,6 +137,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/jobs", get(list_jobs).post(register_job))
|
||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
||||
.route("/jobs/{id}/run", post(run_job))
|
||||
.route("/jobs/{id}/webhook", post(job_webhook_settings))
|
||||
.route("/terminals", get(list_terminals).post(open_terminal))
|
||||
.route("/terminals/{id}", delete(close_terminal))
|
||||
.route("/terminals/{id}/ws", get(terminal_ws))
|
||||
@@ -160,6 +161,7 @@ pub fn router(app: AppState) -> Router {
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
.route("/hooks/{id}", post(incoming_webhook))
|
||||
.route("/cli/install.sh", get(cli_install_script))
|
||||
.route("/cli/download/{target}", get(cli_download))
|
||||
.fallback_service(static_files)
|
||||
@@ -1254,6 +1256,59 @@ async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WebhookSettings {
|
||||
enable: bool,
|
||||
}
|
||||
|
||||
/// Enable (new secret, shown once) or disable the webhook of an automation.
|
||||
async fn job_webhook_settings(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, Json(b): Json<WebhookSettings>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
if !b.enable {
|
||||
app.store.job_set_webhook(&job.id, None)?;
|
||||
app.audit(Some(&user), "job.webhook.off", &job.path, "");
|
||||
return Ok(Json(json!({"ok": true})));
|
||||
}
|
||||
let secret = crate::auth::random_token(24);
|
||||
app.store.job_set_webhook(&job.id, Some(&secret))?;
|
||||
app.audit(Some(&user), "job.webhook.on", &job.path, "");
|
||||
let base = public_base(&app, &headers);
|
||||
Ok(Json(json!({"url": format!("{base}/hooks/{}", job.id), "secret": secret})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HookQuery {
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Public webhook endpoint: starts the automation with the request body as input.
|
||||
async fn incoming_webhook(State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<HookQuery>, headers: axum::http::HeaderMap, body: bytes::Bytes) -> Response {
|
||||
let ip = crate::auth::client_ip(&headers);
|
||||
let Ok(Some(job)) = app.store.job(&id) else { return (StatusCode::NOT_FOUND, "unknown webhook").into_response() };
|
||||
let Ok(Some(secret)) = app.store.job_webhook_secret(&id) else { return (StatusCode::NOT_FOUND, "unknown webhook").into_response() };
|
||||
if !crate::jobs::verify_webhook(&secret, &headers, q.secret.as_deref(), &body) {
|
||||
app.store.audit(None, "", "webhook.denied", &id, &ip);
|
||||
return (StatusCode::UNAUTHORIZED, "invalid secret or signature").into_response();
|
||||
}
|
||||
if !job.enabled {
|
||||
return (StatusCode::CONFLICT, "the automation is disabled").into_response();
|
||||
}
|
||||
if job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) {
|
||||
return (StatusCode::CONFLICT, "the automation is still running").into_response();
|
||||
}
|
||||
if job.last_run.is_some_and(|t| now_ms() - t < 10_000) {
|
||||
return (StatusCode::TOO_MANY_REQUESTS, "too many calls – wait a few seconds").into_response();
|
||||
}
|
||||
let payload = crate::jobs::payload_text(&headers, &body);
|
||||
match crate::jobs::run_with(&app, &job, "webhook", Some(payload)).await {
|
||||
Ok(sid) => {
|
||||
app.store.audit(None, "", "webhook.run", &id, &ip);
|
||||
(StatusCode::ACCEPTED, Json(json!({"ok": true, "session_id": sid}))).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let sid = crate::jobs::run(&app, &job, "manual").await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
@@ -1348,8 +1403,8 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
|
||||
}
|
||||
|
||||
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
|
||||
Ok(Json(json!({"sent": sent})))
|
||||
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
|
||||
Ok(Json(json!({"sent": sent, "errors": errors})))
|
||||
}
|
||||
|
||||
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct Job {
|
||||
pub last_session_id: Option<String>,
|
||||
pub last_status: Option<String>,
|
||||
pub created_at: i64,
|
||||
/// Webhook enabled (the secret – also the HMAC key – is only shown once when created).
|
||||
pub webhook: bool,
|
||||
}
|
||||
|
||||
/// Parsed job file.
|
||||
@@ -117,6 +119,11 @@ pub fn describe(job: &Job) -> Value {
|
||||
|
||||
/// Start a run now. Returns the new session id.
|
||||
pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
run_with(app, job, trigger, None).await
|
||||
}
|
||||
|
||||
/// Start a run; `payload` (webhook body) is appended to the instructions.
|
||||
pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<String>) -> Result<String> {
|
||||
let path = PathBuf::from(&job.path);
|
||||
let spec = match parse(&path) {
|
||||
Ok(s) => s,
|
||||
@@ -161,6 +168,10 @@ pub async fn run(app: &Arc<App>, job: &Job, trigger: &str) -> Result<String> {
|
||||
"<automation name=\"{}\" trigger=\"{trigger}\" file=\"{}\">\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n</automation>\n\n{}",
|
||||
spec.name, job.path, spec.prompt
|
||||
);
|
||||
let text = match payload {
|
||||
Some(p) => format!("{text}\n\n<webhook-payload>\n{p}\n</webhook-payload>\nThe automation was triggered by this webhook call – use the payload as input."),
|
||||
None => text,
|
||||
};
|
||||
crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?;
|
||||
|
||||
// Watch for the end (or the timeout).
|
||||
@@ -375,3 +386,67 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// --- incoming webhooks -------------------------------------------------------------
|
||||
|
||||
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn hmac_hex(secret: &str, body: &[u8]) -> String {
|
||||
use hmac::{KeyInit, Mac};
|
||||
let mut mac = <hmac::Hmac<sha2::Sha256> as KeyInit>::new_from_slice(secret.as_bytes()).expect("hmac key");
|
||||
mac.update(body);
|
||||
mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Check a webhook call: shared secret (header/query) or Gitea/GitHub HMAC signature.
|
||||
pub fn verify_webhook(secret: &str, headers: &axum::http::HeaderMap, query_secret: Option<&str>, body: &[u8]) -> bool {
|
||||
let h = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(|v| v.trim().to_string());
|
||||
if let Some(given) = h("x-codecraft-secret").or(query_secret.map(String::from)) {
|
||||
return constant_eq(given.as_bytes(), secret.as_bytes());
|
||||
}
|
||||
let expected = hmac_hex(secret, body);
|
||||
if let Some(sig) = h("x-gitea-signature").or(h("x-gogs-signature")) {
|
||||
return constant_eq(sig.to_lowercase().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
if let Some(sig) = h("x-hub-signature-256") {
|
||||
return constant_eq(sig.trim_start_matches("sha256=").to_lowercase().as_bytes(), expected.as_bytes());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The payload as text for the prompt (pretty JSON, event name, size-limited).
|
||||
pub fn payload_text(headers: &axum::http::HeaderMap, body: &[u8]) -> String {
|
||||
let event = ["x-gitea-event", "x-github-event", "x-gitlab-event", "x-event-key"]
|
||||
.iter()
|
||||
.find_map(|k| headers.get(*k).and_then(|v| v.to_str().ok()))
|
||||
.map(|e| format!("event: {e}\n"))
|
||||
.unwrap_or_default();
|
||||
let text = match serde_json::from_slice::<Value>(body) {
|
||||
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(),
|
||||
Err(_) => String::from_utf8_lossy(body).into_owned(),
|
||||
};
|
||||
let mut text: String = text.chars().take(20_000).collect();
|
||||
if body.len() > 20_000 {
|
||||
text.push_str("\n… (payload truncated)");
|
||||
}
|
||||
format!("{event}{text}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod webhook_tests {
|
||||
#[test]
|
||||
fn signatures() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
let sig = super::hmac_hex("s3cret", b"{\"a\":1}");
|
||||
h.insert("x-hub-signature-256", format!("sha256={sig}").parse().unwrap());
|
||||
assert!(super::verify_webhook("s3cret", &h, None, b"{\"a\":1}"));
|
||||
assert!(!super::verify_webhook("other", &h, None, b"{\"a\":1}"));
|
||||
let mut h2 = axum::http::HeaderMap::new();
|
||||
h2.insert("x-gitea-signature", sig.parse().unwrap());
|
||||
assert!(super::verify_webhook("s3cret", &h2, None, b"{\"a\":1}"));
|
||||
assert!(super::verify_webhook("abc", &axum::http::HeaderMap::new(), Some("abc"), b""));
|
||||
assert!(!super::verify_webhook("abc", &axum::http::HeaderMap::new(), None, b""));
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -138,23 +138,25 @@ fn vapid_header(key: &SecretKey, endpoint: &str, subject: &str) -> Result<String
|
||||
Ok(format!("vapid t={jwt}, k={k}"))
|
||||
}
|
||||
|
||||
/// Send a notification to all devices of a user.
|
||||
pub async fn send_to_user(app: &App, user_id: &str, payload: &Value) -> usize {
|
||||
/// Send a notification to all devices of a user: (delivered, error messages).
|
||||
pub async fn send_to_user(app: &App, user_id: &str, payload: &Value) -> (usize, Vec<String>) {
|
||||
let subs = app.store.push_subscriptions(user_id).unwrap_or_default();
|
||||
let mut sent = 0;
|
||||
let mut errors = Vec::new();
|
||||
for sub in subs {
|
||||
match send_one(app, &sub.endpoint, &sub.p256dh, &sub.auth, payload).await {
|
||||
Ok(()) => sent += 1,
|
||||
Err(e) => {
|
||||
let gone = e.to_string().contains("410") || e.to_string().contains("404");
|
||||
tracing::debug!("push to {}: {e:#}", sub.endpoint);
|
||||
tracing::warn!("push to {}…: {e:#}", sub.endpoint.chars().take(40).collect::<String>());
|
||||
errors.push(format!("{e:#}").chars().take(200).collect());
|
||||
if gone {
|
||||
let _ = app.store.push_remove(&sub.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sent
|
||||
(sent, errors)
|
||||
}
|
||||
|
||||
async fn send_one(app: &App, endpoint: &str, p256dh: &str, auth: &str, payload: &Value) -> Result<()> {
|
||||
|
||||
+15
-3
@@ -161,6 +161,7 @@ impl Store {
|
||||
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||
@@ -975,13 +976,14 @@ impl Store {
|
||||
last_session_id: r.get(5)?,
|
||||
last_status: r.get(6)?,
|
||||
created_at: r.get(7)?,
|
||||
webhook: r.get::<_, Option<String>>(8)?.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn jobs(&self, owner: Option<&str>) -> Result<Vec<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs
|
||||
WHERE ?1 IS NULL OR owner_id = ?1 ORDER BY created_at",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner], Self::job_row)?;
|
||||
@@ -991,7 +993,7 @@ impl Store {
|
||||
pub fn job(&self, id: &str) -> Result<Option<crate::jobs::Job>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.query_row("SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE id = ?1", [id], Self::job_row)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
@@ -1007,7 +1009,7 @@ impl Store {
|
||||
}
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at FROM jobs WHERE path = ?1",
|
||||
"SELECT id, owner_id, path, enabled, last_run, last_session_id, last_status, created_at, webhook_secret FROM jobs WHERE path = ?1",
|
||||
[path],
|
||||
Self::job_row,
|
||||
)?)
|
||||
@@ -1039,6 +1041,16 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (Some) or remove (None) the webhook secret of a job.
|
||||
pub fn job_set_webhook(&self, id: &str, secret: Option<&str>) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("UPDATE jobs SET webhook_secret = ?2 WHERE id = ?1", rusqlite::params![id, secret])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn job_webhook_secret(&self, id: &str) -> Result<Option<String>> {
|
||||
Ok(self.conn.lock().unwrap().query_row("SELECT webhook_secret FROM jobs WHERE id = ?1", [id], |r| r.get(0)).optional()?.flatten())
|
||||
}
|
||||
|
||||
pub fn job_delete(&self, id: &str) -> Result<()> {
|
||||
self.conn.lock().unwrap().execute("DELETE FROM jobs WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,15 @@
|
||||
// Retired service worker location (the app now uses /sw-<build>.js). Devices that
|
||||
// still run the old worker get this one as an update: it removes itself and
|
||||
// reloads open windows, which then register the current worker.
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
await self.registration.unregister();
|
||||
const windows = await self.clients.matchAll({ type: "window" });
|
||||
windows.forEach((w) => w.navigate(w.url));
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ interface JobInfo {
|
||||
last_status?: string | null;
|
||||
next_run?: number | null;
|
||||
error?: string;
|
||||
webhook?: boolean;
|
||||
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
const [jobs, setJobs] = useState<JobInfo[]>();
|
||||
const [path, setPath] = useState("");
|
||||
const [busy, setBusy] = useState<string>();
|
||||
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
|
||||
|
||||
const reload = () =>
|
||||
get<JobInfo[]>("/jobs")
|
||||
@@ -108,6 +110,44 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
|
||||
<pre>{j.spec.prompt}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="job-hook">
|
||||
<span>
|
||||
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
|
||||
</span>
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() =>
|
||||
act(j.id, async () => {
|
||||
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
|
||||
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
|
||||
})
|
||||
}
|
||||
>
|
||||
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
|
||||
</button>
|
||||
{j.webhook && (
|
||||
<button
|
||||
className="small ghost"
|
||||
onClick={() =>
|
||||
act(j.id, async () => {
|
||||
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
|
||||
setHook({ id: j.id, url: r.url, secret: r.secret });
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("jobs.webhookRenew")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hook?.id === j.id && (
|
||||
<div className="notice ok">
|
||||
<div>{t("jobs.webhookShown")}</div>
|
||||
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
|
||||
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
|
||||
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
|
||||
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: 8, marginTop: 10 }}>
|
||||
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
|
||||
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getLang, languages, setLang, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
|
||||
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
|
||||
import { currentSubscription, disablePush, enablePush, pushSupported, syncPush, testPush } from "../push";
|
||||
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
|
||||
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
|
||||
import { ModelSelect } from "./ModelSelect";
|
||||
@@ -149,7 +149,11 @@ function PushControl() {
|
||||
if (!pushSupported()) return setState("unsupported");
|
||||
if (Notification.permission === "denied") return setState("denied");
|
||||
currentSubscription()
|
||||
.then((s) => setState(s ? "on" : "off"))
|
||||
.then(async (s) => {
|
||||
setState(s ? "on" : "off");
|
||||
// A subscription the server lost (or never got) is registered again.
|
||||
if (s) await syncPush().catch(() => undefined);
|
||||
})
|
||||
.catch(() => setState("off"));
|
||||
}, []);
|
||||
const run = (f: () => Promise<unknown>) => async () => {
|
||||
@@ -174,8 +178,9 @@ function PushControl() {
|
||||
className="small"
|
||||
disabled={busy}
|
||||
onClick={run(async () => {
|
||||
const n = await testPush();
|
||||
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
|
||||
const r = await testPush();
|
||||
if (r.sent > 0) toast(t("settings.pushTestSent"), "success");
|
||||
else toast(r.errors?.length ? `${t("settings.pushTestFailed")}: ${r.errors.join("; ")}` : t("settings.pushTestNone"));
|
||||
})}
|
||||
>
|
||||
{t("settings.pushTest")}
|
||||
|
||||
@@ -411,6 +411,13 @@ export const de: Messages = {
|
||||
"install.hint.firefox": "Firefox kann keine Web-Apps installieren – öffne CodeCraft in Chrome (Android) oder nutze „Zum Startbildschirm hinzufügen“.",
|
||||
"install.hint.ios": "Safari: Teilen-Knopf → „Zum Home-Bildschirm“. Push-Benachrichtigungen funktionieren nur in der installierten App.",
|
||||
"install.hint.desktop": "Chrome/Edge: Installations-Symbol in der Adressleiste oder Menü → „CodeCraft installieren“.",
|
||||
"settings.pushTestFailed": "Der Push-Dienst hat die Benachrichtigung abgelehnt",
|
||||
"jobs.webhook": "Webhook",
|
||||
"jobs.webhookOn": "Webhook aktivieren",
|
||||
"jobs.webhookOff": "Webhook deaktivieren",
|
||||
"jobs.webhookRenew": "Neues Secret",
|
||||
"jobs.webhookShown": "Secret jetzt kopieren – es wird nur einmal angezeigt:",
|
||||
"jobs.webhookHelp": "Beliebiges JSON per POST an die URL schicken; es wird an den Auftrag angehängt. Anmeldung per Header X-CodeCraft-Secret (oder ?secret=…), oder das Secret in Gitea/GitHub als Webhook-Secret eintragen (Signatur X-Gitea-Signature / X-Hub-Signature-256).",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.up": "Hoch",
|
||||
|
||||
@@ -410,6 +410,13 @@ export const en = {
|
||||
"install.hint.firefox": "Firefox can't install web apps – open CodeCraft in Chrome (Android) or use “Add to home screen”.",
|
||||
"install.hint.ios": "Safari: Share button → “Add to Home Screen”. Push notifications work only in the installed app.",
|
||||
"install.hint.desktop": "Chrome/Edge: install icon in the address bar or menu → “Install CodeCraft”.",
|
||||
"settings.pushTestFailed": "The push service rejected the notification",
|
||||
"jobs.webhook": "Webhook",
|
||||
"jobs.webhookOn": "Enable webhook",
|
||||
"jobs.webhookOff": "Disable webhook",
|
||||
"jobs.webhookRenew": "New secret",
|
||||
"jobs.webhookShown": "Copy the secret now – it is shown only once:",
|
||||
"jobs.webhookHelp": "POST any JSON to the URL; it is appended to the instructions. Authenticate with the header X-CodeCraft-Secret (or ?secret=…), or enter the secret as webhook secret in Gitea/GitHub (signature X-Gitea-Signature / X-Hub-Signature-256).",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.up": "Up",
|
||||
|
||||
@@ -32,6 +32,8 @@ export async function startPrefsSync() {
|
||||
} catch {
|
||||
/* offline or old server */
|
||||
}
|
||||
// Push subscription of this device: make sure the server has it.
|
||||
void import("./push").then((m) => m.syncPush()).catch(() => undefined);
|
||||
onThemeChange(push);
|
||||
onLangChange(push);
|
||||
}
|
||||
+13
-3
@@ -47,7 +47,17 @@ export async function disablePush(): Promise<void> {
|
||||
localStorage.removeItem("codecraft.push");
|
||||
}
|
||||
|
||||
export async function testPush(): Promise<number> {
|
||||
const r = await post<{ sent: number }>("/push/test", {});
|
||||
return r.sent;
|
||||
/** Re-register this device's existing subscription with the server (idempotent). */
|
||||
export async function syncPush(): Promise<boolean> {
|
||||
const sub = await currentSubscription().catch(() => null);
|
||||
if (!sub) return false;
|
||||
await post("/push/subscribe", sub.toJSON());
|
||||
localStorage.setItem("codecraft.push", "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function testPush(): Promise<{ sent: number; errors: string[] }> {
|
||||
// Make sure the server knows this device before testing.
|
||||
await syncPush();
|
||||
return post<{ sent: number; errors: string[] }>("/push/test", {});
|
||||
}
|
||||
@@ -3086,3 +3086,13 @@ button.danger-solid {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.job-hook {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
+9
-2
@@ -4,11 +4,18 @@ import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
const backend = process.env.CODECRAFT_BACKEND ?? "http://127.0.0.1:8787";
|
||||
|
||||
const BUILD_ID = Date.now().toString(36);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
// A new file name per build: reverse proxies with "cache assets" (e.g. Nginx
|
||||
// Proxy Manager) cache *.js by URL – a stale service worker would break
|
||||
// updates and installation. /sw.js is a kill switch for old installations.
|
||||
filename: `sw-${BUILD_ID}.js`,
|
||||
injectRegister: false,
|
||||
includeAssets: ["icon.svg", "apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "CodeCraft",
|
||||
@@ -38,9 +45,9 @@ export default defineConfig({
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/cli/],
|
||||
runtimeCaching: [],
|
||||
// The STEP importer (OpenCascade, ~8 MB) is loaded only when needed.
|
||||
globIgnores: ["**/occt-import-js*"],
|
||||
globIgnores: ["**/occt-import-js*", "**/screenshots/**", "sw.js"],
|
||||
// Web Push handlers (push, notificationclick).
|
||||
importScripts: ["push-sw.js"],
|
||||
importScripts: ["push-sw.mjs"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user