aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/call-id.lisp15
-rw-r--r--src/config.lisp54
-rw-r--r--src/devtools-protocol.lisp45
-rw-r--r--src/macro.lisp14
-rw-r--r--src/main.lisp141
-rw-r--r--src/option.lisp82
-rw-r--r--src/package.lisp3
7 files changed, 354 insertions, 0 deletions
diff --git a/src/call-id.lisp b/src/call-id.lisp
new file mode 100644
index 0000000..a21e6ab
--- /dev/null
+++ b/src/call-id.lisp
@@ -0,0 +1,15 @@
+(in-package :extreload)
+
+(defclass call-id ()
+ ((id
+ :initform 0
+ :reader id
+ :documentation "Current call ID."))
+
+ (:documentation "An incrementing identifier."))
+
+(defgeneric next-call-id (call-id)
+ (:documentation "Increment the call ID and return the result."))
+
+(defmethod next-call-id ((call-id call-id))
+ (incf (slot-value call-id 'id)))
diff --git a/src/config.lisp b/src/config.lisp
new file mode 100644
index 0000000..ab973cd
--- /dev/null
+++ b/src/config.lisp
@@ -0,0 +1,54 @@
+(in-package :extreload)
+
+(defclass config ()
+ ((socket-url
+ :initarg :socket-url
+ :reader socket-url
+ :documentation "DevTools WebSocket URL")
+ (extension-ids
+ :initarg :extension-ids
+ :reader extension-ids
+ :documentation "Sequence of extension IDs")
+ (reload-current-tab
+ :initarg :reload-current-tab
+ :initform nil
+ :reader reload-current-tab
+ :documentation "True if the current tab should be reloaded")
+ (debug-output
+ :initarg :debug-output
+ :initform nil
+ :reader debug-output
+ :documentation "True to enable debug output")
+
+ (ws-client
+ :reader ws-client
+ :documentation "WebSocket client")))
+
+(defmethod print-object ((object config) stream)
+ (print-unreadable-object (object stream :type t)
+ (with-slots (socket-url
+ extension-ids
+ reload-current-tab
+ debug-output
+ ws-client)
+ object
+ (format
+ stream
+ ":socket-url ~s :extension-ids ~s :reload-current-tab ~s :debug-output ~s :ws-client ~s"
+ socket-url extension-ids reload-current-tab debug-output ws-client))))
+
+(defun make-config (&key socket-url
+ extension-ids
+ reload-current-tab
+ debug-output)
+ "Initialise a new config."
+ (let ((config (make-instance 'config
+ :socket-url socket-url
+ :extension-ids extension-ids
+ :reload-current-tab reload-current-tab
+ :debug-output debug-output)))
+
+ ;; Initialise a new websocket-driver client
+ (setf (slot-value config 'ws-client) (wsd:make-client socket-url))
+
+ config))
diff --git a/src/devtools-protocol.lisp b/src/devtools-protocol.lisp
new file mode 100644
index 0000000..2d67033
--- /dev/null
+++ b/src/devtools-protocol.lisp
@@ -0,0 +1,45 @@
+;;;; DevTools Protocol messages.
+
+(in-package :extreload)
+
+(defun target-get-targets-msg (call-id)
+ "DevTools Protocol `Target.getTargets` message."
+ (jsown:to-json
+ `(:obj ("id" . ,call-id)
+ ("method" . "Target.getTargets"))))
+
+(defun target-attach-to-target-msg (call-id target-id)
+ "DevTools Protocol `Target.attachToTarget` message."
+ (jsown:to-json
+ `(:obj ("id" . ,call-id)
+ ("method" . "Target.attachToTarget")
+ ("params" . (:obj ("targetId" . ,target-id)
+ ("flatten" . t))))))
+
+(defun target-attached-to-target-msg-p (message)
+ "Returns true if `message` is `Target.attachedToTarget`."
+ (equal
+ (json-obj-get message "method")
+ "Target.attachedToTarget"))
+
+(defun runtime-evaluate-msg (call-id session-id expression)
+ "DevTools Protocol `Runtime.evaluate` message."
+ (jsown:to-json
+ `(:obj ("id" . ,call-id)
+ ("sessionId" . ,session-id)
+ ("method" . "Runtime.evaluate")
+ ("params" . (:obj ("expression" . ,expression))))))
+
+(defun runtime-evaluate-msg-p (message)
+ "Returns true if `message` is a response to `Runtime.evaluate`."
+ (jsown:keyp (json-obj-get message "result") "sessionId"))
+
+(defun runtime-evaluate-exception-p (message)
+ "Returns true if `message` describes a runtime exception"
+ (jsown:keyp (json-obj-get message "result") "exceptionDetails"))
+
+(defun parse-get-targets-response (response)
+ "Parses a list of target info objects from the response to `Target.getTargets`."
+ (let* ((result (json-obj-get response "result"))
+ (targetInfos (json-obj-get result "targetInfos")))
+ targetInfos))
diff --git a/src/macro.lisp b/src/macro.lisp
new file mode 100644
index 0000000..18c7d21
--- /dev/null
+++ b/src/macro.lisp
@@ -0,0 +1,14 @@
+(in-package :extreload)
+
+(defmacro filter (predicate list-form)
+ "Returns a sequence that only includes elements of `list-form` that satisfy
+the test of `predicate`."
+ `(remove-if-not ,predicate ,list-form))
+
+(defmacro with-websocket-connection ((client) &body body)
+ "Open a WebSocket connection on `client` and run `body` forms. The connection
+is automatically closed at the end of execution."
+ `(progn
+ (wsd:start-connection ,client)
+ (unwind-protect (progn ,@body)
+ (wsd:close-connection ,client))))
diff --git a/src/main.lisp b/src/main.lisp
new file mode 100644
index 0000000..5e44158
--- /dev/null
+++ b/src/main.lisp
@@ -0,0 +1,141 @@
+(in-package :extreload)
+
+(defvar *wg* (wait-group:make-wait-group))
+(defvar *devtools-root-call-id* (make-instance 'call-id)
+ "DevTools Protocol call ID.")
+(defvar *devtools-secondary-call-id* (make-instance 'call-id)
+ "DevTools Protocol call ID used for messages to individual target sessions.")
+
+(defconstant +timeout-seconds+ 5
+ "Global timeout. The program will exit at the end of this delay.")
+
+(defun main ()
+ (handler-bind ((error #'(lambda (e)
+ (exit-with-error e sysexits:+unavailable+))))
+
+ ;; Store the config as a global.
+ (defvar *config* (parse-options))
+
+ (trivial-timeout:with-timeout (+timeout-seconds+)
+ (with-websocket-connection ((ws-client *config*))
+ (wsd:on :message (ws-client *config*)
+ #'(lambda (message)
+ (ws-on-message
+ message
+ (extension-ids *config*)
+ *config*)))
+
+ (websocket-send
+ (ws-client *config*)
+ (target-get-targets-msg
+ (next-call-id *devtools-root-call-id*)))
+
+ (wait-group:wait *wg*)))))
+
+(defun ws-on-message (message extension-ids config)
+ "Called when a WebSocket message is received."
+ (let* ((response (jsown:parse message))
+ (targets (parse-get-targets-response response)))
+ (when (debug-output config)
+ (format t "Response: ~a~%" response)
+ (format t "~a~%" *wg*))
+
+ (when targets
+ (let ((targets (extension-targets targets)))
+
+ (attach-extensions targets extension-ids)))
+
+ (if (target-attached-to-target-msg-p response)
+ (reload-extension (json-obj-get
+ (json-obj-get response "params")
+ "sessionId")))
+
+ (when (and (reload-current-tab config)
+ (runtime-evaluate-msg-p response))
+ (reload-tab (json-obj-get
+ (json-obj-get response "result")
+ "sessionId")))
+
+ ;; Failed to reload tab.
+ (when (runtime-evaluate-exception-p response)
+ ;; `reload-tab` adds an extra increment to the wait group. If the call
+ ;; fails, we only receive one message instead of two, so the wait group
+ ;; must be decremented to match.
+ (wait-group:done *wg*)
+
+ (reload-tab (json-obj-get response "sessionId")))
+
+ (wait-group:done *wg*)))
+
+(defun json-obj-get (obj key)
+ "Get the value of `key` from `obj` (a `jsown` object). Return nil if `key` is
+not defined."
+ (handler-case
+ (jsown:val obj key)
+ (simple-error (e)
+ (let ((s (format nil "~A" e)))
+ (if (search "not available" s)
+ nil)))))
+
+(defun attach-extensions (targets extension-ids)
+ "Attach to all extensions in `targets` that match the IDs in
+`extension-ids`."
+ (labels ((requested-extension-p (target)
+ (find-if
+ #'(lambda (id)
+ (uiop:string-prefix-p
+ (concatenate 'string "chrome-extension://" id)
+ (json-obj-get target "url")))
+ extension-ids)))
+
+ (dolist (extension (filter #'requested-extension-p targets))
+ (attach-to-target extension))))
+
+(defun attach-to-target (extension)
+ "Send a message to the target in `extension` asking to attach to the target."
+ (let ((target-id (json-obj-get extension "targetId")))
+ (websocket-send (ws-client *config*)
+ (target-attach-to-target-msg
+ (next-call-id *devtools-root-call-id*)
+ target-id))))
+
+(defun reload-extension (session-id)
+ "Send a message to an extension page corresponding to `session-id`, telling
+the target extension to reload itself."
+ (websocket-send
+ (ws-client *config*)
+ (runtime-evaluate-msg
+ (next-call-id *devtools-secondary-call-id*)
+ session-id
+ "chrome.runtime.reload()")))
+
+(defun reload-tab (session-id)
+ "Send a message to an extension page corresponding to `session-id`, telling
+the target to reload the current tab."
+
+ ;; Two response messages always come back from the `chrome.tabs.reload()`
+ ;; messages, so we need to add a second increment to the wait group.
+ (wait-group:add *wg*)
+
+ (websocket-send
+ (ws-client *config*)
+ (runtime-evaluate-msg
+ (next-call-id *devtools-secondary-call-id*)
+ session-id
+ "chrome.tabs.reload()")))
+
+(defun extension-targets (targets)
+ "Filter `targets`, returning a list of targets corresponding to extensions."
+ (labels ((extensionp (target)
+ (string= (json-obj-get target "type")
+ "background_page")))
+
+ (filter #'extensionp targets)))
+
+(defun websocket-send (client data)
+ "Send `data` to WebSocket `client` and increment `*wg*`."
+ (when (debug-output *config*)
+ (format t "Sending: ~a~%" data))
+
+ (wsd:send client data)
+ (wait-group:add *wg*))
diff --git a/src/option.lisp b/src/option.lisp
new file mode 100644
index 0000000..6f9129c
--- /dev/null
+++ b/src/option.lisp
@@ -0,0 +1,82 @@
+;;;; Command line options.
+
+(in-package :extreload)
+
+;; Available command line options.
+(opts:define-opts
+ (:name :socket-url
+ :description "DevTools protocol WebSocket URL"
+ :long "socket-url"
+ :arg-parser #'identity
+ :meta-var "SOCKET_URL")
+ (:name :reload-current-tab
+ :description "pass this to reload the active Chrome tab"
+ :long "reload-current-tab")
+ (:name :debug
+ :description "print debug output"
+ :long "debug")
+ (:name :help
+ :description "print this help menu"
+ :short #\h
+ :long "help")
+ (:name :version
+ :description "show the program version"
+ :short #\V
+ :long "version"))
+
+(defmacro when-option ((options option) &body body)
+ "When `option` is present in `options`, run `body`."
+ `(let ((value (getf ,options ,option)))
+ (when value
+ ,@body)))
+
+(defun exit-with-error (condition exit-code)
+ "Print the error associated with `condition` on standard error, then exit
+with code `exit-code`."
+ (format *error-output* "error: ~a~%" condition)
+
+ (opts:exit exit-code))
+
+(defun handle-option-error (condition)
+ "Handle errors related to command line options. Prints the error specified by
+`condition` and exits with EX_USAGE."
+ (exit-with-error condition sysexits:+usage+))
+
+(defun parse-options ()
+ "Parse command line options."
+ (multiple-value-bind (options free-args)
+ (handler-bind
+ ((opts:unknown-option #'handle-option-error)
+ (opts:missing-arg #'handle-option-error)
+ (opts:arg-parser-failed #'handle-option-error)
+ (opts:missing-required-option #'handle-option-error))
+
+ (opts:get-opts))
+
+ (when-option (options :help)
+ (opts:describe
+ :usage-of "extreload"
+ :args "EXTENSION_ID...")
+
+ (opts:exit sysexits:+usage+))
+
+ (when-option (options :version)
+ (format t "~a~%" (asdf:component-version (asdf:find-system :extreload)))
+
+ (opts:exit sysexits:+ok+))
+
+ (when (null (getf options :socket-url))
+ (format *error-output* "error: '--socket-url' is required~%")
+
+ (opts:exit sysexits:+usage+))
+
+ ;; Error if no extension IDs were given.
+ (when (null free-args)
+ (format *error-output* "error: missing extension IDs~%")
+
+ (opts:exit sysexits:+usage+))
+
+ (make-config :socket-url (getf options :socket-url)
+ :reload-current-tab (getf options :reload-current-tab)
+ :debug-output (getf options :debug)
+ :extension-ids free-args)))
diff --git a/src/package.lisp b/src/package.lisp
new file mode 100644
index 0000000..03ee677
--- /dev/null
+++ b/src/package.lisp
@@ -0,0 +1,3 @@
+(defpackage :extreload
+ (:use :cl)
+ (:export :main))