[Select to Speak] Update extension to MV3 manifest

List of changes:
* Create an off screen document to handle DOM interactions:
  * playing the null selection tone in select_to_speak.ts
  * google docs specific logic in input_handler.ts
* Removing the use of eval from a content script and hard coding the extension ID instead.
* Replaced deprecated API end point chrome.tabs.executeScript
* Add KeepAlive class
* Use MV3 InstanceChecker

Testing:
* Basic testing of select-to-speak
* Null selection did not work before (https://issuetracker.google.com/issues/398728543), but I confirmed that null tone can be heard by calling `chrome.runtime.sendMessage(undefined, {command: 'playNullSelectionTone'});` directly.
* After turning on select-to-speak and opening a google doc, the text of the google doc can be selected and spoken with text to speak
* If the google doc is already open and select to speak is turned on, the google doc text will not be spoken, bug filed here: https://issuetracker.google.com/issues/399093287
* I was not able to test google docs specific logic in input_hanlder.ts because of a pre-existing bug: https://issuetracker.google.com/issues/400335369

Bug: 388867837
Change-Id: I375fc3f7ce71445e65722d366ec910b4be6b1819
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6324769
Reviewed-by: Akihiro Ota <akihiroota@chromium.org>
Commit-Queue: Valerie Young <spectranaut@igalia.com>
Cr-Commit-Position: refs/heads/main@{#1433622}
diff --git a/chrome/browser/resources/chromeos/accessibility/definitions/offscreen.d.ts b/chrome/browser/resources/chromeos/accessibility/definitions/offscreen.d.ts
new file mode 100644
index 0000000..f22b53b
--- /dev/null
+++ b/chrome/browser/resources/chromeos/accessibility/definitions/offscreen.d.ts
@@ -0,0 +1,48 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Definitions for chrome.offscreen API
+ * Generated from: extensions/common/api/offscreen.idl
+ * run `tools/json_schema_compiler/compiler.py
+ * extensions/common/api/offscreen.idl -g ts_definitions` to regenerate.
+ */
+
+
+
+declare namespace chrome {
+  export namespace offscreen {
+
+    export enum Reason {
+      TESTING = 'TESTING',
+      AUDIO_PLAYBACK = 'AUDIO_PLAYBACK',
+      IFRAME_SCRIPTING = 'IFRAME_SCRIPTING',
+      DOM_SCRAPING = 'DOM_SCRAPING',
+      BLOBS = 'BLOBS',
+      DOM_PARSER = 'DOM_PARSER',
+      USER_MEDIA = 'USER_MEDIA',
+      DISPLAY_MEDIA = 'DISPLAY_MEDIA',
+      WEB_RTC = 'WEB_RTC',
+      CLIPBOARD = 'CLIPBOARD',
+      LOCAL_STORAGE = 'LOCAL_STORAGE',
+      WORKERS = 'WORKERS',
+      BATTERY_STATUS = 'BATTERY_STATUS',
+      MATCH_MEDIA = 'MATCH_MEDIA',
+      GEOLOCATION = 'GEOLOCATION',
+    }
+
+    export interface CreateParameters {
+      reasons: Reason[];
+      url: string;
+      justification: string;
+    }
+
+    export function createDocument(parameters: CreateParameters): Promise<void>;
+
+    export function closeDocument(): Promise<void>;
+
+    export function hasDocument(): Promise<boolean>;
+
+  }
+}
diff --git a/chrome/browser/resources/chromeos/accessibility/definitions/scripting.d.ts b/chrome/browser/resources/chromeos/accessibility/definitions/scripting.d.ts
new file mode 100644
index 0000000..f9199aa
--- /dev/null
+++ b/chrome/browser/resources/chromeos/accessibility/definitions/scripting.d.ts
@@ -0,0 +1,96 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Definitions for chrome.scripting API
+ * Generated from: chrome/common/extensions/api/scripting.idl
+ * run `tools/json_schema_compiler/compiler.py
+ * chrome/common/extensions/api/scripting.idl -g ts_definitions` to regenerate.
+ */
+
+
+
+declare namespace chrome {
+  export namespace scripting {
+
+    export const globalParams: number;
+
+    export enum StyleOrigin {
+      AUTHOR = 'AUTHOR',
+      USER = 'USER',
+    }
+
+    export enum ExecutionWorld {
+      ISOLATED = 'ISOLATED',
+      MAIN = 'MAIN',
+    }
+
+    export interface InjectionTarget {
+      tabId: number;
+      frameIds?: number[];
+      documentIds?: string[];
+      allFrames?: boolean;
+    }
+
+    export interface ScriptInjection {
+      func?: () => void;
+      args?: any[];
+      function?: () => void;
+      files?: string[];
+      target: InjectionTarget;
+      world?: ExecutionWorld;
+      injectImmediately?: boolean;
+    }
+
+    export interface CSSInjection {
+      target: InjectionTarget;
+      css?: string;
+      files?: string[];
+      origin?: StyleOrigin;
+    }
+
+    export interface InjectionResult {
+      result?: any;
+      frameId: number;
+      documentId: string;
+    }
+
+    export interface RegisteredContentScript {
+      id: string;
+      matches?: string[];
+      excludeMatches?: string[];
+      css?: string[];
+      js?: string[];
+      allFrames?: boolean;
+      matchOriginAsFallback?: boolean;
+      runAt?: extensionTypes.RunAt;
+      persistAcrossSessions?: boolean;
+      world?: ExecutionWorld;
+    }
+
+    export interface ContentScriptFilter {
+      ids?: string[];
+    }
+
+    export function executeScript(injection: ScriptInjection):
+        Promise<InjectionResult[]>;
+
+    export function insertCSS(injection: CSSInjection): Promise<void>;
+
+    export function removeCSS(injection: CSSInjection): Promise<void>;
+
+    export function registerContentScripts(scripts: RegisteredContentScript[]):
+        Promise<void>;
+
+    export function getRegisteredContentScripts(filter?: ContentScriptFilter):
+        Promise<RegisteredContentScript[]>;
+
+    export function unregisterContentScripts(filter?: ContentScriptFilter):
+        Promise<void>;
+
+    export function updateContentScripts(scripts: RegisteredContentScript[]):
+        Promise<void>;
+
+  }
+}
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/BUILD.gn b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/BUILD.gn
index fec8bef..474f1e9 100644
--- a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/BUILD.gn
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/BUILD.gn
@@ -38,6 +38,7 @@
   "ui_manager.ts",
   "prefs_manager.ts",
   "select_to_speak.ts",
+  "offscreen.ts",
 ]
 
 # Root dir must be the parent directory so it can reach common.
@@ -61,7 +62,9 @@
     "../../definitions/extensions.d.ts",
     "../../definitions/extension_types.d.ts",
     "../../definitions/i18n.d.ts",
+    "../../definitions/offscreen.d.ts",
     "../../definitions/runtime.d.ts",
+    "../../definitions/scripting.d.ts",
     "../../definitions/settings_private_mv2.d.ts",
     "../../definitions/storage_mv2.d.ts",
     "../../definitions/tabs.d.ts",
@@ -91,6 +94,8 @@
     "background.html",
     "checked.png",
     "earcons/null_selection.ogg",
+    "gdocs_script.js",
+    "offscreen.html",
     "select_to_speak-2x.svg",
     "sts-icon-128.png",
     "sts-icon-16.png",
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/gdocs_script.js b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/gdocs_script.js
new file mode 100644
index 0000000..49133366
--- /dev/null
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/gdocs_script.js
@@ -0,0 +1,6 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Set to Select to Speak extension ID.
+window['_docs_annotate_canvas_by_ext'] = 'klbcgckkldhdhonijdbnhhaiedfkllef';
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/input_handler.ts b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/input_handler.ts
index bb9bf19..2922b9e 100644
--- a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/input_handler.ts
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/input_handler.ts
@@ -6,6 +6,8 @@
 
 import {SelectToSpeakConstants} from './select_to_speak_constants.js';
 
+type MessageSender = chrome.runtime.MessageSender;
+
 /**
  * Callbacks for InputHandler.
  * |canStartSelecting| returns true if the user can start selecting a region
@@ -39,13 +41,9 @@
   private isSelectionKeyDown_: boolean;
   private keysCurrentlyDown_: Set<number>;
   private keysPressedTogether_: Set<number>;
-  private lastClearClipboardDataTime_: Date;
-  private lastReadClipboardDataTime_: Date;
   private mouseStart_: {x: number, y: number};
   private mouseEnd_: {x: number, y: number};
   private trackingMouse_: boolean;
-  static kClipboardClearMaxDelayMs: number;
-  static kClipboardReadMaxDelayMs: number;
 
   /**
    * Please keep fields in alphabetical order.
@@ -62,65 +60,14 @@
      */
     this.keysPressedTogether_ = new Set();
 
-    /**
-     * The timestamp at which the last clipboard data clear was requested.
-     * Used to make sure we don't clear the clipboard on a user's request,
-     * but only after the clipboard was used to read selected text.
-     */
-    this.lastClearClipboardDataTime_ = new Date(0);
-
-    /**
-     * The timestamp at which clipboard data read was requested by the user
-     * doing a "read selection" keystroke on a Google Docs app. If a
-     * clipboard change event comes in within kClipboardReadMaxDelayMs,
-     * Select-to-Speak will read that text out loud.
-     */
-    this.lastReadClipboardDataTime_ = new Date(0);
-
     this.mouseStart_ = {x: 0, y: 0};
     this.mouseEnd_ = {x: 0, y: 0};
     this.trackingMouse_ = false;
   }
 
