Skip to content

Commit 39f8b8e

Browse files
committed
Fix bug with offscreen API
1 parent be80d2c commit 39f8b8e

7 files changed

Lines changed: 158 additions & 37 deletions

File tree

.github/workflows/release-server.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,7 @@ jobs:
7878
7979
- name: Publish to NPM
8080
working-directory: packages/server
81-
env:
82-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
83-
run: |
84-
npm publish --access public
81+
run: npm publish --access public
8582

8683
- name: Commit version change
8784
if: github.event_name == 'workflow_dispatch'

packages/extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@distracted/extension",
3-
"version": "1.3.0",
3+
"version": "1.3.1",
44
"private": true,
55
"description": "blocks distracting websites! do mini tasks to get back on them...",
66
"keywords": [

packages/extension/src/entrypoints/background/index.ts

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,73 @@ type GuardEntry = {
3636
settings: unknown;
3737
};
3838

39-
const offscreenApi = (globalThis as { chrome?: { offscreen?: any } }).chrome?.offscreen;
39+
// MV3-only (Chrome): WXT provides typings for `browser.offscreen`.
40+
// Avoid `globalThis` shims/`any` here so type checking stays strict.
41+
const offscreenApi = isMV3 ? browser.offscreen : undefined;
42+
43+
function isNoOffscreenDocumentError(error: unknown): boolean {
44+
const message =
45+
error instanceof Error
46+
? error.message
47+
: typeof error === "string"
48+
? error
49+
: JSON.stringify(error);
50+
return message.includes("No current offscreen document");
51+
}
52+
53+
function isReceivingEndDoesNotExistError(error: unknown): boolean {
54+
const message =
55+
error instanceof Error
56+
? error.message
57+
: typeof error === "string"
58+
? error
59+
: JSON.stringify(error);
60+
return (
61+
message.includes("Receiving end does not exist") ||
62+
message.includes("Could not establish connection") ||
63+
message.includes("No current offscreen document")
64+
);
65+
}
4066

4167
async function ensureOffscreenDocument(): Promise<void> {
4268
if (!isMV3 || !offscreenApi) return;
43-
const hasDocument = (await offscreenApi.hasDocument?.()) as boolean | undefined;
44-
if (hasDocument) return;
45-
46-
await offscreenApi.createDocument?.({
47-
url: browser.runtime.getURL(
48-
"/offscreen.html" as unknown as Parameters<typeof browser.runtime.getURL>[0],
49-
),
50-
reasons: ["WORKERS"],
51-
justification: "Maintain unlock guards that require real-time server state via WebSocket.",
52-
});
69+
try {
70+
const hasDocument = (await offscreenApi.hasDocument()) as boolean | undefined;
71+
if (hasDocument) return;
72+
} catch (error) {
73+
// Treat errors as "no document" and attempt to create one.
74+
console.warn("[distracted] offscreen.hasDocument failed:", error);
75+
}
76+
77+
try {
78+
await offscreenApi.createDocument({
79+
url: browser.runtime.getURL(
80+
"/offscreen.html" as unknown as Parameters<typeof browser.runtime.getURL>[0],
81+
),
82+
reasons: ["WORKERS"],
83+
justification: "Maintain unlock guards that require real-time server state via WebSocket.",
84+
});
85+
} catch (error) {
86+
// Chrome can be flaky here; if the document was created concurrently, this is safe to ignore.
87+
console.warn("[distracted] offscreen.createDocument failed:", error);
88+
}
5389
}
5490

5591
async function maybeCloseOffscreenDocument(): Promise<void> {
5692
if (!isMV3 || !offscreenApi) return;
5793
const session = await browser.storage.session.get();
5894
const hasGuards = Object.keys(session).some((key) => key.startsWith(GUARD_PREFIX));
5995
if (!hasGuards) {
60-
const hasDocument = (await offscreenApi.hasDocument?.()) as boolean | undefined;
61-
if (hasDocument) {
62-
await offscreenApi.closeDocument?.();
96+
try {
97+
const hasDocument = (await offscreenApi.hasDocument()) as boolean | undefined;
98+
if (hasDocument) {
99+
await offscreenApi.closeDocument();
100+
}
101+
} catch (error) {
102+
// If the document is already gone, treat close as a no-op.
103+
if (!isNoOffscreenDocumentError(error)) {
104+
console.warn("[distracted] offscreen.closeDocument failed:", error);
105+
}
63106
}
64107
}
65108
}
@@ -135,6 +178,24 @@ async function startGuardForSite(site: BlockedSite): Promise<void> {
135178
pollIntervalMs: guard.pollIntervalMs,
136179
});
137180
} catch (error) {
181+
// MV3 service worker/offscreen can race; retry once after ensuring document exists.
182+
if (isReceivingEndDoesNotExistError(error)) {
183+
try {
184+
await ensureOffscreenDocument();
185+
await browser.runtime.sendMessage({
186+
type: "GUARD_START",
187+
siteId: entry.siteId,
188+
method: entry.method,
189+
settings: entry.settings,
190+
heartbeatMs: GUARD_HEARTBEAT_MS,
191+
pollIntervalMs: guard.pollIntervalMs,
192+
});
193+
return;
194+
} catch (retryError) {
195+
console.warn("[distracted] Failed to start guard in offscreen (retry):", retryError);
196+
return;
197+
}
198+
}
138199
console.warn("[distracted] Failed to start guard in offscreen:", error);
139200
}
140201
} else {
@@ -152,7 +213,10 @@ async function stopGuardForSite(siteId: string): Promise<void> {
152213
siteId,
153214
});
154215
} catch (error) {
155-
console.warn("[distracted] Failed to stop guard in offscreen:", error);
216+
// If offscreen isn't around anymore, stopping is effectively complete.
217+
if (!isReceivingEndDoesNotExistError(error)) {
218+
console.warn("[distracted] Failed to stop guard in offscreen:", error);
219+
}
156220
}
157221
await maybeCloseOffscreenDocument();
158222
} else {

packages/extension/src/entrypoints/blocked/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,11 @@ export default function BlockedPage() {
112112
if (!blockedSite || !originalUrl) return;
113113

114114
setUnlocking(true);
115+
let navigationStarted = false;
115116

116117
try {
117118
if (alreadyUnlocked) {
119+
navigationStarted = true;
118120
window.location.href = originalUrl;
119121
return;
120122
}
@@ -146,6 +148,7 @@ export default function BlockedPage() {
146148
});
147149
}
148150

151+
navigationStarted = true;
149152
window.location.href = originalUrl;
150153
} else {
151154
setError(result.error || "Failed to unlock");
@@ -155,6 +158,13 @@ export default function BlockedPage() {
155158
console.error("[distracted] Error unlocking:", err);
156159
setError("Failed to unlock site");
157160
setUnlocking(false);
161+
} finally {
162+
// If navigation doesn't happen (e.g. browser blocks it), don't leave the UI stuck forever.
163+
if (!navigationStarted) {
164+
setUnlocking(false);
165+
} else {
166+
setTimeout(() => setUnlocking(false), 5000);
167+
}
158168
}
159169
}, [blockedSite, originalUrl, statsEnabled, alreadyUnlocked]);
160170

