Split main browsh code into separate files
This commit is contained in:
parent
c0a79caf4e
commit
3d0b2ec9c8
|
@ -1,9 +1,7 @@
|
|||
package browsh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
|
@ -13,8 +11,6 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
// TCell seems to be one of the best projects in any language for handling terminal
|
||||
|
@ -109,20 +105,6 @@ func Log(msg string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Write a simple text string to the screen. Not for use in the browser frames
|
||||
// themselves. If you want anything to appear in the browser that must be done
|
||||
// through the webextension.
|
||||
func writeString(x, y int, str string) {
|
||||
var defaultColours = tcell.StyleDefault
|
||||
rgb := tcell.NewHexColor(int32(0xffffff))
|
||||
defaultColours.Foreground(rgb)
|
||||
for _, c := range str {
|
||||
screen.SetContent(x, y, c, nil, defaultColours)
|
||||
x++
|
||||
}
|
||||
screen.Sync()
|
||||
}
|
||||
|
||||
func initialise(isTesting bool) {
|
||||
flag.Parse()
|
||||
if isTesting {
|
||||
|
@ -132,16 +114,6 @@ func initialise(isTesting bool) {
|
|||
setupLogging()
|
||||
}
|
||||
|
||||
func setupTcell() {
|
||||
var err error
|
||||
if err = screen.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
screen.EnableMouse()
|
||||
screen.Clear()
|
||||
}
|
||||
|
||||
// Shutdown ... Cleanly Shutdown browsh
|
||||
func Shutdown(err error) {
|
||||
exitCode := 0
|
||||
|
@ -155,146 +127,6 @@ func Shutdown(err error) {
|
|||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func sendTtySize() {
|
||||
x, y := screen.Size()
|
||||
sendMessageToWebExtension(fmt.Sprintf("/tty_size,%d,%d", x, y))
|
||||
}
|
||||
|
||||
func readStdin() {
|
||||
for {
|
||||
ev := screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventKey:
|
||||
if ev.Key() == tcell.KeyCtrlQ {
|
||||
if !*isUseExistingFirefox {
|
||||
quitFirefox()
|
||||
}
|
||||
Shutdown(errors.New("normal"))
|
||||
}
|
||||
eventMap := map[string]interface{}{
|
||||
"key": int(ev.Key()),
|
||||
"char": string(ev.Rune()),
|
||||
"mod": int(ev.Modifiers()),
|
||||
}
|
||||
marshalled, _ := json.Marshal(eventMap)
|
||||
sendMessageToWebExtension("/stdin," + string(marshalled))
|
||||
case *tcell.EventResize:
|
||||
screen.Sync()
|
||||
sendTtySize()
|
||||
case *tcell.EventMouse:
|
||||
x, y := ev.Position()
|
||||
button := ev.Buttons()
|
||||
eventMap := map[string]interface{}{
|
||||
"button": int(button),
|
||||
"mouse_x": int(x),
|
||||
"mouse_y": int(y),
|
||||
"modifiers": int(ev.Modifiers()),
|
||||
}
|
||||
marshalled, _ := json.Marshal(eventMap)
|
||||
sendMessageToWebExtension("/stdin," + string(marshalled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessageToWebExtension(message string) {
|
||||
if (!isConnectedToWebExtension) {
|
||||
Log("Webextension not connected. Message not sent: " + message)
|
||||
return
|
||||
}
|
||||
stdinChannel <- message
|
||||
}
|
||||
|
||||
func webSocketReader(ws *websocket.Conn) {
|
||||
defer ws.Close()
|
||||
for {
|
||||
_, message, err := ws.ReadMessage()
|
||||
handleWebextensionCommand(message)
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseGoingAway) {
|
||||
Log("Socket reader detected that the browser closed the websocket")
|
||||
triggerSocketWriterClose()
|
||||
return
|
||||
}
|
||||
Shutdown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleWebextensionCommand(message []byte) {
|
||||
parts := strings.Split(string(message), ",")
|
||||
command := parts[0]
|
||||
switch command {
|
||||
case "/frame":
|
||||
frame := parseJSONframe(strings.Join(parts[1:], ","))
|
||||
renderFrame(frame)
|
||||
case "/screenshot":
|
||||
saveScreenshot(parts[1])
|
||||
default:
|
||||
Log("WEBEXT: " + string(message))
|
||||
}
|
||||
}
|
||||
|
||||
// Frames received from the webextension are 1 dimensional arrays of strings.
|
||||
// They are made up of a repeating pattern of 7 items:
|
||||
// ["FG RED", "FG GREEN", "FG BLUE", "BG RED", "BG GREEN", "BG BLUE", "CHARACTER" ...]
|
||||
func parseJSONframe(jsonString string) []string {
|
||||
var frame []string
|
||||
jsonBytes := []byte(jsonString)
|
||||
if err := json.Unmarshal(jsonBytes, &frame); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// Tcell uses a buffer to collect screen updates on, it only actually sends
|
||||
// ANSI rendering commands to the terminal when we tell it to. And even then it
|
||||
// will try to minimise rendering commands by only rendering parts of the terminal
|
||||
// that have changed.
|
||||
func renderFrame(frame []string) {
|
||||
var styling = tcell.StyleDefault
|
||||
var character string
|
||||
var runeChars []rune
|
||||
width, height := screen.Size()
|
||||
if (width * height * 7 != len(frame)) {
|
||||
Log("Not rendering frame: current frame is not the same size as the screen")
|
||||
Log(fmt.Sprintf("screen: %d, frame: %d", width * height * 7, len(frame)))
|
||||
return
|
||||
}
|
||||
index := 0
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
styling = styling.Foreground(getRGBColor(frame, index))
|
||||
index += 3
|
||||
styling = styling.Background(getRGBColor(frame, index))
|
||||
index += 3
|
||||
character = frame[index]
|
||||
runeChars = []rune(character)
|
||||
index++
|
||||
if (character == "WIDE") {
|
||||
continue
|
||||
}
|
||||
screen.SetCell(x, y, styling, runeChars[0])
|
||||
}
|
||||
}
|
||||
screen.Show()
|
||||
}
|
||||
|
||||
func getRGBColor(frame []string, index int) tcell.Color {
|
||||
rgb := frame[index:index + 3]
|
||||
return tcell.NewRGBColor(
|
||||
toInt32(rgb[0]),
|
||||
toInt32(rgb[1]),
|
||||
toInt32(rgb[2]))
|
||||
}
|
||||
|
||||
func toInt32(char string) int32 {
|
||||
i, err := strconv.ParseInt(char, 10, 32)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
return int32(i)
|
||||
}
|
||||
|
||||
func saveScreenshot(base64String string) {
|
||||
dec, err := base64.StdEncoding.DecodeString(base64String)
|
||||
if err != nil {
|
||||
|
@ -319,47 +151,6 @@ func saveScreenshot(base64String string) {
|
|||
file.Close()
|
||||
}
|
||||
|
||||
// When the socket reader attempts to read from a closed websocket it quickly and
|
||||
// simply closes its associated Go routine. However the socket writer won't
|
||||
// automatically notice until it actually needs to send something. So we force that
|
||||
// by sending this NOOP text.
|
||||
// TODO: There's a potential race condition because new connections share the same
|
||||
// Go channel. So we need to setup a new channel for every connection.
|
||||
func triggerSocketWriterClose() {
|
||||
stdinChannel <- "BROWSH CLIENT FORCING CLOSE OF WEBSOCKET WRITER"
|
||||
}
|
||||
|
||||
func webSocketWriter(ws *websocket.Conn) {
|
||||
var message string
|
||||
defer ws.Close()
|
||||
for {
|
||||
message = <-stdinChannel
|
||||
Log(fmt.Sprintf("TTY sending: %s", message))
|
||||
if err := ws.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {
|
||||
if err == websocket.ErrCloseSent {
|
||||
Log("Socket writer detected that the browser closed the websocket")
|
||||
return
|
||||
}
|
||||
Shutdown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webSocketServer(w http.ResponseWriter, r *http.Request) {
|
||||
Log("Incoming web request from browser")
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
|
||||
isConnectedToWebExtension = true
|
||||
|
||||
go webSocketWriter(ws)
|
||||
go webSocketReader(ws)
|
||||
|
||||
sendTtySize()
|
||||
}
|
||||
|
||||
// Gets a cross-platform path to store Browsh config
|
||||
func getConfigFolder() string {
|
||||
configDirs := configdir.New("browsh", "firefox_profile")
|
||||
|
@ -389,188 +180,6 @@ func Shell(command string) string {
|
|||
return stripWhitespace(string(out))
|
||||
}
|
||||
|
||||
func startHeadlessFirefox() {
|
||||
Log("Starting Firefox in headless mode")
|
||||
firefoxPath := Shell("which " + *firefoxBinary)
|
||||
if _, err := os.Stat(firefoxPath); os.IsNotExist(err) {
|
||||
Shutdown(errors.New("Firefox command not found: " + *firefoxBinary))
|
||||
}
|
||||
args := []string{"--marionette"}
|
||||
if !*isFFGui {
|
||||
args = append(args, "--headless")
|
||||
}
|
||||
if *useFFProfile != "default" {
|
||||
Log("Using profile: " + *useFFProfile)
|
||||
args = append(args, "-P", *useFFProfile)
|
||||
} else {
|
||||
profilePath := getConfigFolder()
|
||||
Log("Using default profile at: " + profilePath)
|
||||
args = append(args, "--profile", profilePath)
|
||||
}
|
||||
firefoxProcess := exec.Command(*firefoxBinary, args...)
|
||||
defer firefoxProcess.Process.Kill()
|
||||
stdout, err := firefoxProcess.StdoutPipe()
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
if err := firefoxProcess.Start(); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
in := bufio.NewScanner(stdout)
|
||||
for in.Scan() {
|
||||
Log("FF-CONSOLE: " + in.Text())
|
||||
}
|
||||
}
|
||||
|
||||
// Start Firefox via the `web-ext` CLI tool. This is for development and testing,
|
||||
// because I haven't been able to recreate the way `web-ext` injects an unsigned
|
||||
// extension.
|
||||
func startWERFirefox() {
|
||||
Log("Attempting to start headless Firefox with `web-ext`")
|
||||
var rootDir = Shell("git rev-parse --show-toplevel")
|
||||
args := []string{
|
||||
"run",
|
||||
"--firefox=" + rootDir + "/webext/contrib/firefoxheadless.sh",
|
||||
"--verbose",
|
||||
"--no-reload",
|
||||
"--url=http://www.something.com/",
|
||||
}
|
||||
firefoxProcess := exec.Command(rootDir+"/webext/node_modules/.bin/web-ext", args...)
|
||||
firefoxProcess.Dir = rootDir + "/webext/dist/"
|
||||
defer firefoxProcess.Process.Kill()
|
||||
stdout, err := firefoxProcess.StdoutPipe()
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
if err := firefoxProcess.Start(); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
in := bufio.NewScanner(stdout)
|
||||
for in.Scan() {
|
||||
if strings.Contains(in.Text(), "JavaScript strict") ||
|
||||
strings.Contains(in.Text(), "D-BUS") ||
|
||||
strings.Contains(in.Text(), "dbus") {
|
||||
continue
|
||||
}
|
||||
Log("FF-CONSOLE: " + in.Text())
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to Firefox's Marionette service.
|
||||
// RANT: Firefox's remote control tools are so confusing. There seem to be 2
|
||||
// services that come with your Firefox binary; Marionette and the Remote
|
||||
// Debugger. The latter you would expect to follow the widely supported
|
||||
// Chrome standard, but no, it's merely on the roadmap. There is very little
|
||||
// documentation on either. I have the impression, but I'm not sure why, that
|
||||
// the Remote Debugger is better, seemingly more API methods, and as mentioned
|
||||
// is on the roadmap to follow the Chrome standard.
|
||||
// I've used Marionette here, simply because it was easier to reverse engineer
|
||||
// from the Python Marionette package.
|
||||
func firefoxMarionette() {
|
||||
Log("Attempting to connect to Firefox Marionette")
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:2828")
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
marionette = conn
|
||||
readMarionette()
|
||||
sendFirefoxCommand("newSession", map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Install the Browsh extension that was bundled with `go-bindata` under
|
||||
// `webextension.go`.
|
||||
func installWebextension() {
|
||||
data, err := Asset("webext/dist/web-ext-artifacts/browsh.xpi")
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
file, err := ioutil.TempFile(os.TempDir(), "prefix")
|
||||
defer os.Remove(file.Name())
|
||||
ioutil.WriteFile(file.Name(), []byte(data), 0644)
|
||||
args := map[string]interface{}{"path": file.Name()}
|
||||
sendFirefoxCommand("addon:install", args)
|
||||
}
|
||||
|
||||
// Set a Firefox preference as you would in `about:config`
|
||||
// `value` needs to be supplied with quotes if it's to be used as a JS string
|
||||
func setFFPreference(key string, value string) {
|
||||
sendFirefoxCommand("setContext", map[string]interface{}{"value": "chrome"})
|
||||
script := fmt.Sprintf(`
|
||||
Components.utils.import("resource://gre/modules/Preferences.jsm");
|
||||
prefs = new Preferences({defaultBranch: false});
|
||||
prefs.set("%s", %s);`, key, value)
|
||||
args := map[string]interface{}{"script": script}
|
||||
sendFirefoxCommand("executeScript", args)
|
||||
sendFirefoxCommand("setContext", map[string]interface{}{"value": "content"})
|
||||
}
|
||||
|
||||
// Consume output from Marionette, we don't do anything with it. It"s just
|
||||
// useful to have it in the logs.
|
||||
func readMarionette() {
|
||||
buffer := make([]byte, 4096)
|
||||
count, err := marionette.Read(buffer)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
Log("FF-MRNT: " + string(buffer[:count]))
|
||||
}
|
||||
|
||||
func sendFirefoxCommand(command string, args map[string]interface{}) {
|
||||
Log("Sending `" + command + "` to Firefox Marionette")
|
||||
fullCommand := []interface{}{0, ffCommandCount, command, args}
|
||||
marshalled, _ := json.Marshal(fullCommand)
|
||||
message := fmt.Sprintf("%d:%s", len(marshalled), marshalled)
|
||||
fmt.Fprintf(marionette, message)
|
||||
ffCommandCount++
|
||||
readMarionette()
|
||||
}
|
||||
|
||||
func loadHomePage() {
|
||||
// Wait for the CLI websocket server to start listening
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
args := map[string]interface{}{
|
||||
"url": *startupURL,
|
||||
}
|
||||
sendFirefoxCommand("get", args)
|
||||
}
|
||||
|
||||
func setDefaultPreferences() {
|
||||
for key, value := range defaultFFPrefs {
|
||||
setFFPreference(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func beginTimeLimit() {
|
||||
warningLength := 10
|
||||
warningLimit := time.Duration(*timeLimit - warningLength);
|
||||
time.Sleep(warningLimit * time.Second)
|
||||
message := fmt.Sprintf("Browsh will close in %d seconds...", warningLength)
|
||||
sendMessageToWebExtension("/status," + message)
|
||||
time.Sleep(time.Duration(warningLength) * time.Second)
|
||||
quitFirefox()
|
||||
Shutdown(errors.New("normal"))
|
||||
}
|
||||
|
||||
// Note that everything executed in and from this function is not covered by the integration
|
||||
// tests, because it uses the officially signed webextension, of which there can be only one.
|
||||
// We can't bump the version and create a new signed webextension for every commit.
|
||||
func setupFirefox() {
|
||||
go startHeadlessFirefox()
|
||||
if (*timeLimit > 0) {
|
||||
go beginTimeLimit()
|
||||
}
|
||||
// TODO: Do something better than just waiting
|
||||
time.Sleep(3 * time.Second)
|
||||
firefoxMarionette()
|
||||
setDefaultPreferences()
|
||||
installWebextension()
|
||||
go loadHomePage()
|
||||
}
|
||||
|
||||
func quitFirefox() {
|
||||
sendFirefoxCommand("quitApplication", map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Start ... Start Browsh
|
||||
func Start(injectedScreen tcell.Screen) {
|
||||
var isTesting = fmt.Sprintf("%T", injectedScreen) == "*tcell.simscreen"
|
||||
|
|
103
interfacer/src/browsh/comms.go
Normal file
103
interfacer/src/browsh/comms.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package browsh
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func sendMessageToWebExtension(message string) {
|
||||
if (!isConnectedToWebExtension) {
|
||||
Log("Webextension not connected. Message not sent: " + message)
|
||||
return
|
||||
}
|
||||
stdinChannel <- message
|
||||
}
|
||||
|
||||
// Listen to all messages coming from the webextension
|
||||
func webSocketReader(ws *websocket.Conn) {
|
||||
defer ws.Close()
|
||||
for {
|
||||
_, message, err := ws.ReadMessage()
|
||||
handleWebextensionCommand(message)
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseGoingAway) {
|
||||
Log("Socket reader detected that the browser closed the websocket")
|
||||
triggerSocketWriterClose()
|
||||
return
|
||||
}
|
||||
Shutdown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleWebextensionCommand(message []byte) {
|
||||
parts := strings.Split(string(message), ",")
|
||||
command := parts[0]
|
||||
switch command {
|
||||
case "/frame":
|
||||
frame := parseJSONframe(strings.Join(parts[1:], ","))
|
||||
renderFrame(frame)
|
||||
case "/screenshot":
|
||||
saveScreenshot(parts[1])
|
||||
default:
|
||||
Log("WEBEXT: " + string(message))
|
||||
}
|
||||
}
|
||||
|
||||
// Frames received from the webextension are 1 dimensional arrays of strings.
|
||||
// They are made up of a repeating pattern of 7 items:
|
||||
// ["FG RED", "FG GREEN", "FG BLUE", "BG RED", "BG GREEN", "BG BLUE", "CHARACTER" ...]
|
||||
func parseJSONframe(jsonString string) []string {
|
||||
var frame []string
|
||||
jsonBytes := []byte(jsonString)
|
||||
if err := json.Unmarshal(jsonBytes, &frame); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// When the socket reader attempts to read from a closed websocket it quickly and
|
||||
// simply closes its associated Go routine. However the socket writer won't
|
||||
// automatically notice until it actually needs to send something. So we force that
|
||||
// by sending this NOOP text.
|
||||
// TODO: There's a potential race condition because new connections share the same
|
||||
// Go channel. So we need to setup a new channel for every connection.
|
||||
func triggerSocketWriterClose() {
|
||||
stdinChannel <- "BROWSH CLIENT FORCING CLOSE OF WEBSOCKET WRITER"
|
||||
}
|
||||
|
||||
// Send a message to the webextension
|
||||
func webSocketWriter(ws *websocket.Conn) {
|
||||
var message string
|
||||
defer ws.Close()
|
||||
for {
|
||||
message = <-stdinChannel
|
||||
Log(fmt.Sprintf("TTY sending: %s", message))
|
||||
if err := ws.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {
|
||||
if err == websocket.ErrCloseSent {
|
||||
Log("Socket writer detected that the browser closed the websocket")
|
||||
return
|
||||
}
|
||||
Shutdown(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webSocketServer(w http.ResponseWriter, r *http.Request) {
|
||||
Log("Incoming web request from browser")
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
|
||||
isConnectedToWebExtension = true
|
||||
|
||||
go webSocketWriter(ws)
|
||||
go webSocketReader(ws)
|
||||
|
||||
sendTtySize()
|
||||
}
|
197
interfacer/src/browsh/firefox.go
Normal file
197
interfacer/src/browsh/firefox.go
Normal file
|
@ -0,0 +1,197 @@
|
|||
package browsh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
)
|
||||
|
||||
func startHeadlessFirefox() {
|
||||
Log("Starting Firefox in headless mode")
|
||||
firefoxPath := Shell("which " + *firefoxBinary)
|
||||
if _, err := os.Stat(firefoxPath); os.IsNotExist(err) {
|
||||
Shutdown(errors.New("Firefox command not found: " + *firefoxBinary))
|
||||
}
|
||||
args := []string{"--marionette"}
|
||||
if !*isFFGui {
|
||||
args = append(args, "--headless")
|
||||
}
|
||||
if *useFFProfile != "default" {
|
||||
Log("Using profile: " + *useFFProfile)
|
||||
args = append(args, "-P", *useFFProfile)
|
||||
} else {
|
||||
profilePath := getConfigFolder()
|
||||
Log("Using default profile at: " + profilePath)
|
||||
args = append(args, "--profile", profilePath)
|
||||
}
|
||||
firefoxProcess := exec.Command(*firefoxBinary, args...)
|
||||
defer firefoxProcess.Process.Kill()
|
||||
stdout, err := firefoxProcess.StdoutPipe()
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
if err := firefoxProcess.Start(); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
in := bufio.NewScanner(stdout)
|
||||
for in.Scan() {
|
||||
Log("FF-CONSOLE: " + in.Text())
|
||||
}
|
||||
}
|
||||
|
||||
// Start Firefox via the `web-ext` CLI tool. This is for development and testing,
|
||||
// because I haven't been able to recreate the way `web-ext` injects an unsigned
|
||||
// extension.
|
||||
func startWERFirefox() {
|
||||
Log("Attempting to start headless Firefox with `web-ext`")
|
||||
var rootDir = Shell("git rev-parse --show-toplevel")
|
||||
args := []string{
|
||||
"run",
|
||||
"--firefox=" + rootDir + "/webext/contrib/firefoxheadless.sh",
|
||||
"--verbose",
|
||||
"--no-reload",
|
||||
"--url=http://www.something.com/",
|
||||
}
|
||||
firefoxProcess := exec.Command(rootDir+"/webext/node_modules/.bin/web-ext", args...)
|
||||
firefoxProcess.Dir = rootDir + "/webext/dist/"
|
||||
defer firefoxProcess.Process.Kill()
|
||||
stdout, err := firefoxProcess.StdoutPipe()
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
if err := firefoxProcess.Start(); err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
in := bufio.NewScanner(stdout)
|
||||
for in.Scan() {
|
||||
if strings.Contains(in.Text(), "JavaScript strict") ||
|
||||
strings.Contains(in.Text(), "D-BUS") ||
|
||||
strings.Contains(in.Text(), "dbus") {
|
||||
continue
|
||||
}
|
||||
Log("FF-CONSOLE: " + in.Text())
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to Firefox's Marionette service.
|
||||
// RANT: Firefox's remote control tools are so confusing. There seem to be 2
|
||||
// services that come with your Firefox binary; Marionette and the Remote
|
||||
// Debugger. The latter you would expect to follow the widely supported
|
||||
// Chrome standard, but no, it's merely on the roadmap. There is very little
|
||||
// documentation on either. I have the impression, but I'm not sure why, that
|
||||
// the Remote Debugger is better, seemingly more API methods, and as mentioned
|
||||
// is on the roadmap to follow the Chrome standard.
|
||||
// I've used Marionette here, simply because it was easier to reverse engineer
|
||||
// from the Python Marionette package.
|
||||
func firefoxMarionette() {
|
||||
Log("Attempting to connect to Firefox Marionette")
|
||||
conn, err := net.Dial("tcp", "127.0.0.1:2828")
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
marionette = conn
|
||||
readMarionette()
|
||||
sendFirefoxCommand("newSession", map[string]interface{}{})
|
||||
}
|
||||
|
||||
// Install the Browsh extension that was bundled with `go-bindata` under
|
||||
// `webextension.go`.
|
||||
func installWebextension() {
|
||||
data, err := Asset("webext/dist/web-ext-artifacts/browsh.xpi")
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
file, err := ioutil.TempFile(os.TempDir(), "prefix")
|
||||
defer os.Remove(file.Name())
|
||||
ioutil.WriteFile(file.Name(), []byte(data), 0644)
|
||||
args := map[string]interface{}{"path": file.Name()}
|
||||
sendFirefoxCommand("addon:install", args)
|
||||
}
|
||||
|
||||
// Set a Firefox preference as you would in `about:config`
|
||||
// `value` needs to be supplied with quotes if it's to be used as a JS string
|
||||
func setFFPreference(key string, value string) {
|
||||
sendFirefoxCommand("setContext", map[string]interface{}{"value": "chrome"})
|
||||
script := fmt.Sprintf(`
|
||||
Components.utils.import("resource://gre/modules/Preferences.jsm");
|
||||
prefs = new Preferences({defaultBranch: false});
|
||||
prefs.set("%s", %s);`, key, value)
|
||||
args := map[string]interface{}{"script": script}
|
||||
sendFirefoxCommand("executeScript", args)
|
||||
sendFirefoxCommand("setContext", map[string]interface{}{"value": "content"})
|
||||
}
|
||||
|
||||
// Consume output from Marionette, we don't do anything with it. It"s just
|
||||
// useful to have it in the logs.
|
||||
func readMarionette() {
|
||||
buffer := make([]byte, 4096)
|
||||
count, err := marionette.Read(buffer)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
Log("FF-MRNT: " + string(buffer[:count]))
|
||||
}
|
||||
|
||||
func sendFirefoxCommand(command string, args map[string]interface{}) {
|
||||
Log("Sending `" + command + "` to Firefox Marionette")
|
||||
fullCommand := []interface{}{0, ffCommandCount, command, args}
|
||||
marshalled, _ := json.Marshal(fullCommand)
|
||||
message := fmt.Sprintf("%d:%s", len(marshalled), marshalled)
|
||||
fmt.Fprintf(marionette, message)
|
||||
ffCommandCount++
|
||||
readMarionette()
|
||||
}
|
||||
|
||||
func loadHomePage() {
|
||||
// Wait for the CLI websocket server to start listening
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
args := map[string]interface{}{
|
||||
"url": *startupURL,
|
||||
}
|
||||
sendFirefoxCommand("get", args)
|
||||
}
|
||||
|
||||
func setDefaultPreferences() {
|
||||
for key, value := range defaultFFPrefs {
|
||||
setFFPreference(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func beginTimeLimit() {
|
||||
warningLength := 10
|
||||
warningLimit := time.Duration(*timeLimit - warningLength);
|
||||
time.Sleep(warningLimit * time.Second)
|
||||
message := fmt.Sprintf("Browsh will close in %d seconds...", warningLength)
|
||||
sendMessageToWebExtension("/status," + message)
|
||||
time.Sleep(time.Duration(warningLength) * time.Second)
|
||||
quitFirefox()
|
||||
Shutdown(errors.New("normal"))
|
||||
}
|
||||
|
||||
// Note that everything executed in and from this function is not covered by the integration
|
||||
// tests, because it uses the officially signed webextension, of which there can be only one.
|
||||
// We can't bump the version and create a new signed webextension for every commit.
|
||||
func setupFirefox() {
|
||||
go startHeadlessFirefox()
|
||||
if (*timeLimit > 0) {
|
||||
go beginTimeLimit()
|
||||
}
|
||||
// TODO: Do something better than just waiting
|
||||
time.Sleep(3 * time.Second)
|
||||
firefoxMarionette()
|
||||
setDefaultPreferences()
|
||||
installWebextension()
|
||||
go loadHomePage()
|
||||
}
|
||||
|
||||
func quitFirefox() {
|
||||
sendFirefoxCommand("quitApplication", map[string]interface{}{})
|
||||
}
|
130
interfacer/src/browsh/tty.go
Normal file
130
interfacer/src/browsh/tty.go
Normal file
|
@ -0,0 +1,130 @@
|
|||
package browsh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gdamore/tcell"
|
||||
"github.com/go-errors/errors"
|
||||
)
|
||||
|
||||
func setupTcell() {
|
||||
var err error
|
||||
if err = screen.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
screen.EnableMouse()
|
||||
screen.Clear()
|
||||
}
|
||||
|
||||
func sendTtySize() {
|
||||
x, y := screen.Size()
|
||||
sendMessageToWebExtension(fmt.Sprintf("/tty_size,%d,%d", x, y))
|
||||
}
|
||||
|
||||
// This is basically a proxy that listens to STDIN and forwards all relevant input
|
||||
// from the user to the webextension. So keyboard, mouse, terminal resizes, etc.
|
||||
func readStdin() {
|
||||
for {
|
||||
ev := screen.PollEvent()
|
||||
switch ev := ev.(type) {
|
||||
case *tcell.EventKey:
|
||||
if ev.Key() == tcell.KeyCtrlQ {
|
||||
if !*isUseExistingFirefox {
|
||||
quitFirefox()
|
||||
}
|
||||
Shutdown(errors.New("normal"))
|
||||
}
|
||||
eventMap := map[string]interface{}{
|
||||
"key": int(ev.Key()),
|
||||
"char": string(ev.Rune()),
|
||||
"mod": int(ev.Modifiers()),
|
||||
}
|
||||
marshalled, _ := json.Marshal(eventMap)
|
||||
sendMessageToWebExtension("/stdin," + string(marshalled))
|
||||
case *tcell.EventResize:
|
||||
screen.Sync()
|
||||
sendTtySize()
|
||||
case *tcell.EventMouse:
|
||||
x, y := ev.Position()
|
||||
button := ev.Buttons()
|
||||
eventMap := map[string]interface{}{
|
||||
"button": int(button),
|
||||
"mouse_x": int(x),
|
||||
"mouse_y": int(y),
|
||||
"modifiers": int(ev.Modifiers()),
|
||||
}
|
||||
marshalled, _ := json.Marshal(eventMap)
|
||||
sendMessageToWebExtension("/stdin," + string(marshalled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write a simple text string to the screen. Not for use in the browser frames
|
||||
// themselves. If you want anything to appear in the browser that must be done
|
||||
// through the webextension.
|
||||
func writeString(x, y int, str string) {
|
||||
var defaultColours = tcell.StyleDefault
|
||||
rgb := tcell.NewHexColor(int32(0xffffff))
|
||||
defaultColours.Foreground(rgb)
|
||||
for _, c := range str {
|
||||
screen.SetContent(x, y, c, nil, defaultColours)
|
||||
x++
|
||||
}
|
||||
screen.Sync()
|
||||
}
|
||||
|
||||
// Tcell uses a buffer to collect screen updates on, it only actually sends
|
||||
// ANSI rendering commands to the terminal when we tell it to. And even then it
|
||||
// will try to minimise rendering commands by only rendering parts of the terminal
|
||||
// that have changed.
|
||||
func renderFrame(frame []string) {
|
||||
var styling = tcell.StyleDefault
|
||||
var character string
|
||||
var runeChars []rune
|
||||
width, height := screen.Size()
|
||||
if (width * height * 7 != len(frame)) {
|
||||
Log("Not rendering frame: current frame is not the same size as the screen")
|
||||
Log(fmt.Sprintf("screen: %d, frame: %d", width * height * 7, len(frame)))
|
||||
return
|
||||
}
|
||||
index := 0
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
styling = styling.Foreground(getRGBColor(frame, index))
|
||||
index += 3
|
||||
styling = styling.Background(getRGBColor(frame, index))
|
||||
index += 3
|
||||
character = frame[index]
|
||||
runeChars = []rune(character)
|
||||
index++
|
||||
if (character == "WIDE") {
|
||||
continue
|
||||
}
|
||||
screen.SetCell(x, y, styling, runeChars[0])
|
||||
}
|
||||
}
|
||||
screen.Show()
|
||||
}
|
||||
|
||||
// Given a raw frame from the webextension, find the RGB colour at a given
|
||||
// 1 dimensional index.
|
||||
func getRGBColor(frame []string, index int) tcell.Color {
|
||||
rgb := frame[index:index + 3]
|
||||
return tcell.NewRGBColor(
|
||||
toInt32(rgb[0]),
|
||||
toInt32(rgb[1]),
|
||||
toInt32(rgb[2]))
|
||||
}
|
||||
|
||||
// Convert a string representation of an integer to an integer
|
||||
func toInt32(char string) int32 {
|
||||
i, err := strconv.ParseInt(char, 10, 32)
|
||||
if err != nil {
|
||||
Shutdown(err)
|
||||
}
|
||||
return int32(i)
|
||||
}
|
Loading…
Reference in a new issue