diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/call-id.lisp | 15 | ||||
-rw-r--r-- | src/config.lisp | 54 | ||||
-rw-r--r-- | src/devtools-protocol.lisp | 45 | ||||
-rw-r--r-- | src/macro.lisp | 14 | ||||
-rw-r--r-- | src/main.lisp | 141 | ||||
-rw-r--r-- | src/option.lisp | 82 | ||||
-rw-r--r-- | src/package.lisp | 3 |
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)) |