1+ // background.js
12/*
23Background script. This works across all of Google Chrome.
34*/
@@ -14,135 +15,180 @@ import { localStorageInfo, sessionStorageInfo } from 'lo_event/lo_event/metadata
1415
1516const { RAW_DEBUG , WEBSOCKET_SERVER_URL } = CONFIG ;
1617
17- // We would like to support fetching the websocket server from storage
18+ // Track which tabs currently have an active content script.
19+ // Used to send a clean 'all_docs_closed' event when all Google Docs tabs close,
20+ // giving the server a session boundary without tearing down the WebSocket.
21+ const activeContentTabs = new Set ( ) ;
1822
19- // callback = new Promise((resolve, reject) => {
20- // storage.getItem('server').then((data) => resolve(data)
21- // } /* and other async logic */);
22- // websocketLogger( callback );
23+ // lo_event is initialized at most once per service worker lifetime.
24+ // Chrome will kill the worker when idle, which naturally tears down
25+ // the WebSocket. When the worker wakes back up, the module re-executes
26+ // from scratch so a fresh init() is safe.
27+ let loggerStarted = false ;
2328
24- // Track which tabs currently have an active content script and the state of
25- // our logger so that we only initialize logging when needed.
26- const activeContentTabs = new Set ( ) ;
27- let loEventActive = false ;
28- let loggers = [ ] ;
2929const manifestVersion = chrome . runtime . getManifest ( ) . version ;
3030
31- // We are not sure if this should be done within `websocketLogger()`'s `init`
32- // or one level up.
33- function startLogger ( ) {
34- if ( loEventActive ) return ;
35- loggers = [
36- consoleLogger ( ) ,
37- websocketLogger ( WEBSOCKET_SERVER_URL )
38- ] ;
31+ /**
32+ * Initialize lo_event exactly once per service worker lifetime.
33+ * Subsequent calls are no-ops. Chrome's worker lifecycle handles
34+ * cleanup — when the worker is terminated, all in-memory state
35+ * (including the WebSocket) is destroyed. On wake-up the module
36+ * re-evaluates, `loggerStarted` resets to false, and we get a
37+ * clean init().
38+ */
39+ function ensureLoggerStarted ( ) {
40+ if ( loggerStarted ) return ;
41+ loggerStarted = true ;
42+
43+ const wsLogger = websocketLogger ( WEBSOCKET_SERVER_URL ) ;
3944 loEvent . init (
4045 'org.mitros.writing_analytics' ,
4146 manifestVersion ,
42- loggers ,
47+ [ consoleLogger ( ) , wsLogger ] ,
4348 {
4449 debugLevel : loEventDebug . LEVEL . SIMPLE ,
45- // TODO document what we have currently and what we want
4650 metadata : [
4751 browserInfo ( ) ,
4852 chromeAuth ( ) ,
4953 localStorageInfo ( ) ,
50- sessionStorageInfo ( ) ,
54+ sessionStorageInfo ( )
5155 ]
5256 }
5357 ) ;
5458 loEvent . go ( ) ;
55- loEventActive = true ;
5659 loEvent . logEvent ( 'extension_loaded' , { } ) ;
5760 logFromServiceWorker ( 'Extension loaded' ) ;
5861}
5962
60- function stopLogger ( ) {
61- if ( ! loEventActive ) return ;
62- loEvent . logEvent ( 'terminate' , { } ) ;
63- loEventActive = false ;
63+ /**
64+ * Notify the server that the user has closed all Google Docs tabs.
65+ * This provides a clean session boundary for analytics without
66+ * triggering the server-side terminate handler (which would close
67+ * the WebSocket). The WebSocket stays alive so that if the user
68+ * opens a new doc before the worker dies, events flow immediately.
69+ *
70+ * We intentionally do NOT reset `loggerStarted` or tear down
71+ * lo_event. The worker will die naturally when Chrome decides
72+ * it's idle, which destroys the WebSocket from the client side.
73+ */
74+ function maybeSendSessionEnd ( ) {
75+ if ( loggerStarted && activeContentTabs . size === 0 ) {
76+ loEvent . logEvent ( 'all_docs_closed' , { } ) ;
77+ logFromServiceWorker ( 'All Google Docs tabs closed' ) ;
78+ }
79+ }
80+
81+ /**
82+ * Check for existing Google Docs tabs when the service worker starts.
83+ * This covers the case where Chrome wakes the worker (e.g. due to a
84+ * webRequest or message) but the content scripts already sent their
85+ * 'content_script_ready' message during a previous worker lifetime.
86+ */
87+ async function reconcileActiveGoogleDocsTabs ( ) {
88+ try {
89+ const tabs = await chrome . tabs . query ( { url : [ '*://docs.google.com/document/*' ] } ) ;
90+ for ( const tab of tabs ) {
91+ if ( tab . id !== undefined && isGoogleDocsDocumentUrl ( tab . url ) ) {
92+ activeContentTabs . add ( tab . id ) ;
93+ }
94+ }
95+ if ( activeContentTabs . size > 0 ) {
96+ logFromServiceWorker (
97+ `Reconciled ${ activeContentTabs . size } Google Docs tab(s) on worker start`
98+ ) ;
99+ ensureLoggerStarted ( ) ;
100+ }
101+ } catch ( error ) {
102+ logFromServiceWorker ( `Failed to reconcile Google Docs tabs: ${ error } ` ) ;
103+ }
64104}
65105
66- // Function to serve as replacement for
67- // chrome.extension.getBackgroundPage().console.log(event); because it is not allowed in V3
68- // It logs the event to the console for debugging.
106+ /**
107+ * Replacement for chrome.extension.getBackgroundPage().console.log()
108+ * which is not available in Manifest V3 service workers.
109+ */
69110function logFromServiceWorker ( event ) {
70111 console . log ( event ) ;
71112}
72113
114+ /**
115+ * Returns true if `url` points to a Google Docs document.
116+ */
117+ function isGoogleDocsDocumentUrl ( url ) {
118+ return typeof url === 'string' && / ^ h t t p s ? : \/ \/ d o c s \. g o o g l e \. c o m \/ d o c u m e n t \/ / i. test ( url ) ;
119+ }
120+
121+ /**
122+ * Check if this is a Google Docs save request. Return true for something like:
123+ * https://docs.google.com/document/d/1lt_lSfEM.../save?id=...
124+ * And false otherwise.
125+ *
126+ * Note that while `save` is often early in the URL, on the first
127+ * few requests of a web page load, it can be towards the end. We
128+ * went from a conservative regexp to a liberal one. We should
129+ * confirm this never catches extra requests, though.
130+ */
73131function this_a_google_docs_save ( request ) {
74- /*
75- Check if this is a Google Docs save request. Return true for something like:
76- https://docs.google.com/document/d/1lt_lSfEM9jd7Ga6uzENS_s8ZajcxpE0cKuzXbDoBoyU/save?id=dfhjklhsjklsdhjklsdhjksdhkjlsdhkjsdhsdkjlhsd&sid=dhsjklhsdjkhsdas&vc=2&c=2&w=2&smv=2&token=lasjklhasjkhsajkhsajkhasjkashjkasaajhsjkashsajksas&includes_info_params=true
77- And false otherwise.
78-
79- Note that while `save` is often early in the URL, on the first
80- few requests of a web page load, it can be towards the end. We
81- went from a conservative regexp to a liberal one. We should
82- confirm this never catches extra requests, though.
83- */
84- if ( request . url . match ( / .* : \/ \/ d o c s \. g o o g l e \. c o m \/ d o c u m e n t \/ ( .* ) \/ s a v e / i) ) {
85- return true ;
86- }
87- return false ;
132+ return / .* : \/ \/ d o c s \. g o o g l e \. c o m \/ d o c u m e n t \/ ( .* ) \/ s a v e / i. test ( request . url ) ;
88133}
89134
135+ /**
136+ * These requests correspond to some server-push features, such as
137+ * collaborative editing. We still need to reverse-engineer these.
138+ *
139+ * Note that we cannot monitor request responses without more
140+ * complex JavaScript. See:
141+ * https://stackoverflow.com/questions/6831916
142+ */
90143function this_a_google_docs_bind ( request ) {
91- /*
92- These request correspond to some server-push features, such as collaborative
93- editing. We still need to reverse-engineer these.
94-
95- Note that we cannot monitor request responses without more
96- complex JavaScript. See:
97-
98- https://stackoverflow.com/questions/6831916/is-it-possible-to-monitor-http-traffic-in-chrome-using-an-extension#6832018
99- */
100- if ( request . url . match ( / .* : \/ \/ d o c s \. g o o g l e \. c o m \/ d o c u m e n t \/ ( .* ) \/ b i n d / i) ) {
101- return true ;
102- }
103- return false ;
144+ return / .* : \/ \/ d o c s \. g o o g l e \. c o m \/ d o c u m e n t \/ ( .* ) \/ b i n d / i. test ( request . url ) ;
104145}
105146
106- // Figure out the system settings. Note this is asynchronous, so we
107- // chain dequeue_events when this is done.
108- /*
109- var WRITINGJS_AJAX_SERVER = null;
110-
111- chrome.storage.sync.get(['process_server'], function(result) {
112- //WRITINGJS_AJAX_SERVER = result['process_server'];
113- if(!WRITINGJS_AJAX_SERVER) {
114- WRITINGJS_AJAX_SERVER = "https://writing.learning-observer.org/webapi/";
115- }
116- dequeue_events();
117- });*/
118-
119147// Listen for the keystroke messages from the page script and forward to the server.
120148chrome . runtime . onMessage . addListener (
121- function ( request , sender , sendResponse ) {
122- // Lifecycle messages from content scripts manage the logger state
123- if ( request ?. type === 'content_script_ready' ) {
149+ function ( request , sender , _sendResponse ) {
150+ // Guard against null / malformed messages from other extensions or
151+ // internal Chrome plumbing.
152+ if ( ! request || typeof request !== 'object' ) {
153+ return false ;
154+ }
155+
156+ // Lifecycle messages from content scripts manage tab tracking
157+ if ( request . type === 'content_script_ready' ) {
124158 if ( sender . tab ?. id !== undefined ) {
125159 activeContentTabs . add ( sender . tab . id ) ;
126- if ( ! loEventActive ) {
127- startLogger ( ) ;
128- }
129160 }
130- return ;
131- } else if ( request ?. type === 'content_script_unloading' ) {
161+ ensureLoggerStarted ( ) ;
162+ return false ;
163+ }
164+
165+ if ( request . type === 'content_script_unloading' ) {
132166 if ( sender . tab ?. id !== undefined ) {
133167 activeContentTabs . delete ( sender . tab . id ) ;
134- if ( activeContentTabs . size === 0 ) {
135- stopLogger ( ) ;
136- }
168+ maybeSendSessionEnd ( ) ;
137169 }
138- return ;
170+ return false ;
171+ }
172+
173+ // Ignore messages that don't carry an event type — we can't route them.
174+ if ( ! request . event ) {
175+ logFromServiceWorker ( 'Ignoring message with no event type' ) ;
176+ return false ;
139177 }
140- // Forward analytics events only when the logger is active
141- if ( ! loEventActive ) {
142- return ;
178+
179+ // Any analytics message from a content script means the tab is active.
180+ // Trust the sender rather than gating on activeContentTabs membership,
181+ // which avoids a race with reconcileActiveGoogleDocsTabs().
182+ if ( sender . tab ?. id !== undefined ) {
183+ activeContentTabs . add ( sender . tab . id ) ;
143184 }
185+ ensureLoggerStarted ( ) ;
186+
144187 request [ 'wa_source' ] = 'client_page' ;
145188 loEvent . logEvent ( request [ 'event' ] , request ) ;
189+
190+ // Explicitly return false — we will not call sendResponse asynchronously.
191+ return false ;
146192 }
147193) ;
148194
@@ -152,34 +198,33 @@ chrome.webRequest.onBeforeRequest.addListener(
152198 This allows us to log web requests. There are two types of web requests:
153199 * Ones we understand (SEMANTIC)
154200 * Ones we don't (RAW/DEBUG)
155-
201+
156202 There is an open question as to how we ought to handle RAW/DEBUG
157203 events. We will reduce potential issues around collecting data
158204 we don't want (privacy, storage, bandwidth) if we silently drop
159205 these. On the other hand, we significantly increase risk of
160206 losing user data should Google ever change their web API. If we
161207 log everything, we have good odds of being able to
162208 reverse-engineer the new API, and reconstruct what happened.
163-
209+
164210 Our current strategy is to:
165211 * Log the former requests in a clean way, extracting the data we
166212 want
167213 * Have a flag to log the debug requests (which includes the
168214 unparsed version of events we want).
169- We should step through and see how this code manages failures,
170-
215+ We should step through and see how this code manages failures.
216+
171217 For development purposes, both modes of operation are
172218 helpful. Having these is nice for reverse-engineering,
173219 especially new pages. They do inject a lot of noise, though, and
174220 from there, being able to easily ignore these is nice.
175- */
221+ */
176222 function ( request ) {
177- // No logger availaber
178- if ( ! loEventActive ) {
179- return ;
180- }
181- //chrome.extension.getBackgroundPage().console.log("Web request url:"+request.url);
182- var formdata = { } ;
223+ // A webRequest for docs.google.com means a Google Docs tab is active.
224+ // Make sure the logger is running.
225+ ensureLoggerStarted ( ) ;
226+
227+ let formdata = { } ;
183228 let event ;
184229 if ( request . requestBody ) {
185230 formdata = request . requestBody . formData ;
@@ -195,11 +240,10 @@ chrome.webRequest.onBeforeRequest.addListener(
195240 }
196241
197242 if ( this_a_google_docs_save ( request ) ) {
198- //chrome.extension.getBackgroundPage().console.log("Google Docs bundles "+request.url);
199243 try {
200244 /* We should think through which time stamps we should log. These are all subtly
201- different: browser event versus request timestamp, as well as user time zone
202- versus GMT. */
245+ different: browser event versus request timestamp, as well as user time zone
246+ versus GMT. */
203247 event = {
204248 'doc_id' : googledocs_id_from_url ( request . url ) ,
205249 'tab_id' : googledocs_tab_id_from_url ( request . url ) ,
@@ -227,28 +271,53 @@ chrome.webRequest.onBeforeRequest.addListener(
227271 } else if ( this_a_google_docs_bind ( request ) ) {
228272 logFromServiceWorker ( request ) ;
229273 } else {
230- logFromServiceWorker ( " Not a save or bind: " + request . url ) ;
274+ logFromServiceWorker ( ' Not a save or bind: ' + request . url ) ;
231275 }
232276 } ,
233- { urls : [ " *://docs.google.com/*" ] } ,
277+ { urls : [ ' *://docs.google.com/*' ] } ,
234278 [ 'requestBody' ]
235279) ;
236280
237- // re-injected scripts when chrome extension is reloaded, upgraded or re-installed
281+ // Clean up tab tracking when a tab is closed.
282+ chrome . tabs . onRemoved . addListener ( function ( tabId ) {
283+ activeContentTabs . delete ( tabId ) ;
284+ maybeSendSessionEnd ( ) ;
285+ } ) ;
286+
287+ // Track navigation: add tabs that navigate to Google Docs,
288+ // remove tabs that navigate away. Only react when the URL
289+ // actually changes to avoid noise from status/favicon/title updates.
290+ chrome . tabs . onUpdated . addListener ( function ( tabId , changeInfo , _tab ) {
291+ if ( ! changeInfo . url ) return ;
292+
293+ if ( isGoogleDocsDocumentUrl ( changeInfo . url ) ) {
294+ activeContentTabs . add ( tabId ) ;
295+ ensureLoggerStarted ( ) ;
296+ } else if ( activeContentTabs . has ( tabId ) ) {
297+ activeContentTabs . delete ( tabId ) ;
298+ maybeSendSessionEnd ( ) ;
299+ }
300+ } ) ;
301+
302+ // Reconcile on worker start — fire and forget since all event paths
303+ // independently call ensureLoggerStarted() when needed.
304+ reconcileActiveGoogleDocsTabs ( ) ;
305+
306+ // Re-inject content scripts when the extension is reloaded, upgraded, or re-installed.
238307// https://stackoverflow.com/questions/10994324/chrome-extension-content-script-re-injection-after-upgrade-or-install
239308chrome . runtime . onInstalled . addListener ( reinjectContentScripts ) ;
240309async function reinjectContentScripts ( ) {
241310 for ( const contentScript of chrome . runtime . getManifest ( ) . content_scripts ) {
242311 for ( const tab of await chrome . tabs . query ( { url : contentScript . matches } ) ) {
243- // re-inject content script
244- await chrome . scripting . executeScript ( {
245- target : { tabId : tab . id , allFrames : true } ,
246- files : contentScript . js ,
247- } , function ( ) {
248- if ( ! chrome . runtime . lastError ) {
249- console . log ( 'Content script re-injected successfully' ) ;
250- }
251- } ) ;
312+ try {
313+ await chrome . scripting . executeScript ( {
314+ target : { tabId : tab . id , allFrames : true } ,
315+ files : contentScript . js
316+ } ) ;
317+ console . log ( `Content script re-injected into tab ${ tab . id } ` ) ;
318+ } catch ( err ) {
319+ console . warn ( `Failed to inject into tab ${ tab . id } : ${ err . message } ` ) ;
320+ }
252321 }
253322 }
254323}
0 commit comments