-  private clearClipboard_(): void {
-    this.lastClearClipboardDataTime_ = new Date();
-    document.execCommand('copy');
-  }
-
-  private onClipboardCopy_(evt: ClipboardEvent): void {
-    if (new Date().getTime() - this.lastClearClipboardDataTime_.getTime() <
-        InputHandler.kClipboardClearMaxDelayMs) {
-      // onClipboardPaste has just completed reading the clipboard for speech.
-      // This is used to clear the clipboard.
-      // @ts-ignore: TODO(b/270623046): clipboardData can be null.
-      evt.clipboardData.setData('text/plain', '');
-      evt.preventDefault();
-      this.lastClearClipboardDataTime_ = new Date(0);
-    }
-  }
-
-  private onClipboardDataChanged_() : void {
-    if (new Date().getTime() - this.lastReadClipboardDataTime_.getTime() <
-        InputHandler.kClipboardReadMaxDelayMs) {
-      // The data has changed, and we are ready to read it.
-      // Get it using a paste.
-      document.execCommand('paste');
-    }
-  }
-
-  private onClipboardPaste_(evt: ClipboardEvent): void {
-    if (new Date().getTime() - this.lastReadClipboardDataTime_.getTime() <
-        InputHandler.kClipboardReadMaxDelayMs) {
-      // Read the current clipboard data.
-      evt.preventDefault();
-      // @ts-ignore: TODO(b/270623046): clipboardData can be null.
-      this.callbacks_.onTextReceived(evt.clipboardData.getData('text/plain'));
-      this.lastReadClipboardDataTime_ = new Date(0);
-      // Clear the clipboard data by copying nothing (the current document).
-      // Do this in a timeout to avoid a recursive warning per
-      // https://crbug.com/363288.
-      setTimeout(() => this.clearClipboard_(), 0);
-    }
+  private onClipboardPaste_(content: String): void {
+    // @ts-ignore: TODO(crbug.com/270623046): clipboardData can be null.
+    this.callbacks_.onTextReceived(content);
   }
 
   /**
@@ -130,10 +77,9 @@
    * any particular window.
    */
   setUpEventListeners(): void {
-    chrome.clipboard.onClipboardDataChanged.addListener(
-        () => this.onClipboardDataChanged_());
-    document.addEventListener('paste', evt => this.onClipboardPaste_(evt));
-    document.addEventListener('copy', evt => this.onClipboardCopy_(evt));
+    chrome.clipboard.onClipboardDataChanged.addListener(() => {
+      chrome.runtime.sendMessage(undefined, {command: 'clipboardDataChanged'});
+    });
     chrome.accessibilityPrivate.onSelectToSpeakKeysPressedChanged.addListener(
         (keysPressed) => {
           this.onKeysPressedChanged(new Set(keysPressed));
@@ -142,6 +88,18 @@
         (eventType, mouseX, mouseY) => {
           this.onMouseEvent(eventType, mouseX, mouseY);
         });
+
+    // Handle messages from the offscreen document.
+    chrome.runtime.onMessage.addListener(
+        (message: any|undefined, _sender: MessageSender,
+         _sendResponse: (value: any) => void) => {
+          switch (message['command']) {
+            case 'paste':
+              this.onClipboardPaste_(message);
+              break;
+          }
+          return false;
+        });
   }
 
   /**
@@ -166,7 +124,8 @@
    * Sets the date at which we last wanted the clipboard data to be read.
    */
   onRequestReadClipboardData(): void {
-    this.lastReadClipboardDataTime_ = new Date();
+    chrome.runtime.sendMessage(
+        undefined, {command: 'updateLastReadClipboardDataTime'});
   }
 
   /**
@@ -346,11 +305,3 @@
   }
 }
 
-// Number of milliseconds to wait after requesting a clipboard read
-// before clipboard change and paste events are ignored.
-InputHandler.kClipboardReadMaxDelayMs = 1000;
-
-// Number of milliseconds to wait after requesting a clipboard copy
-// before clipboard copy events are ignored, used to clear the clipboard
-// after reading data in a paste event.
-InputHandler.kClipboardClearMaxDelayMs = 500;
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.html b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.html
new file mode 100644
index 0000000..cbae3e95
--- /dev/null
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.html
@@ -0,0 +1,8 @@
+<!--
+Copyright 2025 The Chromium Authors
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file.
+-->
+<body>
+  <script src="offscreen.js"></script>
+</body>
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.ts b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.ts
new file mode 100644
index 0000000..546abb8
--- /dev/null
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/offscreen.ts
@@ -0,0 +1,118 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+type MessageSender = chrome.runtime.MessageSender;
+
+let AudioAndCopyHandlerObject;
+
+// Number of milliseconds to wait after requesting a clipboard read
+// before clipboard change and paste events are ignored.
+const kClipboardReadMaxDelayMs = 1000;
+
+// Number of milliseconds to wait after requesting a clipboard copy
+// before clipboard copy events are ignored, used to clear the clipboard
+// after reading data in a paste event.
+const kClipboardClearMaxDelayMs = 500;
+
+class AudioAndCopyHandler {
+  private audioElement_: HTMLAudioElement;
+
+  private lastClearClipboardDataTime_: Date;
+  private lastReadClipboardDataTime_: Date;
+
+  constructor() {
+    this.audioElement_ = new Audio('earcons/null_selection.ogg');
+
+    /**
+     * The timestamp at which the last clipboard data clear was requested.
+     * Used to make sure we don't clear the clipboard on a user's request,
+     * but only after the clipboard was used to read selected text.
+     */
+    this.lastClearClipboardDataTime_ = new Date(0);
+
+    /**
+     * The timestamp at which clipboard data read was requested by the user
+     * doing a "read selection" keystroke on a Google Docs app. If a
+     * clipboard change event comes in within kClipboardReadMaxDelayMs,
+     * Select-to-Speak will read that text out loud.
+     */
+    this.lastReadClipboardDataTime_ = new Date(0);
+
+    document.addEventListener('paste', evt => {
+      this.onClipboardPaste_(evt);
+    });
+
+    document.addEventListener('copy', evt => {
+      this.onClipboardCopy_(evt);
+    });
+
+    // Handle messages from the service worker.
+    chrome.runtime.onMessage.addListener(
+        (message: any|undefined, _sender: MessageSender,
+         _sendResponse: (value: any) => void) => {
+          switch (message['command']) {
+            case 'playNullSelectionTone':
+              this.audioElement_.play();
+              break;
+            case 'updateLastReadClipboardDataTime':
+              this.lastReadClipboardDataTime_ = new Date();
+              break;
+            case 'clipboardDataChanged':
+              this.onClipboardDataChanged_();
+              break;
+          }
+          return false;
+        });
+  }
+
+  private onClipboardDataChanged_(): void {
+    if (new Date().getTime() - this.lastReadClipboardDataTime_.getTime() <
+        kClipboardReadMaxDelayMs) {
+      // The data has changed, and we are ready to read it.
+      // Get it using a paste.
+      document.execCommand('paste');
+    }
+  }
+
+  private onClipboardPaste_(evt: ClipboardEvent): void {
+    if (new Date().getTime() - this.lastReadClipboardDataTime_.getTime() <
+        kClipboardReadMaxDelayMs) {
+      // Read the current clipboard data.
+      evt.preventDefault();
+      this.lastReadClipboardDataTime_ = new Date(0);
+      // Clear the clipboard data by copying nothing (the current document).
+      // Do this in a timeout to avoid a recursive warning per
+      // https://crbug.com/363288.
+      setTimeout(() => this.clearClipboard_(), 0);
+
+      // @ts-ignore: TODO(crbug.com/270623046): clipboardData can be null.
+      let content = evt.clipboardData.getData('text/plain');
+      chrome.runtime.sendMessage(undefined, {
+        command: 'paste',
+        content,
+      });
+    }
+  }
+
+  private onClipboardCopy_(evt: ClipboardEvent): void {
+    if (new Date().getTime() - this.lastClearClipboardDataTime_.getTime() <
+        kClipboardClearMaxDelayMs) {
+      // onClipboardPaste has just completed reading the clipboard for speech.
+      // This is used to clear the clipboard.
+      // @ts-ignore: TODO(crbug.com/270623046): clipboardData can be null.
+      evt.clipboardData.setData('text/plain', '');
+      evt.preventDefault();
+      this.lastClearClipboardDataTime_ = new Date(0);
+    }
+  }
+
+  private clearClipboard_(): void {
+    this.lastClearClipboardDataTime_ = new Date();
+    document.execCommand('copy');
+  }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+  AudioAndCopyHandlerObject = new AudioAndCopyHandler();
+});
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak.ts b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak.ts
index ff696c9..b7b104a 100644
--- a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak.ts
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak.ts
@@ -5,7 +5,6 @@
 import {AutomationPredicate} from '/common/automation_predicate.js';
 import {AutomationUtil} from '/common/automation_util.js';
 import {constants} from '/common/constants.js';