packages/extension/src/lib/claude-blocker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ export async function getClaudeBlockerStatus(serverUrl: string): Promise<ClaudeB
7474
}
7575

7676
try {
77-
const response = await fetch(statusUrl, { cache: "no-store" });
77+
const controller = new AbortController();
78+
const timeout = setTimeout(() => controller.abort(), 2000);
79+
const response = await fetch(statusUrl, { cache: "no-store", signal: controller.signal });
80+
clearTimeout(timeout);
7881
if (!response.ok) {
7982
return {
8083
active: false,

packages/extension/src/lib/guard-watcher.ts

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function startGuardWatcher({
2727
let socket: WebSocket | null = null;
2828
let pollTimer: ReturnType<typeof setInterval> | null = null;
2929
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
30+
let wsPingTimer: ReturnType<typeof setInterval> | null = null;
31+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
32+
let reconnectAttempts = 0;
3033
let lastState: UnlockGuardState | null = null;
3134

3235
const emitState = (state: UnlockGuardState) => {
@@ -40,6 +43,8 @@ export function startGuardWatcher({
4043
stopped = true;
4144
if (pollTimer) clearInterval(pollTimer);
4245
if (heartbeatTimer) clearInterval(heartbeatTimer);
46+
if (wsPingTimer) clearInterval(wsPingTimer);
47+
if (reconnectTimer) clearTimeout(reconnectTimer);
4348
if (socket) {
4449
socket.close();
4550
socket = null;
@@ -80,25 +85,67 @@ export function startGuardWatcher({
8085

8186
const wsUrl = guard.getWebSocketUrl?.(settings as never);
8287
if (wsUrl) {
83-
socket = new WebSocket(wsUrl);
84-
void runCheck();
85-
socket.addEventListener("message", (event) => {
88+
// WebSocket mode: keep a polling fallback and auto-reconnect the socket.
89+
// This avoids instantly revoking access on transient WS drops (common in MV3/offscreen).
90+
startPolling();
91+
92+
const scheduleReconnect = () => {
93+
if (stopped) return;
94+
if (reconnectTimer) return;
95+
reconnectAttempts += 1;
96+
const backoffMs = Math.min(10_000, 500 * 2 ** Math.min(6, reconnectAttempts)); // 0.5s .. 10s
97+
reconnectTimer = setTimeout(() => {
98+
reconnectTimer = null;
99+
connect();
100+
}, backoffMs);
101+
};
102+
103+
const connect = () => {
104+
if (stopped) return;
86105
try {
87-
const payload = JSON.parse(event.data as string);
88-
const state = guard.parseWebSocketMessage?.(payload, settings as never);
89-
if (state) {
90-
handleGuardState(state);
106+
socket?.close();
107+
} catch {
108+
// ignore
109+
}
110+
socket = new WebSocket(wsUrl);
111+
112+
socket.addEventListener("open", () => {
113+
reconnectAttempts = 0;
114+
void runCheck();
115+
});
116+
117+
socket.addEventListener("message", (event) => {
118+
try {
119+
const payload = JSON.parse(event.data as string);
120+
const state = guard.parseWebSocketMessage?.(payload, settings as never);
121+
if (state) {
122+
handleGuardState(state);
123+
}
124+
} catch {
125+
// Ignore malformed messages
91126
}
127+
});
128+
129+
socket.addEventListener("close", () => {
130+
scheduleReconnect();
131+
});
132+
socket.addEventListener("error", () => {
133+
scheduleReconnect();
134+
});
135+
};
136+
137+
connect();
138+
139+
// App-level ping to keep the server (and any intermediaries) from timing out idle connections.
140+
wsPingTimer = setInterval(() => {
141+
if (stopped) return;
142+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
143+
try {
144+
socket.send(JSON.stringify({ type: "ping" }));
92145
} catch {
93-
// Ignore malformed messages
146+
// ignore; reconnect loop will handle close/error
94147
}
95-
});
96-
socket.addEventListener("close", () => {
97-
handleGuardState({ active: false, reason: "offline" });
98-
});
99-
socket.addEventListener("error", () => {
100-
handleGuardState({ active: false, reason: "offline" });
101-
});
148+
}, 10_000);
102149

103150
startHeartbeat();
104151
} else {

packages/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@distracted/server",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"description": "Local server + Claude Code hook installer for distracted's Claude Blocker integration.",
55
"license": "MIT",
66
"repository": {

0 commit comments

Comments
 (0)