diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js
index 709f3a354..da125459a 100644
--- a/src/gui/src/IPC.js
+++ b/src/gui/src/IPC.js
@@ -39,41 +39,16 @@ import UINotification from './UI/UINotification.js';
import { PROCESS_IPC_ATTACHED } from './definitions.js';
import TeePromise from './util/TeePromise.js';
+import { createFeedbackDialogGuard } from './util/feedbackDialogGuard.js';
window.ipc_handlers = {};
// Abuse guard for the app-triggered feedback dialog (`showFeedbackDialog`
-// handler below). The dialog is a full-viewport modal reachable from app IPC
-// with no user-gesture requirement, so an unguarded loop of
-// `showFeedbackDialog()` calls would cover the desktop — taskbar and the
-// app's own close button included — forever. One dialog may be open at a
-// time, and every dismissal that sent nothing backs off the app's next open
-// (10s, then 60s, then blocked until the page reloads); a successful send
-// resets the backoff.
-const FEEDBACK_DISMISS_BACKOFF_MS = [10_000, 60_000, Infinity];
-let feedback_dialog_open = false;
-const feedback_dialog_backoff = new Map(); // app uid/name -> { dismissals, until }
+// handler below): one dialog at a time, and an app that reopens it at machine
+// speed gets backed off. Closing the dialog costs the user nothing — the next
+// human-paced open always goes through. See feedbackDialogGuard.js.
+const feedback_dialog_guard = createFeedbackDialogGuard();
-const feedback_dialog_may_open = (app_key) => {
- if ( feedback_dialog_open ) return false;
- const entry = feedback_dialog_backoff.get(app_key);
- return ! entry || Date.now() >= entry.until;
-};
-
-const record_feedback_dialog_outcome = (app_key, sent) => {
- if ( sent ) {
- feedback_dialog_backoff.delete(app_key);
- return;
- }
- const dismissals = (feedback_dialog_backoff.get(app_key)?.dismissals ?? 0) + 1;
- const backoff = FEEDBACK_DISMISS_BACKOFF_MS[
- Math.min(dismissals, FEEDBACK_DISMISS_BACKOFF_MS.length) - 1
- ];
- feedback_dialog_backoff.set(app_key, {
- dismissals,
- until: Date.now() + backoff,
- });
-};
/**
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI), and each other, using the postMessage API.
* The following sets up an Inter-Process Messaging System between apps and the GUI that enables communication
@@ -1440,17 +1415,17 @@ const ipc_listener = async (event, handled) => {
}, '*');
};
- // Re-entry / dismissal-backoff guard (see the state at the top of
- // this file). Checked before the auth gate too: for a signed-out
- // user the gate opens a full-page signup window, which an
- // unguarded loop could spam just as effectively as the dialog.
+ // Re-entry / reopen-rate guard (see the state at the top of this
+ // file). Checked before the auth gate too: for a signed-out user the
+ // gate opens a full-page signup window, which an unguarded loop could
+ // spam just as effectively as the dialog.
const guard_key = app_uuid || app_name;
- if ( ! feedback_dialog_may_open(guard_key) ) {
+ if ( ! feedback_dialog_guard.mayOpen(guard_key) ) {
respond(false);
return;
}
- feedback_dialog_open = true;
+ feedback_dialog_guard.markOpened();
let sent = false;
try {
// auth
@@ -1484,8 +1459,7 @@ const ipc_listener = async (event, handled) => {
respond(sent === true);
$(target_iframe).get(0)?.focus({ preventScroll: true });
} finally {
- feedback_dialog_open = false;
- record_feedback_dialog_outcome(guard_key, sent);
+ feedback_dialog_guard.markClosed(guard_key, sent);
}
}
//--------------------------------------------------------
diff --git a/src/gui/src/util/feedbackDialogGuard.js b/src/gui/src/util/feedbackDialogGuard.js
new file mode 100644
index 000000000..25c1426b9
--- /dev/null
+++ b/src/gui/src/util/feedbackDialogGuard.js
@@ -0,0 +1,119 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+// A reopen this soon after the app's last dialog activity is a loop, not a
+// person: a human has to notice the dialog is gone, find the app's "Send
+// feedback" control and click it, which no click-driven flow does inside a
+// second.
+export const IMMEDIATE_REOPEN_MS = 1000;
+
+// What an app pays for each machine-speed reopen, in order.
+export const BACKOFF_MS = [10_000, 60_000, Infinity];
+
+/**
+ * Abuse guard for the app-triggered feedback dialog (`showFeedbackDialog`).
+ *
+ * The dialog is a full-viewport modal an app can open with no user gesture, so
+ * an unguarded loop of `showFeedbackDialog()` calls would cover the desktop —
+ * taskbar and the app's own close button included — forever.
+ *
+ * The guard keys off *how fast the app comes back*, never off how many times
+ * the user said no. Closing the dialog must not cost the user their next one:
+ * people close it by accident, or open it to see what it asks before they have
+ * anything to write, and the app's own "Send feedback" button has to keep
+ * working when they come back to it.
+ *
+ * So a reopen that lands within `IMMEDIATE_REOPEN_MS` of the app's last dialog
+ * activity — its last dismissal, or its last attempt while backed off — is
+ * refused and backs the app off (10s, then 60s, then blocked until the page
+ * reloads). Hammering the guard while it is closed counts as activity too, so
+ * waiting a backoff out is not a way around the escalation. Any human-paced
+ * reopen clears the app's record: the tiers are only ever reached by an app
+ * that reopens at machine speed several times over.
+ *
+ * One dialog may be open at a time, across all apps.
+ *
+ * @param {{ now?: () => number }} [deps] - `now` is injectable for tests.
+ */
+export const createFeedbackDialogGuard = ({ now = () => Date.now() } = {}) => {
+ let dialog_open = false;
+ // app uid/name -> { strikes, until, last_activity }
+ const records = new Map();
+
+ return {
+ /**
+ * May this app open the dialog right now? Records the attempt either
+ * way, so call it once per request and honor the answer.
+ *
+ * @param {string} key - App uid, or name when there is no uid.
+ * @returns {boolean}
+ */
+ mayOpen (key) {
+ // Not the app's doing — another dialog is up — so this attempt
+ // earns no strike and does not count as activity.
+ if ( dialog_open ) return false;
+
+ const record = records.get(key);
+ if ( ! record ) return true;
+
+ const t = now();
+ const quiet_for = t - record.last_activity;
+ record.last_activity = t;
+
+ if ( t < record.until ) return false;
+
+ if ( quiet_for > IMMEDIATE_REOPEN_MS ) {
+ records.delete(key);
+ return true;
+ }
+
+ record.strikes += 1;
+ record.until = t + BACKOFF_MS[
+ Math.min(record.strikes, BACKOFF_MS.length) - 1
+ ];
+ return false;
+ },
+
+ /**
+ * The dialog is up for this app (call only after `mayOpen` said yes).
+ */
+ markOpened () {
+ dialog_open = true;
+ },
+
+ /**
+ * The dialog is down. A submitted message clears the app's record
+ * outright — an app whose users are actually sending feedback is not
+ * the app this guard is for.
+ *
+ * @param {string} key - The key passed to `mayOpen`.
+ * @param {boolean} sent - Did the user submit feedback?
+ */
+ markClosed (key, sent) {
+ dialog_open = false;
+ if ( sent === true ) {
+ records.delete(key);
+ return;
+ }
+ const record = records.get(key) ?? { strikes: 0, until: 0 };
+ record.last_activity = now();
+ records.set(key, record);
+ },
+ };
+};
diff --git a/src/gui/src/util/feedbackDialogGuard.test.js b/src/gui/src/util/feedbackDialogGuard.test.js
new file mode 100644
index 000000000..c00e40217
--- /dev/null
+++ b/src/gui/src/util/feedbackDialogGuard.test.js
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { describe, it, expect } from 'vitest';
+import { createFeedbackDialogGuard } from './feedbackDialogGuard.js';
+
+const APP = 'app-uid-1';
+
+// A guard on a clock the test drives by hand.
+const makeGuard = () => {
+ let t = 1_000_000;
+ const guard = createFeedbackDialogGuard({ now: () => t });
+ return {
+ guard,
+ advance: (ms) => { t += ms; },
+ // One full open-and-dismiss cycle, the user taking `duration` to
+ // close the dialog. Returns false if the guard refused to open it.
+ cycle: (key = APP, { duration = 5_000, sent = false } = {}) => {
+ if ( ! guard.mayOpen(key) ) return false;
+ guard.markOpened();
+ t += duration;
+ guard.markClosed(key, sent);
+ return true;
+ },
+ };
+};
+
+describe('createFeedbackDialogGuard', () => {
+ it('opens for an app it has never seen', () => {
+ const { guard } = makeGuard();
+ expect(guard.mayOpen(APP)).toBe(true);
+ });
+
+ it('reopens after a dismissal, as many times as the user asks', () => {
+ const { guard, cycle, advance } = makeGuard();
+ // The bug this guards against: closing the dialog used to cost the
+ // app its next open for 10s, then 60s, then the rest of the session.
+ for ( let i = 0; i < 5; i++ ) {
+ expect(cycle(), `open #${i + 1}`).toBe(true);
+ advance(2_000); // the user goes back and clicks "Send feedback"
+ }
+ expect(guard.mayOpen(APP)).toBe(true);
+ });
+
+ it('reopens a second after the dialog closed', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle()).toBe(true);
+ advance(1_001);
+ expect(guard.mayOpen(APP)).toBe(true);
+ });
+
+ it('refuses a second dialog while one is open', () => {
+ const { guard } = makeGuard();
+ expect(guard.mayOpen(APP)).toBe(true);
+ guard.markOpened();
+ expect(guard.mayOpen(APP)).toBe(false);
+ expect(guard.mayOpen('some-other-app')).toBe(false);
+ guard.markClosed(APP, false);
+ });
+
+ it('backs off an app that reopens the instant it is dismissed', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle()).toBe(true);
+ advance(1);
+ expect(guard.mayOpen(APP)).toBe(false);
+
+ // ...and holds it off for the first tier.
+ advance(9_000);
+ expect(guard.mayOpen(APP)).toBe(false);
+ });
+
+ it('escalates while the app keeps hammering, and blocks it for good', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle()).toBe(true);
+
+ // A loop calling every 50ms. Waiting out a backoff is no escape: the
+ // attempts made during it are activity of their own, so the next tier
+ // applies rather than a clean slate.
+ const hammer = (ms) => {
+ for ( let elapsed = 0; elapsed < ms; elapsed += 50 ) {
+ advance(50);
+ if ( guard.mayOpen(APP) ) return true;
+ }
+ return false;
+ };
+
+ expect(hammer(10_000), 'first tier').toBe(false);
+ expect(hammer(60_000), 'second tier').toBe(false);
+ expect(hammer(10 * 60_000), 'blocked for the session').toBe(false);
+ });
+
+ it('lets a hammering app back in once it goes quiet', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle()).toBe(true);
+ advance(1);
+ expect(guard.mayOpen(APP)).toBe(false); // strike: 10s
+
+ // The user closes the app's own dialog loop by leaving it alone, then
+ // comes back later and asks for the form themselves.
+ advance(30_000);
+ expect(guard.mayOpen(APP)).toBe(true);
+ });
+
+ it('keeps each app on its own record', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle('noisy-app')).toBe(true);
+ advance(1);
+ expect(guard.mayOpen('noisy-app')).toBe(false);
+ expect(guard.mayOpen('quiet-app')).toBe(true);
+ });
+
+ it('clears the record when the user actually sends feedback', () => {
+ const { guard, cycle, advance } = makeGuard();
+ expect(cycle()).toBe(true);
+ advance(1);
+ expect(guard.mayOpen(APP)).toBe(false); // strike: 10s
+
+ advance(30_000);
+ expect(cycle(APP, { sent: true })).toBe(true);
+ // A send wipes the strikes, so even an immediate reopen is allowed.
+ expect(guard.mayOpen(APP)).toBe(true);
+ });
+});