├── .github └── workflows │ └── pull_request.yml ├── .gitignore ├── .nvmrc ├── .sbtopts ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── customize.sh ├── dev.webpack.config.js ├── production.webpack.config.js ├── project ├── AppManifest.scala ├── build.properties └── plugins.sbt ├── src ├── main │ ├── resources │ │ ├── _locales │ │ │ ├── en │ │ │ │ └── messages.json │ │ │ └── es │ │ │ │ └── messages.json │ │ ├── css │ │ │ ├── active-tab.css │ │ │ └── popup.css │ │ ├── icons │ │ │ ├── 48 │ │ │ │ └── app.png │ │ │ ├── 96 │ │ │ │ └── app.png │ │ │ └── 128 │ │ │ │ └── app.png │ │ ├── popup.html │ │ └── scripts │ │ │ ├── active-tab-script.js │ │ │ ├── active-tab-website-script.js │ │ │ ├── background-script.js │ │ │ ├── common.js │ │ │ └── popup-script.js │ └── scala │ │ ├── Main.scala │ │ └── com │ │ └── alexitc │ │ └── chromeapp │ │ ├── Config.scala │ │ ├── activetab │ │ ├── ActiveTabConfig.scala │ │ ├── ActiveTabPublicAPI.scala │ │ ├── CommandProcessor.scala │ │ ├── ExternalMessageProcessor.scala │ │ ├── Runner.scala │ │ ├── ScriptInjector.scala │ │ └── models │ │ │ ├── Command.scala │ │ │ ├── Event.scala │ │ │ └── TaggedModel.scala │ │ ├── background │ │ ├── BackgroundAPI.scala │ │ ├── CommandProcessor.scala │ │ ├── Runner.scala │ │ ├── alarms │ │ │ └── AlarmRunner.scala │ │ ├── models │ │ │ ├── Command.scala │ │ │ └── Event.scala │ │ └── services │ │ │ ├── browser │ │ │ └── BrowserNotificationService.scala │ │ │ └── storage │ │ │ └── StorageService.scala │ │ ├── common │ │ ├── I18NMessages.scala │ │ └── ResourceProvider.scala │ │ ├── facades │ │ ├── CommonsFacade.scala │ │ └── SweetAlert.scala │ │ ├── popup │ │ └── Runner.scala │ │ └── website │ │ ├── ObjectInjector.scala │ │ └── Runner.scala └── test │ └── scala │ └── ExampleSpec.scala ├── test.webpack.config.js └── yarn.lock /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Build the app 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v2 16 | 17 | - name: Setup Scala 18 | uses: japgolly/setup-everything-scala@v1.0 19 | 20 | - name: Check code format 21 | run: sbt scalafmtCheckAll 22 | 23 | - name: Compile 24 | run: sbt compile 25 | 26 | - name: Run tests 27 | run: sbt test 28 | 29 | - name : Package the app 30 | run: sbt chromePackage 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | project/target/ 3 | .idea/ 4 | 5 | .bsp/ 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.7.0 2 | -------------------------------------------------------------------------------- /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx4G 2 | -J-XX:MaxMetaspaceSize=4G 3 | -J-XX:+CMSClassUnloadingEnabled 4 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = 3.5.1 2 | project.git = true 3 | project.excludeFilters = [ 4 | ] 5 | 6 | runner.dialect=scala213 7 | 8 | maxColumn = 120 9 | assumeStandardLibraryStripMargin = false 10 | 11 | continuationIndent.callSite = 2 12 | continuationIndent.defnSite = 4 13 | 14 | align.preset = none 15 | 16 | onTestFailure = "To fix this, run 'sbt scalafmt' from the project directory, to avoid this issue, ensure you set up IntelliJ to format the code using scalafmt, see https://scalameta.org/scalafmt/docs/installation.html#intellij" 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexis Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The chrome-scalajs-template 2 | 3 | [](https://scala-steward.org) 4 | 5 | This is an opinionated template that can help you to get started fast while building browser extensions with scala-js. 6 | 7 | **NOTE** Be aware that this template targets the Manifest Version 2, track the Manifest Version 3 support in this [issue](https://github.com/AlexITC/chrome-scalajs-template/issues/28). 8 | 9 | ## Why 10 | While there are docs for building browser extensions, it isn't obvious how to do it with scala-js, after dealing with an extension for a while, I got a reasonable architecture that can be reused on other extensions and simplify its development, the goal from this template is to save you valuable time. 11 | 12 | The current template includes the examples for the following: 13 | - A tiny script that displays a notification every time you visit github.com 14 | - A tiny script displaying a nice alert on the page with [SweetAlert](https://www.npmjs.com/package/sweetalert). 15 | = The SweetAlert facade, so that you get an idea on how to write the JavaScript bindings. 16 | - A small button on the browser toolbar which renders pop-up and displays a notification. 17 | - An alarm which is a task executed frequently, currently, displaying a notification. 18 | - Support for two languages (English/Spanish). 19 | - An example for dealing with the storage. 20 | - Configuration classes. 21 | - A way for building the extension for the dev environment by default, which can be overridden by an environment variable to prepare the extension for release, in this case, it replaces the server from localhost to the one you choose. 22 | - Webpack integration (thanks to [scalajs-bundler](https://github.com/scalacenter/scalajs-bundler/)). 23 | 24 | NOTE: If you have any reason to not use webpack, checkout `b92c0f08690a8cd3e57e6dcf0c5d7694a5f20810` and follow the instructions from there. 25 | 26 | ## Get started 27 | 28 | **NOTE**: This template works only with scalajs 1.0.0, if you want to use a previous version, run `git checkout 402abfac9a4b9eba7f395009aa9b2243f3498273` and follow the docs from that version. 29 | 30 | It's pretty simple to get started, just follow these steps: 31 | - Clone the repo: `git clone https://github.com/AlexITC/chrome-scalajs-template.git` 32 | - Move to the cloned repo: `cd chrome-scalajs-template` 33 | - Add your brand: `./customize.sh com.alexitc.chrome com/alexitc/chrome` (replace the arguments with your desired base package, ignore the `sed` related warnings). 34 | - Edit the [build.sbt](build.sbt) to add the desired details for your app. 35 | - Edit the [AppManifest.scala](project/AppManifest.scala) to define your app manifest. 36 | - Edit the [app resources](src/main/resources) to the ones for your app. 37 | - Commit your changes and continue to the next section for building the app, also, start looking on the [Firefox guide](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) or the [Chrome guide](https://developer.chrome.com/docs/extensions/mv2/) for developing extensions. 38 | - Running `sbt chromePackage` on this project is enough to get your extension packaged. 39 | 40 | ## Development 41 | - Running `sbt "~chromeUnpackedFast"` will build the app each time it detects changes on the code, it also disables js optimizations which result in faster builds (placing the build at `target/chrome/unpacked-fast`). 42 | - Be sure to integrate scalafmt on your IntelliJ to format the source code on save (see https://scalameta.org/scalafmt/docs/installation.html#intellij). 43 | 44 | ## Release 45 | Run: `PROD=true sbt chromePackage` which generates: 46 | - A zip file that can be uploaded to the chrome store: `target/chrome/chrome-scalajs-template.zip` 47 | - A folder with all resources which can be packaged for firefox: `target/chrome/unpacked-opt/` 48 | 49 | ## Docs 50 | The project has 3 components, and each of them acts as a different application, there are interfaces for interacting between them. 51 | 52 | ### Active Tab 53 | The [activetab](/src/main/scala/com/alexitc/activetab) package has the script that is executed when the user visits a web page that the app is allowed to interact with (like github.com), everything running on the active tab has limited permissions, see the [official docs](https://developer.chrome.com/extensions/activeTab) for more details, be sure to read about the [content scripts](https://developer.chrome.com/extensions/content_scripts) too. 54 | 55 | ### Popup 56 | The [activetab](/src/main/scala/com/alexitc/popup) package has the script that is executed when the user visits clicks on the app icon which is displayed on the browser navigation bar. 57 | 58 | There is a limited functionality that the popup can do, see the [official docs](https://developer.chrome.com/extensions/browserAction) for more details. 59 | 60 | ### Background 61 | The [background](/src/main/scala/com/alexitc/background) package has the script that is executed when the browser starts, it keeps running to do anything the extension is supposed to do on the background, for example, interacts with the storage, web services, and alarms. 62 | 63 | As other components can't interact directly with the storage or web services, they use a high-level API ([BackgroundAPI](/src/main/scala/com/alexitc/background/BackgroundAPI.scala)) to request the background to do that. 64 | 65 | Be sure to review the [official docs](https://developer.chrome.com/extensions/background_pages). 66 | 67 | 68 | ## Hints 69 | While scalajs works great, there are some limitations, in particular, you must pay lots of attention while dealing with boundaries. 70 | 71 | A boundary is whatever interacts directly with JavaScript, like the browser APIs, for example: 72 | - When the background receives requests from other components, it gets a message that is serialized and deserialized by the browser, in order to support strongly typed models, the app encodes these models to a JSON string, the browser knows how to deal with strings but it doesn't know what to do with Scala objects. 73 | - When the storage needs to persist data, the app encodes the Scala objects to a JSON string, which is what the browser understands. 74 | - When there is a need to interact with JavaScript APIs (like external libraries), you'll need to write a [facade](/src/main/scala/com/alexitc/facades) unless there is one available, this facade will deal with primitive types only (strings, ints, etc). 75 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import com.alexitc.ChromeSbtPlugin 2 | 3 | lazy val appName = "chrome-scalajs-template" // TODO: REPLACE ME 4 | lazy val isProductionBuild = sys.env.getOrElse("PROD", "false") == "true" 5 | 6 | Global / onChangedBuildSource := ReloadOnSourceChanges 7 | 8 | val circe = "0.14.1" 9 | 10 | lazy val baseSettings: Project => Project = { 11 | _.enablePlugins(ScalaJSPlugin) 12 | .settings( 13 | name := appName, 14 | version := "1.0.0", 15 | scalaVersion := "2.13.8", 16 | scalacOptions ++= Seq( 17 | "-language:implicitConversions", 18 | "-language:existentials", 19 | "-Xlint", 20 | "-deprecation", // Emit warning and location for usages of deprecated APIs. 21 | "-encoding", 22 | "utf-8", // Specify character encoding used by source files. 23 | "-explaintypes", // Explain type errors in more detail. 24 | "-feature", // Emit warning and location for usages of features that should be imported explicitly. 25 | "-unchecked" // Enable additional warnings where generated code depends on assumptions. 26 | ), 27 | scalacOptions += "-Ymacro-annotations", 28 | Test / requireJsDomEnv := true 29 | ) 30 | } 31 | 32 | lazy val bundlerSettings: Project => Project = { 33 | _.enablePlugins(ScalaJSBundlerPlugin) 34 | .settings( 35 | useYarn := true, 36 | // NOTE: source maps are disabled to avoid a file not found error which occurs when using the current 37 | // webpack settings. 38 | scalaJSLinkerConfig := scalaJSLinkerConfig.value.withSourceMap(false), 39 | webpack / version := "4.8.1", 40 | // running `sbt test` fails if the webpack config is specified, it seems to happen because 41 | // the default webpack config from scalajs-bundler isn't written, making `sbt test` depend on 42 | // the chromeUnpackedFast task ensures that such config is generated, there might be a better 43 | // solution but this works for now. 44 | Test / test := (Test / test).dependsOn(chromeUnpackedFast).value, 45 | Test / webpackConfigFile := Some(baseDirectory.value / "test.webpack.config.js"), 46 | webpackConfigFile := { 47 | val file = if (isProductionBuild) "production.webpack.config.js" else "dev.webpack.config.js" 48 | Some(baseDirectory.value / file) 49 | }, 50 | // scala-js-chrome 51 | scalaJSLinkerConfig := scalaJSLinkerConfig.value.withRelativizeSourceMapBase( 52 | Some((Compile / fastOptJS / artifactPath).value.toURI) 53 | ), 54 | packageJSDependencies / skip := false, 55 | webpackBundlingMode := BundlingMode.Application, 56 | fastOptJsLib := (Compile / fastOptJS / webpack).value.head, 57 | fullOptJsLib := (Compile / fullOptJS / webpack).value.head, 58 | webpackBundlingMode := BundlingMode.LibraryAndApplication(), 59 | // you can customize and have a static output name for lib and dependencies 60 | // instead of having the default files names like extension-fastopt.js, ... 61 | Compile / fastOptJS / artifactPath := { 62 | (Compile / fastOptJS / crossTarget).value / "main.js" 63 | }, 64 | Compile / fullOptJS / artifactPath := { 65 | (Compile / fullOptJS / crossTarget).value / "main.js" 66 | } 67 | ) 68 | } 69 | 70 | lazy val buildInfoSettings: Project => Project = { 71 | _.enablePlugins(BuildInfoPlugin) 72 | .settings( 73 | buildInfoPackage := "com.alexitc", 74 | buildInfoKeys := Seq[BuildInfoKey](name), 75 | buildInfoKeys ++= Seq[BuildInfoKey]( 76 | "production" -> isProductionBuild, 77 | 78 | // it's simpler to propagate the required js scripts from this file to avoid hardcoding 79 | // them on the code that actually injects them. 80 | "activeTabWebsiteScripts" -> AppManifest.manifestActiveTabWebsiteScripts 81 | ), 82 | buildInfoUsePackageAsPath := true 83 | ) 84 | } 85 | 86 | lazy val root = (project in file(".")) 87 | .enablePlugins(ChromeSbtPlugin, ScalablyTypedConverterPlugin) 88 | .configure(baseSettings, bundlerSettings, buildInfoSettings) 89 | .settings( 90 | chromeManifest := AppManifest.generate(appName, Keys.version.value), 91 | // js dependencies, adding typescript type definitions gets them a Scala facade 92 | Compile / npmDependencies ++= Seq( 93 | "sweetalert" -> "2.1.2" 94 | ), 95 | libraryDependencies ++= Seq( 96 | "org.scala-js" %%% "scalajs-dom" % "2.1.0", 97 | "com.alexitc" %%% "scala-js-chrome" % "0.8.1", 98 | "org.scala-js" %%% "scala-js-macrotask-executor" % "1.0.0", 99 | "org.scala-js" %%% "scalajs-java-securerandom" % "1.0.0", 100 | "io.circe" %%% "circe-core" % circe, 101 | "io.circe" %%% "circe-generic" % circe, 102 | "io.circe" %%% "circe-parser" % circe, 103 | "org.scalatest" %%% "scalatest" % "3.2.16" % "test" 104 | ) 105 | ) 106 | -------------------------------------------------------------------------------- /customize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | PACKAGE=$1 4 | PACKAGE_DIRECTORY=$2 5 | DEFAULT_PACKAGE="com.alexitc.chromeapp" 6 | DEFAULT_PACKAGE_DIRECTORY="com/alexitc/chromeapp" 7 | 8 | # move files to the new package directory 9 | mkdir -p src/main/scala/$PACKAGE_DIRECTORY 10 | mv src/main/scala/$DEFAULT_PACKAGE_DIRECTORY/* src/main/scala/$PACKAGE_DIRECTORY/ 11 | 12 | # rename packages on files 13 | find src -name '*' -exec sed -i -e "s/$DEFAULT_PACKAGE/$PACKAGE/g" {} \; 14 | 15 | # update packages on build.sbt 16 | find . -name 'build.sbt' -exec sed -i -e "s/$DEFAULT_PACKAGE/$PACKAGE/g" {} \; 17 | -------------------------------------------------------------------------------- /dev.webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = require('./scalajs.webpack.config'); 4 | 5 | // NOTE: development is useful for debugging but apparently it breaks the build for Chrome 6 | // with the current settings. 7 | module.exports.mode = "production"; 8 | 9 | // by default, scalajs-bundler sets this to "var" but that breaks the build for Firefox. 10 | module.exports.output.libraryTarget = "window"; 11 | -------------------------------------------------------------------------------- /production.webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = require('./dev.webpack.config'); 4 | module.exports.mode = "production"; 5 | -------------------------------------------------------------------------------- /project/AppManifest.scala: -------------------------------------------------------------------------------- 1 | import chrome.permissions.Permission 2 | import chrome.permissions.Permission.API 3 | import chrome.{Background, BrowserAction, ContentScript, ExtensionManifest} 4 | import com.alexitc.Chrome 5 | 6 | object AppManifest { 7 | // scripts used on all modules 8 | val commonScripts = List("scripts/common.js", "main-bundle.js") 9 | 10 | // The script that runs on the current tab context needs the common scripts to execute scalajs code. 11 | val manifestActiveTabWebsiteScripts = commonScripts :+ "scripts/active-tab-website-script.js" 12 | 13 | def generate(appName: String, appVersion: String): ExtensionManifest = { 14 | new ExtensionManifest { 15 | override val name = appName 16 | override val version = appVersion 17 | 18 | override val description = Some( 19 | "TO BE UPDATED" // TODO: REPLACE ME 20 | ) 21 | override val icons = Chrome.icons("icons", "app.png", Set(48, 96, 128)) 22 | 23 | // TODO: REPLACE ME, use only the minimum required permissions 24 | override val permissions = Set[Permission]( 25 | API.Storage, 26 | API.Notifications, 27 | API.Alarms 28 | ) 29 | 30 | override val defaultLocale: Option[String] = Some("en") 31 | 32 | // TODO: REPLACE ME 33 | override val browserAction: Option[BrowserAction] = 34 | Some(BrowserAction(icons, Some("TO BE DEFINED - POPUP TITLE"), Some("popup.html"))) 35 | 36 | override val background = Background( 37 | scripts = commonScripts ::: List("scripts/background-script.js") 38 | ) 39 | 40 | override val contentScripts: List[ContentScript] = List( 41 | ContentScript( 42 | matches = List( 43 | "https://github.com/*" // TODO: REPLACE ME 44 | ), 45 | css = List("css/active-tab.css"), 46 | js = commonScripts ::: List("scripts/active-tab-script.js") 47 | ) 48 | ) 49 | 50 | // the script running on the tab context requires the common scripts 51 | override val webAccessibleResources = "icons/*" :: manifestActiveTabWebsiteScripts 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.6.2 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta37") 2 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.10.0") 3 | addSbtPlugin("com.alexitc" % "sbt-chrome-plugin" % "0.8.1") 4 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") 5 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.20.0") 6 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") 7 | -------------------------------------------------------------------------------- /src/main/resources/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "TO BE DEFINED" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/main/resources/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "POR DEFINIR" 4 | } 5 | } -------------------------------------------------------------------------------- /src/main/resources/css/active-tab.css: -------------------------------------------------------------------------------- 1 | .table-width { 2 | width: 800px; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/resources/css/popup.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexITC/chrome-scalajs-template/9afab6dc4e2a73b948e20e6abb28c519b1089afe/src/main/resources/css/popup.css -------------------------------------------------------------------------------- /src/main/resources/icons/128/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexITC/chrome-scalajs-template/9afab6dc4e2a73b948e20e6abb28c519b1089afe/src/main/resources/icons/128/app.png -------------------------------------------------------------------------------- /src/main/resources/icons/48/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexITC/chrome-scalajs-template/9afab6dc4e2a73b948e20e6abb28c519b1089afe/src/main/resources/icons/48/app.png -------------------------------------------------------------------------------- /src/main/resources/icons/96/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlexITC/chrome-scalajs-template/9afab6dc4e2a73b948e20e6abb28c519b1089afe/src/main/resources/icons/96/app.png -------------------------------------------------------------------------------- /src/main/resources/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/main/resources/scripts/active-tab-script.js: -------------------------------------------------------------------------------- 1 | runOnTab(); 2 | -------------------------------------------------------------------------------- /src/main/resources/scripts/active-tab-website-script.js: -------------------------------------------------------------------------------- 1 | // This script should run on the website context instead of the extension context where content scripts are executed. 2 | runOnCurrentWebsite(); 3 | -------------------------------------------------------------------------------- /src/main/resources/scripts/background-script.js: -------------------------------------------------------------------------------- 1 | runOnBackground(); 2 | -------------------------------------------------------------------------------- /src/main/resources/scripts/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Expose functions that are difficult to represent in Scala. 3 | * 4 | * The idea is to create a facade that simplifies them to our needs. 5 | */ 6 | var facade = { 7 | notify: (title, message, iconUrl) => { 8 | chrome.notifications.create("", { title: title, message: message, type: "basic", iconUrl: iconUrl }) 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/main/resources/scripts/popup-script.js: -------------------------------------------------------------------------------- 1 | runOnPopup(); 2 | -------------------------------------------------------------------------------- /src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | import com.alexitc.chromeapp._ 2 | import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ 3 | 4 | import scala.scalajs.js.annotation.JSExportTopLevel 5 | 6 | /** Entry-point for any context, it loads the config based on the current environment, which can be production or 7 | * development for now. 8 | * 9 | * It creates the necessary objects and execute the runner for the actual context. 10 | * 11 | * It is assumed that the entry-point is an actual JavaScript file that invokes these functions. 12 | */ 13 | object Main { 14 | 15 | private val config = if (com.alexitc.BuildInfo.production) { 16 | Config.Default 17 | } else { 18 | Config.Dev 19 | } 20 | 21 | def main(args: Array[String]): Unit = { 22 | // the main shouldn't do anything to avoid conflicts between contexts (tab, popup, background) 23 | } 24 | 25 | @JSExportTopLevel("runOnTab") 26 | def runOnTab(): Unit = { 27 | activetab.Runner(config).run() 28 | } 29 | 30 | @JSExportTopLevel("runOnBackground") 31 | def runOnBackground(): Unit = { 32 | background.Runner(config).run() 33 | } 34 | 35 | @JSExportTopLevel("runOnPopup") 36 | def runOnPopup(): Unit = { 37 | popup.Runner().run() 38 | } 39 | 40 | @JSExportTopLevel("runOnCurrentWebsite") 41 | def runOnCurrentTabContext(): Unit = { 42 | website.Runner().run() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/Config.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp 2 | 3 | import com.alexitc.chromeapp.activetab.ActiveTabConfig 4 | import com.alexitc.chromeapp.background.alarms.AlarmRunner 5 | 6 | /** This is the global config, which includes any configurable details. 7 | * 8 | * For convenience, there are two configs, the Default one and the one for Development. 9 | */ 10 | case class Config( 11 | alarmRunnerConfig: AlarmRunner.Config, 12 | activeTabConfig: activetab.ActiveTabConfig 13 | ) 14 | 15 | // TODO: REPLACE ME 16 | object Config { 17 | 18 | val Default: Config = { 19 | Config( 20 | AlarmRunner.Config(periodInMinutes = 60 * 3), 21 | ActiveTabConfig( 22 | com.alexitc.BuildInfo.activeTabWebsiteScripts.toList 23 | ) 24 | ) 25 | } 26 | 27 | val Dev: Config = Default.copy(alarmRunnerConfig = AlarmRunner.Config(periodInMinutes = 2)) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/ActiveTabConfig.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | case class ActiveTabConfig(websiteScripts: List[String]) 4 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/ActiveTabPublicAPI.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | import com.alexitc.chromeapp.activetab.models.{Command, Event, TaggedModel} 4 | import io.circe.generic.auto._ 5 | import io.circe.syntax._ 6 | import org.scalajs.dom 7 | 8 | import java.util.UUID 9 | import scala.concurrent.{ExecutionContext, Future, Promise} 10 | 11 | /** While Chrome provides a way to communicate directly from web sites to the extension, it doesn't fix the potential 12 | * security problems, still any other extension available to the web site is a potential a man-in-the-middle, and this 13 | * Chrome approach limit us as we would need to specify the whitelisted domains that can interact with out extension. 14 | * 15 | * While native js messages have the same problems, they don't have the previous limitation as any web site will be 16 | * able to communicate with our extension, the drawback is that the website needs to communicate with the 17 | * content-script which communicates with the background script instead of the direct communication supported by the 18 | * Chrome approach. 19 | * 20 | * As of now, the conclusion is that the Chrome approach drawbacks are worse than requiring a bit more code and latency 21 | * to support any website. 22 | * 23 | * NOTE: This API is intended to run on any other context different to the isolated content-script so that other 24 | * contexts can communicate with the content-script in a simple way. 25 | * 26 | * @see 27 | * https://developer.chrome.com/extensions/messaging#external-webpage 28 | */ 29 | class ActiveTabPublicAPI(implicit ec: ExecutionContext) { 30 | 31 | def getInfo(): Future[Event.GotInfo] = { 32 | val cmd = Command.GetInfo 33 | processCommand(cmd).collect { 34 | case r: Event.GotInfo => r 35 | case x => throw new RuntimeException(s"Unknown response: $x") 36 | } 37 | } 38 | 39 | /** Process a command, wait for an event. 40 | * 41 | * @param cmd 42 | * the command to process. 43 | * @return 44 | * The produced event. 45 | */ 46 | private def processCommand(cmd: Command): Future[Event] = { 47 | val tagged = TaggedModel(UUID.randomUUID(), cmd) 48 | val msg = tagged.asJson.noSpaces 49 | // subscribe for the result before sending the command to avoid latency-related race-conditions 50 | val result = listenFor(tagged.tag) 51 | 52 | // This ensures that the message gets only to the current website 53 | // preventing other windows to grab it, still, it doesn't allow other extensions 54 | // accessing the current website to grab it. 55 | // 56 | // TODO: A safer way could be to send the message to the frame where our content-script runs 57 | dom.window.postMessage(msg, dom.window.location.origin.orNull) 58 | 59 | result 60 | } 61 | 62 | /** Listen to the message stream waiting for a specific message matching the given tag. 63 | * 64 | * TODO: Instead of subscribing on each request it may be more efficient to keep a single stream for all messages. 65 | * 66 | * @param tag 67 | * the tag to look for. 68 | * @return 69 | * the actual message matching the model and the tag. 70 | */ 71 | private def listenFor(tag: UUID): Future[Event] = { 72 | val promise = Promise[Event]() 73 | val listener = (event: dom.MessageEvent) => { 74 | // To reduce security risks, let's accept messages only from the website, 75 | // which is where our content-script runs 76 | dom.window.location.origin 77 | .filter(_ == event.origin) 78 | .foreach { _ => 79 | TaggedModel 80 | .decode[Event](event.data.toString) 81 | .filter(_.tag == tag) 82 | .foreach { result => 83 | // As the listener is de-registered asynchronously, it will catch more events until that's done. 84 | if (!promise.isCompleted) { 85 | promise.success(result.model) 86 | } 87 | } 88 | } 89 | } 90 | 91 | dom.window.addEventListener( 92 | "message", // NOTE: This message type is required to get the message to the extension 93 | listener, 94 | useCapture = true 95 | ) 96 | 97 | // deregister the listener to avoid receiving more messages that will get ignored 98 | promise.future.foreach { _ => 99 | dom.window.removeEventListener("message", listener, useCapture = true) 100 | } 101 | 102 | promise.future 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/CommandProcessor.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | import com.alexitc.chromeapp.activetab.models.{Command, Event} 4 | 5 | import scala.concurrent.{ExecutionContext, Future} 6 | 7 | private[activetab] class CommandProcessor(implicit ec: ExecutionContext) { 8 | 9 | def process(cmd: Command): Future[Event] = { 10 | cmd match { 11 | case Command.GetInfo => 12 | val msg = s"Extension id = ${chrome.runtime.Runtime.id}" 13 | val response = Event.GotInfo(msg) 14 | Future.successful(response) 15 | } 16 | }.recover { case e => 17 | Event.CommandRejected(e.getMessage) // Any exceptions will be resolved to CommandRejected 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/ExternalMessageProcessor.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | import com.alexitc.chromeapp.activetab.models.{Command, Event, TaggedModel} 4 | import io.circe.Encoder 5 | import io.circe.generic.auto._ 6 | import io.circe.syntax._ 7 | import org.scalajs.dom 8 | 9 | import java.util.UUID 10 | import scala.concurrent.ExecutionContext 11 | import scala.util.{Failure, Success, Try} 12 | 13 | private[activetab] class ExternalMessageProcessor(commandProcessor: CommandProcessor)(implicit ec: ExecutionContext) { 14 | 15 | def start(): Unit = { 16 | log("listening for external messages") 17 | dom.window.addEventListener( 18 | "message", // NOTE: This message type is required to get the message to the extension 19 | eventHandler, 20 | useCapture = true 21 | ) 22 | } 23 | 24 | private val eventHandler = (event: dom.MessageEvent) => { 25 | // We need to make sure that the event comes from the same website where this 26 | // content-script is running. 27 | // 28 | // Otherwise, other websites/extensions would make our background context believe 29 | // that the request is from this website content-script while it's not, this 30 | // content-script is just a proxy to get to the background. 31 | dom.window.location.origin 32 | .filter(_ == event.origin) 33 | .foreach { _ => 34 | TaggedModel.decode[Command](event.data.toString) match { 35 | case Failure(_) => 36 | // There is nothing to do when we get an unknown message 37 | () 38 | 39 | case Success(value) => 40 | commandProcessor 41 | .process(value.model) 42 | .onComplete(reply(event.origin, value.tag, _)) 43 | } 44 | } 45 | 46 | } 47 | 48 | private def reply[T: Encoder](origin: String, tag: UUID, result: Try[T]): Unit = { 49 | val model = result match { 50 | case Failure(exception) => Event.CommandRejected(exception.getMessage).asJson 51 | case Success(value) => value.asJson 52 | } 53 | val msg = TaggedModel(tag, model) 54 | 55 | // Ensures only the origin can see the response 56 | dom.window.postMessage(msg.asJson.noSpaces, origin) 57 | } 58 | 59 | private def log(msg: String): Unit = { 60 | println(s"ExternalMessageProcessor: $msg") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/Runner.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | import com.alexitc.chromeapp.Config 4 | import com.alexitc.chromeapp.background.BackgroundAPI 5 | import com.alexitc.chromeapp.common.I18NMessages 6 | import com.alexitc.chromeapp.facades.SweetAlert 7 | import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ 8 | 9 | import scala.concurrent.Future 10 | import scala.scalajs.js.JSConverters._ 11 | 12 | class Runner( 13 | config: ActiveTabConfig, 14 | backgroundAPI: BackgroundAPI, 15 | messages: I18NMessages, 16 | scriptInjector: ScriptInjector, 17 | externalMessageProcessor: ExternalMessageProcessor 18 | ) { 19 | 20 | def run(): Unit = { 21 | log("This was run by the active tab") 22 | externalMessageProcessor.start() 23 | injectPrivilegedScripts(config.websiteScripts) 24 | .foreach { _ => 25 | log("Scripts injected, the website context should start soon") 26 | } 27 | 28 | SweetAlert(new SweetAlert.Options { 29 | title = messages.appName 30 | text = "Do you like this template?" 31 | icon = chrome.runtime.Runtime.getURL("icons/96/app.png") 32 | buttons = Option(List("No", "Yes").toJSArray).orUndefined 33 | }).toFuture.onComplete { t => 34 | log(s"SweetAlert result: $t") 35 | } 36 | 37 | backgroundAPI.sendBrowserNotification(messages.appName, "I'm on the tab!!") 38 | } 39 | 40 | private def injectPrivilegedScripts(scripts: Seq[String]): Future[Unit] = { 41 | // it's important to load the scripts in the right order 42 | scripts.foldLeft(Future.unit) { case (acc, cur) => 43 | acc.flatMap(_ => scriptInjector.injectPrivilegedScript(cur)) 44 | } 45 | } 46 | 47 | private def log(msg: String): Unit = { 48 | println(s"activeTab: $msg") 49 | } 50 | } 51 | 52 | object Runner { 53 | 54 | def apply(config: Config): Runner = { 55 | val backgroundAPI = new BackgroundAPI 56 | val messages = new I18NMessages 57 | val scriptInjector = new ScriptInjector 58 | val commandProcessor = new CommandProcessor 59 | val externalMessageProcessor = new ExternalMessageProcessor(commandProcessor) 60 | new Runner(config.activeTabConfig, backgroundAPI, messages, scriptInjector, externalMessageProcessor) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/ScriptInjector.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab 2 | 3 | import org.scalajs.dom 4 | 5 | import scala.concurrent.{Future, Promise} 6 | 7 | private[activetab] class ScriptInjector { 8 | 9 | /** Content scripts run in an isolated world, see https://developer.chrome.com/extensions/content_scripts 10 | * 11 | * The way to communicate the content script and the web page is by sending messages, which gets ugly very easily, 12 | * hence, we provide a small API to abstract such communication. 13 | * 14 | * When a web site is willing to interact with our extension, it can check whether our API available, then the 15 | * JavaScript functions could be invoked directly. 16 | * 17 | * We would like to inject the JavaScript API that could be invoked directly from the web sites. 18 | * 19 | * NOTE: Metamask has done a great job on making a robust script which is worth to integrate someday.: 20 | * - https://github.com/MetaMask/metamask-extension/blob/develop/app/scripts/contentscript.js 21 | */ 22 | def injectPrivilegedScript(scriptFile: String): Future[Unit] = { 23 | val promise = Promise[Unit]() 24 | val script = dom.document.createElement("script").asInstanceOf[org.scalajs.dom.HTMLScriptElement] 25 | // NOTE: This script must be included on the web_accessible_resources in manifest.json 26 | script.src = chrome.runtime.Runtime.getURL(scriptFile) 27 | script.onload = _ => { 28 | script.parentNode.removeChild(script) 29 | promise.success(()) 30 | } 31 | 32 | Option(dom.document.head) 33 | .getOrElse(dom.document.documentElement) 34 | .appendChild(script) 35 | promise.future 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/models/Command.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab.models 2 | 3 | import io.circe.generic.auto._ 4 | import io.circe.parser.parse 5 | 6 | import scala.util.Try 7 | 8 | /** This is the internal protocol to allow communicating different contexts to the extension active tba context. 9 | * 10 | * This is the request side. 11 | */ 12 | private[activetab] sealed trait Command extends Product with Serializable 13 | 14 | private[activetab] object Command { 15 | 16 | final case object GetInfo extends Command 17 | 18 | def decode(string: String): Try[Command] = { 19 | parse(string).toTry 20 | .flatMap(_.as[Command].toTry) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/models/Event.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab.models 2 | 3 | import io.circe.generic.auto._ 4 | import io.circe.parser.parse 5 | 6 | import scala.util.Try 7 | 8 | /** This is the internal protocol to allow communicating different contexts to the extension active tba context. 9 | * 10 | * This is the response side. 11 | */ 12 | private[activetab] sealed trait Event extends Product with Serializable 13 | 14 | private[activetab] object Event { 15 | 16 | final case class GotInfo(details: String) extends Event 17 | // TODO: There is a likely a better way to do this 18 | final case class CommandRejected(reason: String) extends Event 19 | 20 | def decode(string: String): Try[Event] = { 21 | parse(string).toTry 22 | .flatMap(_.as[Event].toTry) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/activetab/models/TaggedModel.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.activetab.models 2 | 3 | import io.circe.Decoder 4 | import io.circe.generic.auto._ 5 | import io.circe.parser.parse 6 | 7 | import java.util.UUID 8 | import scala.util.Try 9 | 10 | /** In our protocol (Command/Event), the messages are sent through unidirectional channels, hence, we tag the messages 11 | * to allow matching a command with its produced event. 12 | * 13 | * @param tag 14 | * a unique tag 15 | * @param model 16 | * the model 17 | */ 18 | case class TaggedModel[T: Decoder](tag: UUID, model: T) 19 | 20 | object TaggedModel { 21 | def decode[T: Decoder](string: String): Try[TaggedModel[T]] = { 22 | parse(string).toTry 23 | .flatMap(_.as[TaggedModel[T]].toTry) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/BackgroundAPI.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background 2 | 3 | import com.alexitc.chromeapp.background.models.{Command, Event} 4 | import io.circe.generic.auto._ 5 | import io.circe.syntax._ 6 | 7 | import scala.concurrent.{Future, Promise} 8 | import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ 9 | import scala.scalajs.js 10 | import scala.util.{Failure, Success, Try} 11 | 12 | /** There are some APIs that can be accessed only from the background runner, like http/storage/notifications/etc. 13 | * 14 | * A way to call these ones from other contexts is to send a message to the background. 15 | * 16 | * A request/response mechanism can be simulated by using promises/futures. 17 | * 18 | * Any operation is encoded as JSON and parsed on the background context, which runs the actual operation and returns 19 | * the result as another message. 20 | * 21 | * The BackgroundAPI abstracts all that complex logic from the caller and gives a simple API based on futures. 22 | */ 23 | class BackgroundAPI { 24 | 25 | import BackgroundAPI._ 26 | 27 | def sendBrowserNotification(title: String, message: String): Future[Unit] = { 28 | val command: Command = Command.SendBrowserNotification(title, message) 29 | process(command).collect { case _: Event.BrowserNotificationSent => 30 | () 31 | } 32 | } 33 | 34 | /** Processes a command sending a message to the background context, when the background isn't ready, the command is 35 | * retried up to 3 times, delaying 1 second each time, this retry strategy should be enough for most cases. 36 | */ 37 | private def process(command: Command): Future[Event] = { 38 | val timeoutMs = 1000 39 | def processWithRetries(retriesLeft: Int, lastError: String): Future[Event] = { 40 | if (retriesLeft <= 0) { 41 | Future.successful(Event.CommandRejected(lastError)) 42 | } else { 43 | val promise = Promise[Event]() 44 | val _ = org.scalajs.dom.window.setTimeout(() => promise.completeWith(processInternal(command)), timeoutMs) 45 | 46 | promise.future 47 | .recoverWith { case TransientError(e) => 48 | log(s"Trying to recover from transient error, retry = $retriesLeft, command = $command, error = $e") 49 | processWithRetries(retriesLeft - 1, e) 50 | } 51 | } 52 | } 53 | 54 | processInternal(command).recoverWith { case TransientError(e) => 55 | log(s"Trying to recover from transient error, command = $command, error = $e") 56 | processWithRetries(3, e) 57 | } 58 | } 59 | 60 | private def processInternal(command: Command): Future[Event] = { 61 | val promise = Promise[Event]() 62 | val callback: js.Function1[js.Object, Unit] = (x: js.Object) => { 63 | // On exceptional cases, the receiver isn't ready, leading to undefined object on the callback 64 | // One way this happens is when the extension browser-action is opened in a tab when the browser 65 | // starts, it tried to contact the background which isn't ready. 66 | // 67 | // On such cases, the lastError is supposed to include the failure reason but a user claims that 68 | // sometimes lastError is empty but the message is still undefined. 69 | // 70 | // We are handling both cases as a TransientError. 71 | // 72 | // This seems to occur only in Firefox, See: 73 | // - https://bugzilla.mozilla.org/show_bug.cgi?id=1435597 74 | // - https://discourse.mozilla.org/t/reply-to-chrome-runtime-sendmessage-is-undefined/25021/3 75 | chrome.runtime.Runtime.lastError 76 | .flatMap(Option.apply) // Apparently, chrome.runtime.Runtime.lastError could be Option(null) 77 | .flatMap(_.message.toOption) 78 | .orElse( 79 | if (scalajs.js.isUndefined(x)) Some("Got undefined message, receiver likely not ready") 80 | else None 81 | ) 82 | .map { errorReason => 83 | promise.failure(TransientError(errorReason)) 84 | } 85 | .getOrElse { 86 | Try(x.asInstanceOf[String]).flatMap(Event.decode) match { 87 | case Success(Event.CommandRejected(reason)) => 88 | sendBrowserNotification("ERROR", reason) // TODO: Remove hack 89 | promise.failure(new RuntimeException(reason)) 90 | 91 | case Success(e: Event) => 92 | promise.success(e) 93 | 94 | // Unable to parse the incoming message, it's likely the message wasn't sent by our app, no need to process it. 95 | case Failure(exception) => promise.failure(exception) 96 | } 97 | } 98 | } 99 | 100 | val message = command.asJson.noSpaces 101 | chrome.runtime.Runtime 102 | .sendMessage(message = message, responseCallback = callback) 103 | 104 | promise.future 105 | } 106 | 107 | private def log(msg: String): Unit = { 108 | println(s"BackgroundAPI: $msg") 109 | } 110 | } 111 | 112 | object BackgroundAPI { 113 | case class TransientError(message: String) extends RuntimeException 114 | } 115 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/CommandProcessor.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background 2 | 3 | import com.alexitc.chromeapp.background.models.{Command, Event} 4 | import com.alexitc.chromeapp.background.services.browser.BrowserNotificationService 5 | import com.alexitc.chromeapp.background.services.storage.StorageService 6 | 7 | import scala.concurrent.Future 8 | 9 | /** Any command supported by the BackgroundAPI is handled here. 10 | */ 11 | private[background] class CommandProcessor( 12 | productStorage: StorageService, 13 | browserNotificationService: BrowserNotificationService 14 | ) { 15 | 16 | def process(command: Command): Future[Event] = command match { 17 | case Command.SendBrowserNotification(title, message) => 18 | browserNotificationService.notify(title, message) 19 | Future.successful(Event.BrowserNotificationSent()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/Runner.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background 2 | 3 | import com.alexitc.chromeapp.Config 4 | import com.alexitc.chromeapp.background.alarms.AlarmRunner 5 | import com.alexitc.chromeapp.background.models.{Command, Event} 6 | import com.alexitc.chromeapp.background.services.browser.BrowserNotificationService 7 | import com.alexitc.chromeapp.background.services.storage.StorageService 8 | import com.alexitc.chromeapp.common.I18NMessages 9 | import io.circe.generic.auto._ 10 | import io.circe.syntax._ 11 | 12 | import scala.concurrent.Future 13 | import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ 14 | import scala.util.Try 15 | import scala.util.control.NonFatal 16 | 17 | class Runner( 18 | commandProcessor: CommandProcessor, 19 | alarmRunner: AlarmRunner 20 | ) { 21 | 22 | def run(): Unit = { 23 | log("This was run by the background script") 24 | alarmRunner.register() 25 | processExternalMessages() 26 | } 27 | 28 | /** Enables the future-based communication between contexts to the background contexts. 29 | * 30 | * Internally, this is done by string-based messages, which we encode as JSON. 31 | */ 32 | private def processExternalMessages(): Unit = { 33 | chrome.runtime.Runtime.onMessage.listen { message => 34 | message.value.foreach { any => 35 | val response = Future 36 | .fromTry { Try(any.asInstanceOf[String]).flatMap(Command.decode) } 37 | .map { cmd => 38 | log(s"Got command = $cmd") 39 | cmd 40 | } 41 | .flatMap(commandProcessor.process) 42 | .recover { case NonFatal(ex) => 43 | log(s"Failed to process command, error = ${ex.getMessage}") 44 | Event.CommandRejected(ex.getMessage) 45 | } 46 | .map(_.asJson.noSpaces) 47 | 48 | /** NOTE: When replying on futures, the method returning an async response is the only reliable one otherwise, 49 | * the sender is getting no response, a way to use the async method is to pass a response in case of failures 50 | * even if that case was already handled with the CommandRejected event. 51 | */ 52 | message.response(response, "Impossible failure") 53 | } 54 | } 55 | } 56 | 57 | private def log(msg: String): Unit = { 58 | println(s"background: $msg") 59 | } 60 | } 61 | 62 | object Runner { 63 | 64 | def apply(config: Config): Runner = { 65 | val storage = new StorageService 66 | val messages = new I18NMessages 67 | val browserNotificationService = new BrowserNotificationService(messages) 68 | val commandProcessor = 69 | new CommandProcessor(storage, browserNotificationService) 70 | 71 | val productUpdaterAlarm = new AlarmRunner( 72 | config.alarmRunnerConfig, 73 | messages, 74 | browserNotificationService 75 | ) 76 | new Runner(commandProcessor, productUpdaterAlarm) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/alarms/AlarmRunner.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background.alarms 2 | 3 | import chrome.alarms.bindings.AlarmInfo 4 | import com.alexitc.chromeapp.background.services.browser.BrowserNotificationService 5 | import com.alexitc.chromeapp.common.I18NMessages 6 | 7 | /** Example code to register and run a configurable alarm. 8 | */ 9 | private[background] class AlarmRunner( 10 | config: AlarmRunner.Config, 11 | messages: I18NMessages, 12 | notificationService: BrowserNotificationService 13 | ) { 14 | 15 | def register(): Unit = { 16 | val alarmName = "TO_BE_DEFINED" 17 | chrome.alarms.Alarms.create(alarmName, AlarmInfo(delayInMinutes = 1.0, periodInMinutes = config.periodInMinutes)) 18 | chrome.alarms.Alarms.onAlarm.filter(_.name == alarmName).listen { alarm => 19 | log(s"Got alarm: ${alarm.name}") 20 | run() 21 | } 22 | } 23 | 24 | private def run(): Unit = { 25 | // As this runs on the background, it can use its API directly. 26 | notificationService.notify("Alarm time!") 27 | } 28 | 29 | private def log(msg: String): Unit = { 30 | println(s"alarmRunner: $msg") 31 | } 32 | } 33 | 34 | object AlarmRunner { 35 | case class Config(periodInMinutes: Double) 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/models/Command.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background.models 2 | 3 | import io.circe.generic.auto._ 4 | import io.circe.parser.parse 5 | 6 | import scala.util.Try 7 | 8 | /** Internal typed-message to request the background context to perform an operation. 9 | */ 10 | private[background] sealed trait Command extends Product with Serializable 11 | 12 | private[background] object Command { 13 | 14 | final case class SendBrowserNotification(title: String, message: String) extends Command 15 | 16 | def decode(string: String): Try[Command] = { 17 | parse(string).toTry 18 | .flatMap(_.as[Command].toTry) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/models/Event.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background.models 2 | 3 | import io.circe.generic.auto._ 4 | import io.circe.parser.parse 5 | 6 | import scala.util.Try 7 | 8 | /** Internal typed-message used by the background context to reply to an operation 9 | */ 10 | private[background] sealed trait Event extends Product with Serializable 11 | 12 | private[background] object Event { 13 | 14 | final case class BrowserNotificationSent() extends Event 15 | // TODO: Find a better way, possible returning something like Either[CommandRejected, Event] on the BackgroundAPI 16 | final case class CommandRejected(reason: String) extends Event 17 | 18 | def decode(string: String): Try[Event] = { 19 | parse(string).toTry 20 | .flatMap(_.as[Event].toTry) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/services/browser/BrowserNotificationService.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background.services.browser 2 | 3 | import com.alexitc.chromeapp.common.{I18NMessages, ResourceProvider} 4 | import com.alexitc.chromeapp.facades.CommonsFacade 5 | 6 | /** Internal service available to the background context, which allows sending notifications to the browser. 7 | */ 8 | private[background] class BrowserNotificationService(messages: I18NMessages) { 9 | 10 | def notify(message: String): Unit = { 11 | notify(messages.appName, message) 12 | } 13 | 14 | def notify(title: String, message: String): Unit = { 15 | 16 | /** Sadly, scala-js-chrome fails when creating notifications on firefox, to overcome that issue, we expect a simple 17 | * JavaScript function that creates notifications which works on Firefox and Chrome, the facade just invoke that 18 | * function (see common.js) 19 | * 20 | * If you don't need to support firefox, replacing this with chrome.notifications.Notifications.create() is 21 | * simpler. 22 | */ 23 | CommonsFacade.notify( 24 | title, 25 | message, 26 | ResourceProvider.appIcon96 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/background/services/storage/StorageService.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.background.services.storage 2 | 3 | import io.circe.Json 4 | import io.circe.parser.parse 5 | 6 | import scala.concurrent.Future 7 | import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits._ 8 | import scala.scalajs.js 9 | 10 | /** Internal service available to the background context, which allows dealing with the storage local. 11 | */ 12 | private[background] class StorageService { 13 | import StorageService._ 14 | // import js.JSConverters._ 15 | 16 | def save(installedOn: Long): Future[Unit] = { 17 | val json = s"""{"lastUsedOn": $installedOn}""" 18 | val dict = js.Dictionary(StorageKey -> js.Any.fromString(json)) 19 | 20 | chrome.storage.Storage.local.set(dict) 21 | } 22 | 23 | def load(): Future[Option[Long]] = { 24 | chrome.storage.Storage.local 25 | // .get(key) 26 | .get(???) // TODO: Fix 27 | .map(_.asInstanceOf[js.Dictionary[String]]) 28 | .map { dict => 29 | val json = dict.getOrElse(StorageKey, "{}") 30 | parse(json).toOption 31 | .flatMap(_.as[Json].toOption) 32 | .flatMap(_.hcursor.downField("lastUsedOn").as[Long].toOption) 33 | } 34 | } 35 | } 36 | 37 | private[background] object StorageService { 38 | 39 | private val StorageKey = "data" 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/common/I18NMessages.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.common 2 | 3 | import chrome.i18n.I18N 4 | 5 | class I18NMessages { 6 | 7 | def appName: String = getMessage("extensionName") 8 | 9 | private def getMessage(id: String, substitutions: String*): String = { 10 | I18N 11 | .getMessage(id, substitutions: _*) 12 | .getOrElse(throw new RuntimeException(s"Message $id not available")) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/common/ResourceProvider.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.common 2 | 3 | object ResourceProvider { 4 | 5 | def appIcon96: String = { 6 | chrome.runtime.Runtime.getURL("icons/96/app.png") 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/facades/CommonsFacade.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.facades 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSGlobal 5 | 6 | /** A facade for the functions on scripts/common.js 7 | */ 8 | @js.native 9 | @JSGlobal("facade") 10 | object CommonsFacade extends js.Object { 11 | 12 | def notify(title: String, message: String, iconUrl: String): Unit = js.native 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/facades/SweetAlert.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.facades 2 | 3 | import scala.scalajs.js 4 | import scala.scalajs.js.annotation.JSImport 5 | 6 | @js.native 7 | @JSImport("sweetalert", JSImport.Namespace, globalFallback = "swal") 8 | object SweetAlert extends js.Object { 9 | def apply(text: String): js.Promise[js.Dynamic] = js.native 10 | def apply(title: String, text: String): js.Promise[js.Dynamic] = js.native 11 | 12 | def apply(title: String, text: String, icon: String): js.Promise[js.Dynamic] = 13 | js.native 14 | 15 | def apply(options: Options = js.native): js.Promise[Boolean] = js.native 16 | 17 | trait Options extends js.Object { 18 | var title: js.UndefOr[String] = js.undefined 19 | var text: js.UndefOr[String] = js.undefined 20 | var icon: js.UndefOr[String] = js.undefined 21 | var buttons: js.UndefOr[js.Array[String]] = js.undefined 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/popup/Runner.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.popup 2 | 3 | import com.alexitc.chromeapp.background.BackgroundAPI 4 | import com.alexitc.chromeapp.common.I18NMessages 5 | import org.scalajs.dom._ 6 | 7 | class Runner(messages: I18NMessages, backgroundAPI: BackgroundAPI) { 8 | 9 | def run(): Unit = { 10 | log("This was run by the popup script") 11 | document.onreadystatechange = _ => { 12 | if (document.readyState == "interactive") { 13 | document 14 | .getElementById("popup-view-id") 15 | .innerHTML = s"${messages.appName}!!!
" 16 | } 17 | } 18 | backgroundAPI.sendBrowserNotification(messages.appName, "I'm on the Pop-up") 19 | } 20 | 21 | private def log(msg: String): Unit = { 22 | println(s"popup: $msg") 23 | } 24 | } 25 | 26 | object Runner { 27 | 28 | def apply(): Runner = { 29 | val messages = new I18NMessages 30 | val backgroundAPI = new BackgroundAPI() 31 | new Runner(messages, backgroundAPI) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/website/ObjectInjector.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.website 2 | 3 | import com.alexitc.chromeapp.activetab.ActiveTabPublicAPI 4 | 5 | import scala.concurrent.ExecutionContext 6 | import scala.scalajs.js 7 | import scala.scalajs.js.JSConverters.JSRichFutureNonThenable 8 | import scala.scalajs.js.PropertyDescriptor 9 | import scala.scalajs.js.|._ 10 | 11 | /** This is js-object that can be injected directly into websites. 12 | * 13 | * For example, you can use this to interact with the website JavaScript, expose new JavaScript functions to the 14 | * website (like Metamask), etc. 15 | * 16 | * There are some important details to consider: 17 | * - Exposed JavaScript function should be js-friendly instead of pure Scala, expose a js Promise instead of a Scala 18 | * Future. 19 | * - The exposed Scala functions should be public (required by the compiler). 20 | * 21 | * In this case, the website would get this function available: window.injected.getInfo() 22 | */ 23 | private[website] class ObjectInjector(name: String = "injected", activeTab: ActiveTabPublicAPI)(implicit 24 | ec: ExecutionContext 25 | ) { 26 | 27 | def inject(parent: js.Object): Unit = { 28 | js.Object.defineProperty( 29 | parent, 30 | name, 31 | new PropertyDescriptor { 32 | enumerable = false 33 | writable = false 34 | configurable = false 35 | value = js.Dictionary( 36 | "getInfo" -> js.Any.fromFunction0(() => getInfo()) 37 | ): js.UndefOr[js.Any] 38 | } 39 | ) 40 | } 41 | 42 | def getInfo(): js.Promise[String] = { 43 | activeTab 44 | .getInfo() 45 | .map(_.details) 46 | .toJSPromise 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/scala/com/alexitc/chromeapp/website/Runner.scala: -------------------------------------------------------------------------------- 1 | package com.alexitc.chromeapp.website 2 | 3 | import com.alexitc.chromeapp.activetab.ActiveTabPublicAPI 4 | import org.scalajs.dom 5 | 6 | import scala.concurrent.ExecutionContext 7 | 8 | /** NOTE: This runs on the current website context, which means we are in a risky environment as the website isn't 9 | * controlled by us and it can re-define any js function to be different to what we expect. 10 | */ 11 | class Runner(scriptInjection: ObjectInjector) { 12 | 13 | def run(): Unit = { 14 | println("This was run by the active tab on the website context") 15 | scriptInjection.inject(dom.window) 16 | } 17 | } 18 | 19 | object Runner { 20 | 21 | def apply()(implicit ec: ExecutionContext): Runner = { 22 | val activeTabPublicAPI = new ActiveTabPublicAPI() 23 | val scriptInjection = new ObjectInjector(activeTab = activeTabPublicAPI) 24 | new Runner(scriptInjection) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/scala/ExampleSpec.scala: -------------------------------------------------------------------------------- 1 | import org.scalatest.wordspec.AnyWordSpec 2 | import org.scalatest.matchers.must.Matchers._ 3 | 4 | class ExampleSpec extends AnyWordSpec { 5 | "example" should { 6 | "work" in { 7 | println("It works!") 8 | true must be(true) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test.webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | // Unfortunately, scalajs-bundler is not generating this file while running `sbt test` 4 | module.exports = require('../main/scalajs.webpack.config'); 5 | 6 | // NOTE: development is useful for debugging but apparently it breaks the build for Chrome 7 | // with the current settings. 8 | module.exports.mode = "production"; 9 | 10 | // by default, scalajs-bundler sets this to "var" but that breaks the build for Firefox. 11 | module.exports.output.libraryTarget = "window"; 12 | --------------------------------------------------------------------------------