-import {FlagName, Flags} from '/common/flags.js';
 import {NodeNavigationUtils} from '/common/node_navigation_utils.js';
 import {NodeUtils} from '/common/node_utils.js';
 import {ParagraphUtils} from '/common/paragraph_utils.js';
@@ -63,10 +62,10 @@
   private currentNodeGroupItemIndex_: number;
   private currentNodeGroups_: ParagraphUtils.NodeGroup[];
   private currentNodeWord_: {start: number, end: number}|null;
+  private createdOffscreenDocument_: Promise<void>|null = null;
   private desktop_: AutomationNode|undefined;
   private inputHandler_: InputHandler|null;
   private intervalId_: number|undefined;
-  private nullSelectionTone_: HTMLAudioElement;
   private onStateChangeRequestedCallbackForTest_: (() => void)|null;
   private prefsManager_: PrefsManager;
   private scrollToSpokenNode_: boolean;
@@ -136,8 +135,6 @@
      */
     this.intervalId_;
 
-    this.nullSelectionTone_ = new Audio('earcons/null_selection.ogg');
-
     /**
      * Function to be called when a state change request is received from the
      * accessibilityPrivate API.
@@ -196,24 +193,17 @@
     this.prefsManager_.initPreferences();
 
     this.runContentScripts_();
-    this.setUpEventListeners_();
-
-    await Flags.init();
+    await this.setUpEventListeners_();
     const createArgs: chrome.contextMenus.CreateProperties = {
       title: chrome.i18n.getMessage(
           'select_to_speak_listen_context_menu_option_text'),
       contexts: [chrome.contextMenus.ContextType.SELECTION],
       id: 'select_to_speak',
     };
-    if (Flags.isEnabled(FlagName.MANIFEST_V3)) {
-      chrome.contextMenus.onClicked.addListener(() => {
-        this.getFocusedNodeAndSpeakSelectedText_();
-      });
-    } else {
-      createArgs['onclick'] = () => {
-        this.getFocusedNodeAndSpeakSelectedText_();
-      };
-    }
+    chrome.contextMenus.onClicked.addListener(() => {
+      this.getFocusedNodeAndSpeakSelectedText_();
+    });
+
     // Install the context menu in the Ash browser.
     await chrome.contextMenus.create(createArgs);
 
@@ -350,8 +340,8 @@
       // that a node is in ARC++.
       if (!NodeUtils.findAllMatching(root, rect, nodes) && focusedNode &&
           focusedNode.root!.role !== RoleType.DESKTOP) {
-        // TODO(b/314203187): Determine if not null assertion is appropriate
-        // here.
+        // TODO(crbug.com/314203187): Determine if not null assertion is
+        // appropriate here.
         NodeUtils.findAllMatching(focusedNode.root!, rect, nodes);
       }
       if (nodes.length === 1 && UiManager.isTrayButton(nodes[0])) {
@@ -491,7 +481,7 @@
       method: MetricsUtils.StartSpeechMethod|null,
       focusedNode: AutomationNode|undefined): void {
     const nodes = [];
-    // TODO(b/314204374): AutomationUtil.findNextNode may return null.
+    // TODO(crbug.com/314204374): AutomationUtil.findNextNode may return null.
     let selectedNode: AutomationNode|null = firstPosition.node;
     // If the method is set, a user requested the speech.
     const userRequested = method !== null;
@@ -584,11 +574,14 @@
           return;
         }
         const tab = tabs[0];
-        chrome.tabs.executeScript(tab.id, {
-          allFrames: true,
-          matchAboutBlank: true,
-          code: 'document.execCommand("copy");',
+        chrome.scripting.executeScript({
+          target: {
+            tabId: tab.id!,
+            allFrames: true,
+          },
+          func: () => document.execCommand('copy'),
         });
+
         if (userRequested) {
           MetricsUtils.recordStartEvent(methodNumber, this.prefsManager_);
         }
@@ -636,8 +629,10 @@
    * keystroke but nothing was selected.
    */
   private onNullSelection_(): void {
+    this.maybeCreateOffscreenDocument_();
+
     if (!this.shouldShowNavigationControls_()) {
-      this.nullSelectionTone_.play();
+      chrome.runtime.sendMessage(undefined, {command: 'playNullSelectionTone'});
       return;
     }
 
@@ -807,7 +802,11 @@
         },
         tabs => {
           tabs.forEach(tab => {
-            chrome.tabs.executeScript(tab.id, {file: script});
+            chrome.scripting.executeScript({
+              target: {tabId: tab.id!},
+              files: [script],
+              world: chrome.scripting.ExecutionWorld.MAIN,
+            });
           });
         });
   }
