Tabs! As we all know and love

Adding, cycling, deleting. The width of the tab handle is currently
fixed to 20. And if there are more tabs than can fit in the tab bar then
the extra ones just dissapear off to the right, but they can still be
cycled to with CTRL-Tab.

The marks the end of feature development in preperation for a version
1 release.
This commit is contained in:
Thomas Buckley-Houston 2018-05-24 18:45:07 +08:00
parent 5c7ff71c79
commit 938d51b692
15 changed files with 566 additions and 285 deletions

View file

@ -142,21 +142,21 @@ func Shell(command string) string {
return stripWhitespace(string(out))
}
// Start ... Start Browsh
// Start starts Browsh
func Start(injectedScreen tcell.Screen) {
var isTesting = fmt.Sprintf("%T", injectedScreen) == "*tcell.simscreen"
screen = injectedScreen
initialise(isTesting)
if !*isUseExistingFirefox {
if isTesting {
writeString(0, 0, "Starting Browsh in test mode...")
writeString(0, 0, "Starting Browsh in test mode...", tcell.StyleDefault)
go startWERFirefox()
} else {
writeString(0, 0, "Starting Browsh, the modern terminal web browser...")
writeString(0, 0, "Starting Browsh, the modern terminal web browser...", tcell.StyleDefault)
setupFirefox()
}
} else {
writeString(0, 0, "Waiting for a Firefox instance to connect...")
writeString(0, 0, "Waiting for a Firefox instance to connect...", tcell.StyleDefault)
}
Log("Starting Browsh CLI client")
go readStdin()

View file

@ -93,7 +93,7 @@ func startWERFirefox() {
"--firefox=" + rootDir + "/webext/contrib/firefoxheadless.sh",
"--verbose",
"--no-reload",
"--url=http://localhost:" + TestServerPort + "/smorgasbord",
"--url=https://www.google.com",
}
firefoxProcess := exec.Command(rootDir+"/webext/node_modules/.bin/web-ext", args...)
firefoxProcess.Dir = rootDir + "/webext/dist/"

View file

@ -1,6 +1,7 @@
package browsh
import (
"fmt"
"encoding/json"
"unicode"
@ -76,14 +77,16 @@ func parseJSONFrameText(jsonString string) {
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
ensureTabExists(incoming.Meta.TabID)
if (!isTabPresent(incoming.Meta.TabID)) {
Log(fmt.Sprintf("Not building frame for non-existent tab ID: %d", incoming.Meta.TabID))
return
}
tabs[incoming.Meta.TabID].frame.buildFrameText(incoming)
}
func (f *frame) buildFrameText(incoming incomingFrameText) {
f.setup(incoming.Meta)
if (!f.isIncomingFrameTextValid(incoming)) { return }
CurrentTab = tabs[incoming.Meta.TabID]
f.updateInputBoxes(incoming)
f.populateFrameText(incoming)
}
@ -94,7 +97,10 @@ func parseJSONFramePixels(jsonString string) {
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
ensureTabExists(incoming.Meta.TabID)
if (!isTabPresent(incoming.Meta.TabID)) {
Log(fmt.Sprintf("Not building frame for non-existent tab ID: %d", incoming.Meta.TabID))
return
}
if (len(tabs[incoming.Meta.TabID].frame.text) == 0) { return }
tabs[incoming.Meta.TabID].frame.buildFramePixels(incoming)
}
@ -102,7 +108,6 @@ func parseJSONFramePixels(jsonString string) {
func (f *frame) buildFramePixels(incoming incomingFramePixels) {
f.setup(incoming.Meta)
if (!f.isIncomingFramePixelsValid(incoming)) { return }
CurrentTab = tabs[incoming.Meta.TabID]
f.populateFramePixels(incoming)
}

View file

@ -168,7 +168,11 @@ func handleInputBoxInput(ev *tcell.EventKey) {
activeInputBox.cursorBackspace()
case tcell.KeyEnter:
if urlInputBox.isActive {
sendMessageToWebExtension("/url_bar," + activeInputBox.text)
if isNewEmptyTabActive() {
sendMessageToWebExtension("/new_tab," + activeInputBox.text)
} else {
sendMessageToWebExtension("/url_bar," + activeInputBox.text)
}
urlBarFocus(false)
}
case tcell.KeyRune:

View file

@ -1,16 +1,24 @@
package browsh
import (
"fmt"
"encoding/json"
)
// Map of all tab data
var tabs = make(map[int]*tab)
// Slice of the order in which tabs appear in the tab bar
var tabsOrder []int
// There can be a race condition between the webext sending a tab state update and the
// the tab being deleted, so we need to keep track of all deleted IDs
var tabsDeleted []int
// CurrentTab is the currently active tab in the TTY browser
var CurrentTab *tab
// A single tab synced from the browser
type tab struct {
ID int `json:"id"`
Active bool `json:"active"`
Title string `json:"title"`
URI string `json:"uri"`
PageState string `json:"page_state"`
@ -20,24 +28,111 @@ type tab struct {
func ensureTabExists(id int) {
if _, ok := tabs[id]; !ok {
tabs[id] = &tab{
ID: id,
frame: frame{
xScroll: 0,
yScroll: 0,
},
newTab(id)
if isNewEmptyTabActive() {
removeTab(-1)
}
}
}
func isTabPresent(id int) bool {
_, ok := tabs[id]
return ok
}
func newTab(id int) {
tabsOrder = append(tabsOrder, id)
tabs[id] = &tab{
ID: id,
frame: frame{
xScroll: 0,
yScroll: 0,
},
}
}
func removeTab(id int) {
if (len(tabs) == 1) { quitBrowsh() }
tabsDeleted = append(tabsDeleted, id)
sendMessageToWebExtension(fmt.Sprintf("/remove_tab,%d", id))
nextTab()
removeTabIDfromTabsOrder(id)
delete(tabs, id)
renderUI()
renderCurrentTabWindow()
}
// A bit complicated! Just want to remove an integer from a slice whilst retaining
// order :/
func removeTabIDfromTabsOrder(id int) {
for i := 0; i < len(tabsOrder); i++ {
if tabsOrder[i] == id {
tabsOrder = append(tabsOrder[:i], tabsOrder[i+1:]...)
}
}
}
// Creating a new tab in the browser without a URI means it won't register with the
// web extension, which means that, come the moment when we actually have a URI for the new
// tab then we can't talk to it to tell it navigate. So we need to only create a real new
// tab when we actually have a URL.
func createNewEmptyTab() {
if isNewEmptyTabActive() { return }
newTab(-1)
tab := tabs[-1]
tab.Title = "New Tab"
tab.URI = ""
tab.Active = true
CurrentTab = tab
CurrentTab.frame.resetCells()
renderUI()
urlBarFocus(true)
renderCurrentTabWindow()
}
func isNewEmptyTabActive() bool {
return isTabPresent(-1)
}
func nextTab() {
for i := 0; i < len(tabsOrder); i++ {
if tabsOrder[i] == CurrentTab.ID {
if (i + 1 == len(tabsOrder)) {
i = 0;
} else {
i++
}
sendMessageToWebExtension(fmt.Sprintf("/switch_to_tab,%d", tabsOrder[i]))
CurrentTab = tabs[tabsOrder[i]]
renderUI()
renderCurrentTabWindow()
break
}
}
}
func isTabPreviouslyDeleted(id int) bool {
for i := 0; i < len(tabsDeleted); i++ {
if tabsDeleted[i] == id {
return true
}
}
return false
}
func parseJSONTabState(jsonString string) {
var incoming tab
jsonBytes := []byte(jsonString)
if err := json.Unmarshal(jsonBytes, &incoming); err != nil {
Shutdown(err)
}
if (isTabPreviouslyDeleted(incoming.ID)) {
return
}
ensureTabExists(incoming.ID)
CurrentTab = tabs[incoming.ID]
if (incoming.Active && !isNewEmptyTabActive()) {
CurrentTab = tabs[incoming.ID]
}
tabs[incoming.ID].handleStateChange(&incoming)
}

View file

@ -3,7 +3,6 @@ package browsh
import (
"fmt"
"os"
"unicode/utf8"
"encoding/json"
"github.com/gdamore/tcell"
@ -54,10 +53,17 @@ func handleUserKeyPress(ev *tcell.EventKey) {
quitBrowsh()
case tcell.KeyCtrlL:
urlBarFocusToggle()
case tcell.KeyCtrlT:
createNewEmptyTab()
case tcell.KeyCtrlW:
removeTab(CurrentTab.ID)
}
if (ev.Rune() == 'm' && ev.Modifiers() == 4) {
toggleMonochromeMode()
}
if (ev.Key() == 9 && ev.Modifiers() == 0) {
nextTab()
}
forwardKeyPress(ev)
if activeInputBox != nil {
handleInputBoxInput(ev)
@ -135,7 +141,7 @@ func handleMouseEvent(ev *tcell.EventMouse) {
func handleTTYResize() {
width, _ := screen.Size()
// TODO: How does this work with wide UTF8 chars?
urlInputBox.Width = width - utf8.RuneCountInString(urlBarControls)
urlInputBox.Width = width
screen.Sync()
sendTtySize()
}

View file

@ -7,9 +7,8 @@ import (
)
var (
urlBarControls = " ← | x | "
urlInputBox = inputBox{
X: utf8.RuneCountInString(urlBarControls),
X: 0,
Y: 1,
Height: 1,
text: "",
@ -27,12 +26,9 @@ func renderUI() {
// 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)
func writeString(x, y int, str string, style tcell.Style) {
for _, c := range str {
screen.SetContent(x, y, c, nil, defaultColours)
screen.SetContent(x, y, c, nil, style)
x++
}
screen.Show()
@ -41,34 +37,40 @@ func writeString(x, y int, str string) {
func fillLineToEnd(x, y int) {
width, _ := screen.Size()
for i := x; i < width - 1; i++ {
writeString(i, y, " ")
writeString(i, y, " ", tcell.StyleDefault)
}
}
func renderTabs() {
var tab *tab
var style tcell.Style
count := 0
xPosition := 0
tabTitleLength := 15
for _, tab := range tabs {
if (tab.frame.text == nil) { continue } // TODO: this shouldn't be needed
tabTitleLength := 20
for _, tabID := range tabsOrder {
tab = tabs[tabID]
tabTitle := []rune(tab.Title)
tabTitleContent := string(tabTitle[0:tabTitleLength]) + " |x "
writeString(xPosition, 0, tabTitleContent)
tabTitleContent := string(tabTitle[0:tabTitleLength])
style = tcell.StyleDefault
if (CurrentTab.ID == tabID) { style = tcell.StyleDefault.Reverse(true) }
writeString(xPosition, 0, tabTitleContent, style)
style = tcell.StyleDefault.Reverse(false)
count++
xPosition = (count * tabTitleLength) + 4
xPosition = count * (tabTitleLength + 1)
writeString(xPosition - 1, 0, "|", style)
}
fillLineToEnd(xPosition, 0)
}
func renderURLBar() {
content := urlBarControls
var content string
if urlInputBox.isActive {
writeString(0, 1, content)
writeString(0, 1, content, tcell.StyleDefault)
content += urlInputBox.text + " "
urlInputBox.renderURLBox()
} else {
content += CurrentTab.URI
writeString(0, 1, content)
writeString(0, 1, content, tcell.StyleDefault)
}
fillLineToEnd(utf8.RuneCountInString(content), 1)
}
@ -95,6 +97,6 @@ func urlBarFocus(on bool) {
func overlayPageStatusMessage() {
_, height := screen.Size()
writeString(0, height - 1, CurrentTab.StatusMessage)
writeString(0, height - 1, CurrentTab.StatusMessage, tcell.StyleDefault)
}

View file

@ -19,12 +19,10 @@ var _ = Describe("Showing a basic webpage", func() {
})
Describe("Browser UI", func() {
It("should have the page title, buttons and the current URL", func() {
It("should have the page title and current URL", func() {
Expect("Smörgåsbord").To(BeInFrameAt(0, 0))
Expect(" ← |").To(BeInFrameAt(0, 1))
Expect(" x |").To(BeInFrameAt(4, 1))
URL := testSiteURL + "/smorgasbord/"
Expect(URL).To(BeInFrameAt(9, 1))
Expect(URL).To(BeInFrameAt(0, 1))
})
Describe("Interaction", func() {
@ -76,6 +74,34 @@ var _ = Describe("Showing a basic webpage", func() {
Expect("!eM▄esreveR").To(BeInFrameAt(12, 21))
})
})
Describe("Tabs", func() {
BeforeEach(func() {
SpecialKey(tcell.KeyCtrlT)
})
AfterEach(func() {
SpecialKey(tcell.KeyCtrlW)
})
It("should create a new tab", func() {
Expect("New Tab").To(BeInFrameAt(21, 0))
})
It("should be able to goto a new URL", func() {
Keyboard(testSiteURL + "/smorgasbord/another.html")
SpecialKey(tcell.KeyEnter)
Expect("Another").To(BeInFrameAt(21, 0))
})
It("should cycle to the next tab", func() {
Expect(" ").To(BeInFrameAt(0, 1))
SpecialKey(tcell.KeyCtrlL)
simScreen.InjectKey(9, 0, tcell.ModNone)
URL := testSiteURL + "/smorgasbord/"
Expect(URL).To(BeInFrameAt(0, 1))
})
})
})
})

View file

@ -1,9 +1,7 @@
package test
import (
"strings"
"time"
"strconv"
"net/http"
"unicode/utf8"
@ -16,9 +14,8 @@ import (
)
var simScreen tcell.SimulationScreen
var startupWait = 10
var startupWait = 10 * time.Second
var perTestTimeout = 2000 * time.Millisecond
var browserFingerprint = " ← | x | "
var rootDir = browsh.Shell("git rev-parse --show-toplevel")
var testSiteURL = "http://localhost:" + browsh.TestServerPort
var ti *terminfo.Terminfo
@ -88,11 +85,18 @@ func WaitForText(text string, x, y int) {
// WaitForPageLoad waits for the page to load
func WaitForPageLoad() {
sleepUntilPageLoad(perTestTimeout)
}
func sleepUntilPageLoad(maxTime time.Duration) {
start := time.Now()
for time.Since(start) < perTestTimeout {
if browsh.CurrentTab.PageState == "parsing_complete" {
time.Sleep(100 * time.Millisecond)
return
time.Sleep(50 * time.Millisecond)
for time.Since(start) < maxTime {
if browsh.CurrentTab != nil {
if browsh.CurrentTab.PageState == "parsing_complete" {
time.Sleep(100 * time.Millisecond)
return
}
}
time.Sleep(50 * time.Millisecond)
}
@ -108,7 +112,7 @@ func GotoURL(url string) {
WaitForPageLoad()
// TODO: Looking for the URL isn't optimal because it could be the same URL
// as the previous test.
gomega.Expect(url).To(BeInFrameAt(9, 1))
gomega.Expect(url).To(BeInFrameAt(0, 1))
}
// BackspaceRemoveURL holds down the backspace key to delete the existing URL
@ -168,7 +172,7 @@ func GetBgColour(x, y int) [3]int32 {
}
func startHTTPServer() {
// Use `NewServerMux()` so as not to conflict with browsh's websocket server
// Using `NewServeMux()` so as not to conflict with browsh's websocket server
serverMux := http.NewServeMux()
serverMux.Handle("/", http.FileServer(http.Dir(rootDir + "/interfacer/test/sites")))
http.ListenAndServe(":" + browsh.TestServerPort, serverMux)
@ -179,24 +183,6 @@ func startBrowsh() {
browsh.Start(simScreen)
}
func waitForBrowsh() {
var count = 0
for {
if count > startupWait {
var message = "Couldn't find browsh " +
"startup signature within " +
strconv.Itoa(startupWait) +
" seconds"
panic(message)
}
time.Sleep(time.Second)
if (strings.Contains(GetFrame(), browserFingerprint)) {
break
}
count++
}
}
func runeCount(text string) int {
return utf8.RuneCountInString(text)
}
@ -212,7 +198,7 @@ var _ = ginkgo.BeforeSuite(func() {
initTerm()
go startHTTPServer()
go startBrowsh()
waitForBrowsh()
sleepUntilPageLoad(startupWait)
})
var _ = ginkgo.AfterSuite(func() {

View file

@ -1,11 +1,14 @@
import _ from 'lodash';
import stripAnsi from 'strip-ansi';
// Here we keep the public functions used to mediate communications between
// the background process, tabs and the terminal.
export default (MixinBase) => class extends MixinBase {
sendToCurrentTab(message) {
this.currentTab().channel.postMessage(message);
if (this.currentTab().channel === undefined) {
this.log(`Attempting to send "${message}" to tab without a channel`);
} else {
this.currentTab().channel.postMessage(message);
}
}
sendToTerminal(message) {
@ -14,14 +17,6 @@ export default (MixinBase) => class extends MixinBase {
}
}
sendState() {
let state = _.mapValues(this.state, (v) => { return v.toString() });
state.id = this.currentTab().id;
state.title = this.currentTab().title;
state.uri = this.currentTab().url;
this.sendToTerminal(`/tab_state,${JSON.stringify(state)}`);
}
log(...messages) {
if (messages.length === 1) {
messages = messages[0].toString();

View file

@ -0,0 +1,64 @@
import _ from 'lodash';
import utils from 'utils';
import CommonMixin from 'background/common_mixin';
export default class extends utils.mixins(CommonMixin) {
constructor() {
super();
this.tty = {};
this.char = {};
}
setCharValues(incoming) {
if (this.char.width != incoming.width ||
this.char.height != incoming.height) {
this.char = _.clone(incoming);
this.resizeBrowserWindow();
}
}
resizeBrowserWindow() {
if (!this.tty.width || !this.char.width || !this.tty.height || !this.char.height) {
this.log(
'Not resizing browser window without all of the TTY and character dimensions'
);
return;
}
// Does this include scrollbars???
const window_width = parseInt(Math.round(this.tty.width * this.char.width));
// Leave room for tabs and URL bar
const tty_dom_height = this.tty.height - 2;
// I don't know why we have to add 4 more lines to the window height?? But without
// it text doesn't fill the bottom of the TTY.
const window_height = parseInt(Math.round(
(tty_dom_height + 4) * this.char.height
));
const current_window = browser.windows.getCurrent();
current_window.then(
active_window => {
this._sendWindowResizeRequest(active_window, window_width, window_height);
},
error => {
this.log('Error getting current browser window', error);
}
);
}
_sendWindowResizeRequest(active_window, width, height) {
const tag = 'Resizing browser window';
this.log(tag, active_window, width, height);
const updating = browser.windows.update(
active_window.id,
{
width: width,
height: height,
focused: false
}
);
updating.then(
info => this.log(tag, info),
error => this.log(tag, error)
);
}
}

View file

@ -2,64 +2,55 @@ import _ from 'lodash';
import utils from 'utils';
import CommonMixin from 'background/common_mixin';
import TTYCommandsMixin from 'background/tty_commands_mixin';
import TabCommandsMixin from 'background/tab_commands_mixin';
import Tab from 'background/tab';
import Dimensions from 'background/dimensions';
// Boots the background process. Mainly involves connecting to the websocket server
// launched by the Browsh CLI client and setting up listeners for new tabs that
// have our webextension content script inside them.
export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabCommandsMixin) {
export default class extends utils.mixins(CommonMixin, TTYCommandsMixin) {
constructor() {
super();
// All state that needs to be synced with the TTY
this.state = {}
// Dimensions from the tab
this.dimensions = {
dom: {},
char: {},
frame: {}
}
// Keep track of connections to active tabs
this.dimensions = new Dimensions();
// All of the tabs open in the real browser
this.tabs = {};
// The ID of the tab currently opened in the browser
// The ID of the tab currently opened tab
this.active_tab_id = null;
// Keep track of automatic reloads to problematic tabs
this._tab_reloads = [];
// When the real GUI browser first launches it's sized to the same size as the desktop
this._is_initial_window_pending = true;
this._is_initial_window_size_pending = true;
// Used so that reconnections to the terminal don't also attempt to reconnect to the
// browser.
this._is_connected_to_browser = false;
// browser DOM.
this._is_connected_to_browser_dom = false;
// The time in milliseconds between requesting a new TTY-size pixel frame
this._small_pixel_frame_rate = 250;
// The manager is the hub between tabs and the terminal. First we connect to the
// terminal, as that is the process that would have initially booted the browser and
// this very code that now runs.
this._connectToTerminal();
}
getTab(tab_id) {
return this.tabs[tab_id.toString()];
}
reloadTab(id) {
const reloading = browser.tabs.reload(id);
reloading.then(() => {}, (error) => this.log(error));
}
_connectToTerminal() {
// This is the websocket server run by the CLI client
this.terminal = new WebSocket('ws://localhost:3334');
this.terminal.addEventListener('open', (_event) => {
this.log("Webextension connected to the terminal's websocket server");
this.dimensions.terminal = this.terminal;
this._listenForTerminalMessages();
this._connectToBrowser();
this._connectToBrowserDOM();
this._startFrameRequestLoop();
});
this.terminal.addEventListener('close', (_event) => {
this._reconnectToTerminal();
});
}
// TODO: Why is this so slow to reconnect?
// If we've disconnected from the terminal, but we're still running, then that likely
// means the terminal crashed, so we wait to see if the user restarts the terminal.
_reconnectToTerminal() {
try {
this._connectToTerminal();
} catch (_e) {
_.throttle(() => this._reconnectToTerminal(), 100);
_.debounce(() => this._reconnectToTerminal(), 50);
}
}
@ -74,18 +65,25 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
});
}
_connectToBrowser() {
if (this._is_connected_to_browser) {
this.sendState();
this.sendToCurrentTab('/rebuild_text');
return;
_connectToBrowserDOM() {
if (!this._is_connected_to_browser_dom) {
this._initialDOMConnection()
} else {
this._reconnectToDOM();
}
}
_initialDOMConnection() {
this._listenForNewTab();
this._listenForTabUpdates();
this._listenForTabChannelOpen();
this._listenForFocussedTab();
this._startFrameRequestLoop();
this._is_connected_to_browser = true;
}
_reconnectToDOM() {
this.log("Attempting to resend browser state to terminal...");
this.currentTab().sendStateToTerminal();
this.sendToCurrentTab('/rebuild_text');
}
// For when a tab's content script, triggered by `onDOMContentLoaded`,
@ -104,25 +102,55 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
// manually poll :/
_listenForTabUpdates() {
setInterval(() => {
this._pollAllTabs((tab) => {
this._ensureTabConnects(tab);
this._pollAllTabs((native_tab_object) => {
let tab = this._applyUpdates(native_tab_object);
tab.ensureConnectionToBackground();
});
}, 1000);
}, 100);
}
_handleTabUpdate(_tab_id, changes, tab) {
this.log(`Tab ${tab.id} detected chages: ${JSON.stringify(changes)}`);
this._ensureTabConnects(changes.status, tab)
_maybeNewTab(tabish_object) {
const tab_id = parseInt(tabish_object.id);
if (this.tabs[tab_id] === undefined) {
let new_tab = new Tab(tabish_object);
this.tabs[tab_id] = new_tab;
}
return this.tabs[tab_id];
}
_handleTabUpdate(_tab_id, changes, native_tab_object) {
this.log(`Tab ${native_tab_object.id} detected chages: ${JSON.stringify(changes)}`);
let tab = this.tabs[native_tab_object.id];
tab.native_last_change = changes
tab.ensureConnectionToBackground();
}
// Note that although this callback signifies that the tab now exists, it is not fully
// booted and functional until it has opened a communication channel. It can't do that
// until it knows its internally represented ID.
_newTabHandler(_request, sender, sendResponse) {
this.log(`Tab ${sender.tab.id} (${sender.tab.title}) registered with background process`);
if (this._checkForMozillaCliqzTab(sender.tab)) return;
// Send the tab back to itself, such that it can be enlightened unto its own nature
sendResponse(sender.tab);
if (sender.tab.active) {
this.active_tab_id = sender.tab.id;
this._acknowledgeNewTab(sender.tab);
}
_acknowledgeNewTab(native_tab_object) {
let tab = this._applyUpdates(native_tab_object);
tab.postDOMLoadInit(this.terminal, this.dimensions);
}
_applyUpdates(tabish_object) {
let tab = this._maybeNewTab({id: tabish_object.id});
['id', 'title', 'url', 'active'].map(key => {
if (tabish_object.hasOwnProperty(key)){
tab[key] = tabish_object[key]
}
});
if (tabish_object.active) {
this.active_tab_id = tab.id;
}
return tab;
}
// This is the main communication channel for all back and forth messages to tabs
@ -131,14 +159,10 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
}
_tabChannelOpenHandler(channel) {
// TODO: Can we not just assume that channel.name is the same as this.active_tab_id?
this.log(`Tab ${channel.name} connected for communication with background process`);
this.tabs[channel.name] = {
id: parseInt(channel.name),
channel: channel
};
channel.postMessage(`/tty_size,${this.tty_width},${this.tty_height}`);
channel.onMessage.addListener(this.handleTabMessage.bind(this));
let tab = this.tabs[parseInt(channel.name)];
tab.postConnectionInit(channel);
this._is_connected_to_browser_dom = true;
}
_listenForFocussedTab() {
@ -146,47 +170,10 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
}
_focussedTabHandler(tab) {
this.log(`Tab ${tab.id} received new focus`);
this.active_tab_id = tab.id
}
_isTabConnected(tab_id) {
return typeof this.tabs[tab_id.toString()] !== 'undefined';
}
// For various reasons a tab's content script doesn't always load. Currently
// the known reasons are;
// 1. Pages without content, such as direct links to images.
// 2. Native pages such as `about:config`.
// 3. Unknown buggy behaviour such as on Travis :/
// So here we attempt some workarounds.
_ensureTabConnects(tab) {
if (!this._isTabReloadOkay(tab.id)) {
return;
}
if (tab.status === 'complete' && !this._isTabConnected(tab.id)) {
this.log(
`Automatically reloading tab ${tab.id} that has loaded but not connected ` +
'to the webextension'
);
this.reloadTab(tab.id);
this._trackTabReloads(tab.id);
}
}
_isTabReloadOkay(tab_id) {
const count = this._tab_reloads[tab_id.toString()];
if(typeof count === 'undefined') return true;
return count <= 3;
}
_trackTabReloads(tab_id) {
if(typeof this._tab_reloads[tab_id.toString()] === 'undefined') {
this._tab_reloads[tab_id.toString()] = 1;
} else {
this._tab_reloads[tab_id.toString()] += 1;
}
}
_getTabsOnSuccess(windowInfoArray, callback) {
for (let windowInfo of windowInfoArray) {
windowInfo.tabs.map((tab) => {
@ -210,52 +197,48 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin, TabComm
);
}
// On the very first startup of Firefox on a new profile it loads a tab disclaiming
// its data collection to a third-party. Sometimes this tab loads first, sometimes
// it loads second. Especially for testing we always need to load the tab we requested
// first. So let's just close that tab.
// TODO: Only do this for a testing ENV?
_checkForMozillaCliqzTab(tab) {
if (
tab.title.includes('Firefox by default shares data to:') ||
tab.title.includes('Firefox Privacy Notice')
) {
this.log("Removing Firefox startup page")
const removing = browser.tabs.remove(tab.id);
removing.then(() => {}, (error) => this.log(error));
return true;
}
return false;
}
// The browser window can only be resized once we have both the character dimensions from
// the browser tab _and the TTY dimensions from the terminal. There's probably a more
// efficient way of triggering this initial window resize, than just waiting for the data
// on every frame tick.
_initialWindowResize() {
if (!this._is_initial_window_pending) return;
if(this.char_width && this.char_height && this.tty_width && this.tty_height) {
this.resizeBrowserWindow();
this._is_initial_window_pending = false;
}
if (!this._is_initial_window_size_pending) return;
this.dimensions.resizeBrowserWindow();
this._is_initial_window_size_pending = false;
}
// Instead of having each tab manage its own frame rate, just keep this single, centralised
// heartbeat in the background process that switches automatically to the current active
// tab.
//
// Note that by "frame rate" here we justs mean the rate at which a TTY-sized frame of
// graphics pixles are sent. Larger frames are sent in response to scroll events and
// TTY-sized text frames are sent in response to DOM mutation events.
_startFrameRequestLoop() {
this.log('BACKGROUND: Frame loop starting')
setInterval(() => {
if (!this.tty_width || !this.tty_height) {
this.log("Not sending frame to TTY without TTY size")
return;
if (this._is_initial_window_size_pending) this._initialWindowResize();
if (this._isAbleToRequestFrame()) {
this.sendToCurrentTab('/request_frame');
}
if (this._is_initial_window_pending) this._initialWindowResize();
if (!this.tabs.hasOwnProperty(this.active_tab_id)) {
this.log("No active tab, so not requesting a frame");
return;
}
this.sendToCurrentTab('/request_frame');
}, 250);
}, this._small_pixel_frame_rate);
}
_isAbleToRequestFrame() {
if (!this.dimensions.tty.width || !this.dimensions.tty.height) {
this.log("Not sending frame to TTY without TTY size")
return false;
}
if (!this.tabs.hasOwnProperty(this.active_tab_id)) {
this.log("No active tab, so not requesting a frame");
return false;
}
if (this.currentTab().channel === undefined) {
this.log(
`Active tab ${this.active_tab_id} does not have a channel, so not requesting a frame`
);
return false;
}
return true;
}
}

View file

@ -0,0 +1,131 @@
import utils from 'utils';
import CommonMixin from 'background/common_mixin';
import TabCommandsMixin from 'background/tab_commands_mixin';
export default class extends utils.mixins(CommonMixin, TabCommandsMixin) {
constructor() {
super();
// Keep track of automatic reloads to problematic tabs
this._tab_reloads = 0;
// The maximum amount of times to try to recover a tab that won't connect
this._max_number_of_tab_recovery_reloads = 3;
}
postDOMLoadInit(terminal, dimensions) {
this.terminal = terminal;
this.dimensions = dimensions;
this._closeUnwantedStartupTabs();
}
postConnectionInit(channel) {
this.channel = channel;
this._sendTTYDimensions();
this._listenForMessages();
}
isConnected() {
return this.channel !== undefined;
}
reload() {
const reloading = browser.tabs.reload(this.id);
reloading.then(() => {}, error => this.log(error));
}
remove() {
const removing = browser.tabs.remove(this.id);
removing.then(() => {}, (error) => this.log(error));
}
updateStatus(status, message = '') {
let status_message;
switch (status) {
case 'page_init':
status_message = `Loading ${this.url}`;
break;
case 'parsing_complete':
status_message = '';
break;
case 'window_unload':
status_message = 'Loading...';
break;
default:
if (message != '') status_message = message;
}
this.page_state = status;
this.status_message = status_message;
this.sendStateToTerminal();
}
getStateObject() {
return {
id: this.id,
active: this.active,
removed: this.removed,
title: this.title,
uri: this.url,
page_state: this.page_state,
status_message: this.status_message
};
}
sendStateToTerminal() {
this.sendToTerminal(`/tab_state,${JSON.stringify(this.getStateObject())}`);
}
// For various reasons a tab's content script doesn't always load. Currently
// the known reasons are;
// 1. Pages without content, such as direct links to images.
// 2. Native pages such as `about:config`.
// 3. Unknown buggy behaviour such as on Travis :/
// So here we attempt some workarounds.
ensureConnectionToBackground() {
let native_status;
if (!this._isItOKToRetryReload()) {
return;
}
if (this.native_last_change) {
native_status = this.native_last_change.status;
}
if (native_status === 'complete' && !this._isConnected()) {
this.log(
`Automatically reloading tab ${this.id} that has loaded but not connected ` +
'to the webextension'
);
this.reload();
this._reload_count++;
}
}
_listenForMessages() {
this.channel.onMessage.addListener(this.handleTabMessage.bind(this));
}
_sendTTYDimensions() {
this.channel.postMessage(
`/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}`
);
}
_isItOKToRetryReload() {
return this._reload_count <= this._max_number_of_tab_recovery_reloads;
}
// On the very first startup of Firefox on a new profile it loads a tab disclaiming
// its data collection to a third-party. Sometimes this tab loads first, sometimes
// it loads second. Especially for testing we always need to load the tab we requested
// first. So let's just close that tab.
// TODO: Only do this for a testing ENV?
_closeUnwantedStartupTabs() {
if (
this.title.includes('Firefox by default shares data to:') ||
this.title.includes('Firefox Privacy Notice')
) {
this.log("Removing Firefox startup page")
this.remove();
return true;
}
return false;
}
}

View file

@ -1,8 +1,10 @@
import utils from 'utils';
// Handle commands from tabs, like sending a frame or information about
// the current character dimensions .
// the current character dimensions.
export default (MixinBase) => class extends MixinBase {
// TODO: There needs to be some consistency in this message sending protocol.
// Eg; always requiring JSON.
handleTabMessage(message) {
let incoming;
const parts = message.split(',');
@ -16,18 +18,14 @@ export default (MixinBase) => class extends MixinBase {
break;
case '/tab_info':
incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts));
this.currentTab().title = incoming.title
this.currentTab().url = incoming.url
this.sendState();
this._updateTabInfo(incoming);
break;
case '/dimensions':
incoming = JSON.parse(message.slice(12));
this._mightResizeWindow(incoming);
this.dimensions = incoming;
this.dimensions.setCharValues(incoming.char);
break;
case '/status':
this.updateStatus(parts[1]);
this.sendState();
this.updateStatus(parts[1], parts[2]);
break;
case '/log':
this.log(message.slice(5));
@ -37,31 +35,9 @@ export default (MixinBase) => class extends MixinBase {
}
}
_mightResizeWindow(incoming) {
if (this.dimensions.char.width != incoming.char.width ||
this.dimensions.char.height != incoming.char.height) {
this.dimensions = incoming;
this.resizeBrowserWindow();
}
}
updateStatus(status, message = '') {
let status_message;
switch (status) {
case 'page_init':
status_message = `Loading ${this.currentTab().url}`;
break;
case 'parsing_complete':
status_message = '';
break;
case 'window_unload':
status_message = 'Loading...';
break;
default:
if (message != '') status_message = message;
}
this.state['page_state'] = status;
this.state['status_message'] = status_message;
this.sendState();
_updateTabInfo(incoming) {
this.title = incoming.title;
this.url = incoming.url;
this.sendStateToTerminal();
}
};

View file

@ -2,10 +2,6 @@ import utils from 'utils';
// Handle commands coming in from the terminal, like; STDIN keystrokes, TTY resize, etc
export default (MixinBase) => class extends MixinBase {
constructor() {
super();
}
handleTerminalMessage(message) {
const parts = message.split(',');
const command = parts[0];
@ -14,29 +10,46 @@ export default (MixinBase) => class extends MixinBase {
this.sendToCurrentTab(message.slice(13));
break;
case '/tty_size':
this.tty_width = parseInt(parts[1]);
this.tty_height = parseInt(parts[2]);
this.dimensions.tty.width = parseInt(parts[1]);
this.dimensions.tty.height = parseInt(parts[2]);
if (this.currentTab()) {
this.sendToCurrentTab(`/tty_size,${this.tty_width},${this.tty_height}`)
this.sendToCurrentTab(
`/tty_size,${this.dimensions.tty.width},${this.dimensions.tty.height}`
)
}
this.resizeBrowserWindow();
this.dimensions.resizeBrowserWindow();
break;
case '/stdin':
this._handleUICommand(parts);
this.sendToCurrentTab(message);
break;
case '/url_bar':
// TODO: move to CLI client
this._handleURLBarInput(parts.slice(1).join(','));
break;
case '/new_tab':
this.createNewTab(parts.slice(1).join(','));
break;
case '/switch_to_tab':
this.switchToTab(parts.slice(1).join(','));
break;
case '/remove_tab':
this.removeTab(parts.slice(1).join(','));
break;
}
}
_handleUICommand(parts) {
const input = JSON.parse(utils.rebuildArgsToSingleArg(parts));
// CTRL mappings
if (input.mod === 2) {
switch(input.char) {
default:
}
}
// ALT mappings
if (input.mod === 4) {
switch(input.char) {
case 'P':
case 'p':
this.screenshotActiveTab();
break;
}
@ -49,6 +62,7 @@ export default (MixinBase) => class extends MixinBase {
this.sendToCurrentTab(`/url,${final_url}`);
}
// TODO: move to CLI client
_getURLfromUserInput(input) {
let url;
const search_engine = 'https://www.google.com/search?q=';
@ -68,50 +82,44 @@ export default (MixinBase) => class extends MixinBase {
return url;
}
resizeBrowserWindow() {
if (!this.tty_width || !this.dimensions.char.width || !this.tty_height || !this.dimensions.char.height) {
this.log(
'Not resizing browser window without all of the TTY and character dimensions'
);
return;
}
// Does this include scrollbars???
const window_width = parseInt(Math.round(this.tty_width * this.dimensions.char.width));
// Leave room for tabs and URL bar
const tty_dom_height = this.tty_height - 2;
// I don't know why we have to add 4 more lines to the window height?? But without
// it text doesn't fill the bottom of the TTY.
const window_height = parseInt(Math.round(
(tty_dom_height + 4) * this.dimensions.char.height
));
const current_window = browser.windows.getCurrent();
current_window.then(
(active_window) => {
this._sendWindowResizeRequest(active_window, window_width, window_height);
createNewTab(url) {
const final_url = this._getURLfromUserInput(url);
let creating = browser.tabs.create({
url: final_url
});
creating.then(
tab => {
this.log(`New tab created: ${tab}`);
},
(error) => {
this.log('Error getting current browser window', error);
error => {
this.log(`Error creating new tab: ${error}`);
}
);
}
_sendWindowResizeRequest(active_window, width, height) {
const tag = 'Resizing browser window';
this.log(tag, active_window, width, height);
const updating = browser.windows.update(
active_window.id,
{
width: width,
height: height,
focused: false
switchToTab(id) {
let updating = browser.tabs.update(parseInt(id), {
active: true
});
updating.then(
tab => {
this.log(`Switched to tab: ${tab.id}`);
},
error => {
this.log(`Error switching to tab: ${error}`);
}
);
updating.then(
(info) => {
this.log(tag, info);
}
removeTab(id) {
this.tabs[id] = null;
let removing = browser.tabs.remove(parseInt(id));
removing.then(
() => {
this.log(`Removed tab: ${id}`);
},
(error) => {
this.log(tag, error);
error => {
this.log(`Error removing tab: ${error}`);
}
);
}
@ -120,10 +128,10 @@ export default (MixinBase) => class extends MixinBase {
// because the content script may have crashed, even never loaded.
screenshotActiveTab() {
const capturing = browser.tabs.captureVisibleTab({ format: 'jpeg' });
capturing.then(this.saveScreenshot.bind(this), error => this.log(error));
capturing.then(this._saveScreenshot.bind(this), error => this.log(error));
}
saveScreenshot(imageUri) {
_saveScreenshot(imageUri) {
const data = imageUri.replace(/^data:image\/\w+;base64,/, "");
this.sendToTerminal('/screenshot,' + data);
}