@@ -815,7 +814,10 @@
   /**
    * Set up event listeners user input.
    */
-  private setUpEventListeners_(): void {
+  private async setUpEventListeners_(): Promise<void> {
+    // Offscreen documented necessary for InputHandler.
+    await this.maybeCreateOffscreenDocument_();
+
     this.inputHandler_ = new InputHandler({
       // canStartSelecting: Whether mouse selection can begin.
       canStartSelecting: () => {
@@ -1212,7 +1214,7 @@
 
     MetricsUtils.recordTtsEngineUsed(voiceName, this.prefsManager_);
     this.ttsManager_.speak(
-        // TODO(b/314203187): Options may be undefined.
+        // TODO(crbug.com/314203187): Options may be undefined.
         nodeGroup.text, options!, this.prefsManager_.isNetworkVoice(voiceName),
         fallbackVoiceName);
   }
@@ -1820,6 +1822,43 @@
     // Desktop already loaded.
     callback();
   }
+
+  /**
+   * Creates an offscreen document if it does not yet exist.
+   * The offscreen document is used to play sounds (the null tone)
+   * and listen to copy/paste document events in InputHandler.
+   */
+  async maybeCreateOffscreenDocument_() {
+    const offscreenUrl =
+        chrome.runtime.getURL('select_to_speak/offscreen.html');
+
+    const existingContexts = await chrome.runtime.getContexts({
+      contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
+      documentUrls: [offscreenUrl]
+    });
+
+    if (existingContexts.length > 0) {
+      return;
+    }
+
+    if (!this.createdOffscreenDocument_) {
+      this.createdOffscreenDocument_ = chrome.offscreen.createDocument({
+        url: offscreenUrl,
+        reasons: [
+          chrome.offscreen.Reason.AUDIO_PLAYBACK,
+          chrome.offscreen.Reason.DOM_PARSER
+        ],
+        justification: 'Use the audio element and monitor clipboard',
+      });
+    }
+    try {
+      await this.createdOffscreenDocument_;
+    } catch (error) {
+      console.error('Failed to create offscreen document: ', error);
+      throw error;
+    }
+    this.createdOffscreenDocument_ = null;
+  }
 }
 
 TestImportManager.exportForTesting(getGSuiteAppRoot);
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak_main.ts b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak_main.ts
index 5905c5e..3d41f04 100644
--- a/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak_main.ts
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak/mv3/select_to_speak_main.ts
@@ -6,7 +6,8 @@
 import '/common/async_util.js';
 import '/common/event_generator.js';
 
-import {InstanceChecker} from '/common/instance_checker.js';
+import {KeepAlive} from '/common/keep_alive.js';
+import {InstanceChecker} from '/common/mv3/instance_checker.js';
 import {TestImportManager} from '/common/testing/test_import_manager.js';
 
 import {SelectToSpeak} from './select_to_speak.js';
@@ -14,6 +15,9 @@
 export let selectToSpeak: SelectToSpeak;
 
 if (InstanceChecker.isActiveInstance()) {
+  // Prevent this service worker from going to sleep.
+  KeepAlive.init();
+
   selectToSpeak = new SelectToSpeak();
   TestImportManager.exportForTesting(['selectToSpeak', selectToSpeak]);
 }
diff --git a/chrome/browser/resources/chromeos/accessibility/select_to_speak_manifest.json.jinja2 b/chrome/browser/resources/chromeos/accessibility/select_to_speak_manifest.json.jinja2
index 6a0b784..377a6df 100644
--- a/chrome/browser/resources/chromeos/accessibility/select_to_speak_manifest.json.jinja2
+++ b/chrome/browser/resources/chromeos/accessibility/select_to_speak_manifest.json.jinja2
@@ -23,6 +23,10 @@
 {% endif %}
   "permissions": [
     "accessibilityPrivate",
+{% if is_manifest_v3 == '1' %}
+    "offscreen",
+    "scripting",
+{% endif %}
     "commandLinePrivate",
     "metricsPrivate",
     "settingsPrivate",
@@ -36,6 +40,12 @@
     "clipboardWrite",
     "contextMenus"
   ],
+{% if is_manifest_v3 == '1' %}
+  "host_permissions": [
+      "https://docs.google.com/*",
+      "https://docs.sandbox.google.com/*"
+   ],
+{% endif %}
   "icons": {
       "128": "select_to_speak/sts-icon-128.png",
       "16": "select_to_speak/sts-icon-16.png",
@@ -48,11 +58,18 @@
   "content_scripts": [
     {
       "matches": [ "https://docs.google.com/document*",
-                   "https://docs.sandbox.google.com/document*" ],
+                   "https://docs.sandbox.google.com/document*"],
       "all_frames": true,
+{% if is_manifest_v3 == '1' %}
+      "js": [
+        "select_to_speak/gdocs_script.js"
+      ],
+      "world": "MAIN",
+{% else %}
       "js": [
         "common/gdocs_script.js"
       ],
+{% endif %}
       "run_at": "document_start"
     }
   ]