├── .npmignore
├── src
├── app
│ ├── models
│ │ ├── general
│ │ │ ├── effects.js
│ │ │ ├── index.js
│ │ │ ├── state.js
│ │ │ └── reducers.js
│ │ ├── domains
│ │ │ ├── state.js
│ │ │ ├── index.js
│ │ │ ├── reducers.js
│ │ │ └── effects.js
│ │ ├── likes
│ │ │ ├── state.js
│ │ │ ├── index.js
│ │ │ ├── reducers.js
│ │ │ └── effects.js
│ │ ├── tabs
│ │ │ ├── state.js
│ │ │ ├── index.js
│ │ │ ├── tab.js
│ │ │ ├── reducers.js
│ │ │ └── effects.js
│ │ ├── find-in-page
│ │ │ ├── state.js
│ │ │ ├── index.js
│ │ │ ├── reducers.js
│ │ │ └── effects.js
│ │ └── search
│ │ │ ├── state.js
│ │ │ ├── index.js
│ │ │ ├── reducers.js
│ │ │ └── effects.js
│ ├── views
│ │ ├── search-results
│ │ │ ├── row
│ │ │ │ ├── popular.js
│ │ │ │ ├── on-select.js
│ │ │ │ ├── close-button.js
│ │ │ │ ├── open-in-new-tab-button.js
│ │ │ │ ├── index.js
│ │ │ │ ├── tab.js
│ │ │ │ ├── separator.js
│ │ │ │ ├── like.js
│ │ │ │ ├── history.js
│ │ │ │ └── search-query.js
│ │ │ ├── index.js
│ │ │ ├── add-separators.js
│ │ │ └── style.css
│ │ ├── find-in-page
│ │ │ ├── index.js
│ │ │ ├── style.css
│ │ │ ├── buttons.js
│ │ │ └── bar.js
│ │ ├── webviews
│ │ │ ├── new-tab.js
│ │ │ ├── content.js
│ │ │ ├── index.js
│ │ │ ├── style.css
│ │ │ ├── error.js
│ │ │ └── webview.js
│ │ ├── title-bar
│ │ │ ├── private-mode-icon.js
│ │ │ ├── create-tab.js
│ │ │ ├── like-button.js
│ │ │ ├── spinner.css
│ │ │ ├── page-buttons.js
│ │ │ ├── page-icon.js
│ │ │ ├── index.js
│ │ │ └── style.css
│ │ ├── preview
│ │ │ ├── index.js
│ │ │ ├── search-query
│ │ │ │ └── index.js
│ │ │ ├── url
│ │ │ │ ├── button.js
│ │ │ │ ├── index.js
│ │ │ │ └── buttons.js
│ │ │ └── style.css
│ │ ├── top-bar
│ │ │ ├── index.js
│ │ │ ├── surfing-bar.js
│ │ │ ├── style.css
│ │ │ ├── movement-buttons.js
│ │ │ └── search-bar.js
│ │ ├── is-button.js
│ │ ├── style.css
│ │ ├── fonts.css
│ │ └── main.js
│ ├── db
│ │ ├── db.js
│ │ ├── index.js
│ │ ├── embed.js
│ │ ├── likes.js
│ │ ├── domains.js
│ │ ├── history.js
│ │ ├── tabs.js
│ │ └── meta.js
│ ├── pretty-url.js
│ ├── input.js
│ ├── list-of-tabs.js
│ ├── search
│ │ ├── filters.js
│ │ ├── popular.js
│ │ ├── index.js
│ │ ├── maps.js
│ │ ├── sort.js
│ │ └── recent.js
│ ├── index.js
│ ├── urls.js
│ └── partition.js
├── os-menu
│ ├── window.js
│ ├── help.js
│ ├── osx.js
│ ├── index.js
│ ├── edit.js
│ ├── about.js
│ ├── view.js
│ └── file.js
├── index.html
├── recommended.json
└── window-manager.js
├── icon.png
├── kaktus.icns
├── fonts
├── FontAwesome.otf
├── fontawesome-webfont.eot
├── fontawesome-webfont.ttf
├── fontawesome-webfont.woff
└── fontawesome-webfont.woff2
├── .gitignore
├── main.js
├── package.json
├── Makefile
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | test
2 | test.js
3 | example
4 | examples
5 |
--------------------------------------------------------------------------------
/src/app/models/general/effects.js:
--------------------------------------------------------------------------------
1 | module.exports = {}
2 |
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/icon.png
--------------------------------------------------------------------------------
/kaktus.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/kaktus.icns
--------------------------------------------------------------------------------
/src/app/views/search-results/row/popular.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./like')
2 |
--------------------------------------------------------------------------------
/fonts/FontAwesome.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/fonts/FontAwesome.otf
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .DS_Store
4 | build/*.js
5 | build/*.css
6 | dist
7 |
--------------------------------------------------------------------------------
/src/app/db/db.js:
--------------------------------------------------------------------------------
1 | module.exports = require("../../../indexeddb")('kaktus', {
2 | version: 4
3 | })
4 |
--------------------------------------------------------------------------------
/fonts/fontawesome-webfont.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/fonts/fontawesome-webfont.eot
--------------------------------------------------------------------------------
/fonts/fontawesome-webfont.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/fonts/fontawesome-webfont.ttf
--------------------------------------------------------------------------------
/fonts/fontawesome-webfont.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/fonts/fontawesome-webfont.woff
--------------------------------------------------------------------------------
/src/app/models/domains/state.js:
--------------------------------------------------------------------------------
1 | module.exports = get()
2 |
3 | function get () {
4 | return {}
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/models/likes/state.js:
--------------------------------------------------------------------------------
1 | module.exports = get()
2 |
3 | function get () {
4 | return {}
5 | }
6 |
--------------------------------------------------------------------------------
/fonts/fontawesome-webfont.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/azer/kaktus/HEAD/fonts/fontawesome-webfont.woff2
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | const WindowManager = require("./src/window-manager")
2 | const { app } = require("electron")
3 |
4 | new WindowManager(app)
5 |
--------------------------------------------------------------------------------
/src/app/models/tabs/state.js:
--------------------------------------------------------------------------------
1 | const Tab = require("./tab")
2 |
3 | module.exports = get();
4 |
5 | function get (callback) {
6 | return {}
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/models/find-in-page/state.js:
--------------------------------------------------------------------------------
1 | module.exports = get()
2 |
3 | function get () {
4 | return {
5 | enabled: false,
6 | query: ''
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/views/find-in-page/index.js:
--------------------------------------------------------------------------------
1 | const bar = require("./bar")
2 | const buttons = require("./buttons")
3 |
4 | module.exports = {
5 | bar,
6 | buttons
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/views/webviews/new-tab.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const newTab = (state, prev, send) => html`
4 |
5 |
6 | `
7 |
8 | module.exports = newTab
9 |
--------------------------------------------------------------------------------
/src/app/models/search/state.js:
--------------------------------------------------------------------------------
1 | module.exports = get();
2 |
3 | function get () {
4 | return {
5 | isOpen: false,
6 | preview: null,
7 | query: '',
8 | results: []
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/domains/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const effects = require("./effects")
3 | const reducers = require("./reducers")
4 |
5 | module.exports = {
6 | namespace: 'domains',
7 | state,
8 | reducers,
9 | effects
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/general/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const effects = require("./effects")
3 | const reducers = require("./reducers")
4 |
5 | module.exports = {
6 | namespace: 'general',
7 | state,
8 | reducers,
9 | effects
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/likes/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const effects = require("./effects")
3 | const reducers = require("./reducers")
4 |
5 | module.exports = {
6 | namespace: 'likes',
7 | state,
8 | reducers,
9 | effects
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/search/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const effects = require("./effects")
3 | const reducers = require("./reducers")
4 |
5 | module.exports = {
6 | namespace: 'search',
7 | state,
8 | reducers,
9 | effects
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/find-in-page/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const effects = require("./effects")
3 | const reducers = require("./reducers")
4 |
5 | module.exports = {
6 | namespace: 'findInPage',
7 | state,
8 | reducers,
9 | effects
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/models/general/state.js:
--------------------------------------------------------------------------------
1 | const partition = require("../../partition")
2 |
3 | module.exports = get()
4 |
5 | function get () {
6 | return {
7 | focusMode: false,
8 | privateMode: false,
9 | partitionName: partition.window()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/app/models/find-in-page/reducers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setEnabled,
3 | setQuery
4 | }
5 |
6 | function setEnabled (value) {
7 | return {
8 | enabled: value
9 | }
10 | }
11 |
12 | function setQuery (value) {
13 | return {
14 | query: value
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/pretty-url.js:
--------------------------------------------------------------------------------
1 | module.exports = prettyURL;
2 |
3 | function prettyURL (url) {
4 | return url.replace(/^\w+:\/\//, '')
5 | .replace(/^www\./, '')
6 | .replace(/\?.*$/, '')
7 | .replace(/\#.*$/, '')
8 | .replace(/\/$/, '')
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/private-mode-icon.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const privateModeIcon = (state, prev, send) => html`
4 |
5 |
6 |
7 | `
8 |
9 | module.exports = privateModeIcon
10 |
--------------------------------------------------------------------------------
/src/app/db/index.js:
--------------------------------------------------------------------------------
1 | const tabs = require("./tabs")
2 | const history = require("./history")
3 | const likes = require("./likes")
4 | const meta = require("./meta")
5 | const embed = require("./embed")
6 | const domains = require("./domains")
7 |
8 | module.exports = {
9 | embed,
10 | tabs,
11 | history,
12 | likes,
13 | meta,
14 | domains
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/on-select.js:
--------------------------------------------------------------------------------
1 | const isButton = require("../../is-button")
2 |
3 | module.exports = onSelect
4 |
5 | function onSelect (callback) {
6 | return function (row, prev, send) {
7 | return function (event) {
8 | if (isButton(event.target)) {
9 | return
10 | }
11 |
12 | send('search:quit')
13 | callback(row, prev, send)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/models/general/reducers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setPrivateMode,
3 | setPartitionName,
4 | setFocusMode
5 | }
6 |
7 | function setPrivateMode (value) {
8 | return {
9 | privateMode: value
10 | }
11 | }
12 |
13 | function setPartitionName (name) {
14 | return {
15 | partitionName: name
16 | }
17 | }
18 |
19 | function setFocusMode (value) {
20 | return {
21 | focusMode: value
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/os-menu/window.js:
--------------------------------------------------------------------------------
1 | module.exports = window
2 |
3 | function window () {
4 | return {
5 | label: 'Window',
6 | role: 'window',
7 | submenu: [
8 | {
9 | label: 'Minimize',
10 | accelerator: 'CmdOrCtrl+M',
11 | role: 'minimize'
12 | },
13 | {
14 | label: 'Close',
15 | accelerator: 'CmdOrCtrl+W',
16 | role: 'close'
17 | }
18 | ]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/views/preview/index.js:
--------------------------------------------------------------------------------
1 | const url = require("./url")
2 | const searchQuery = require("./search-query")
3 |
4 | module.exports = show
5 |
6 | function show (state, prev, send) {
7 | const view = pick(state)
8 | if (view) return view(state, prev, send)
9 | }
10 |
11 | function pick (state, prev, send) {
12 | if (!state.search.preview) return
13 | if (state.search.preview.search) return searchQuery
14 | return url
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/create-tab.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const button = (state, prev, send) => html`
4 |
5 |
6 |
7 | `
8 |
9 | module.exports = button
10 |
11 | function createTab (state, prev, send) {
12 | return function () {
13 | send('tabs:newTab')
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/close-button.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const closeButton = (tab, prev, send) => html`
4 |
7 | ✕
8 |
9 | `
10 |
11 | module.exports = closeButton
12 |
13 | function close (tab, prev, send) {
14 | return function () {
15 | send('tabs:close', tab.id)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/views/top-bar/index.js:
--------------------------------------------------------------------------------
1 | const tabs = require("../../models/tabs")
2 | const search = require('./search-bar')
3 | const surfing = require('./surfing-bar')
4 |
5 | module.exports = topbar
6 |
7 | function topbar (state, prev, send) {
8 | let view = surfing
9 | const selected = state.tabs[state.tabs.selectedId]
10 |
11 | if (state.search.isOpen) {
12 | view = search
13 | }
14 |
15 | return view(state, prev, send)
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/input.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | get,
3 | select,
4 | focus
5 | }
6 |
7 | function select (key) {
8 | get(key, el => el.select())
9 | }
10 |
11 | function focus (key) {
12 | get(key, el => el.focus())
13 | }
14 |
15 | function get (key, callback) {
16 | setTimeout(function tryAgain () {
17 | var input = document.querySelector(`input.${key}`)
18 | if (!input) return setTimeout(tryAgain, 5)
19 | callback(input)
20 | }, 5)
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/models/tabs/index.js:
--------------------------------------------------------------------------------
1 | const state = require("./state")
2 | const reducers = require("./reducers")
3 | const effects = require("./effects")
4 | const listOfTabs = require("../../list-of-tabs")
5 |
6 | module.exports = {
7 | namespace: 'tabs',
8 | state,
9 | reducers,
10 | effects,
11 | searchRows
12 | }
13 |
14 | function searchRows (state) {
15 | return listOfTabs(state).map(tab => {
16 | return {
17 | tab
18 | }
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/views/is-button.js:
--------------------------------------------------------------------------------
1 | const BUTTON_CLASSES = [
2 | 'button',
3 | 'page-button',
4 | 'like-button',
5 | 'create-tab-button',
6 | 'row-button'
7 | ]
8 |
9 | module.exports = isButton
10 |
11 | function isButton (el) {
12 | let i = BUTTON_CLASSES.length
13 | while (i--) {
14 | if (el.classList.contains(BUTTON_CLASSES[i]) || el.parentNode.classList.contains(BUTTON_CLASSES[i])) {
15 | return true
16 | }
17 | }
18 |
19 | return false
20 | }
21 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Kaktüs
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/app/views/find-in-page/style.css:
--------------------------------------------------------------------------------
1 | .title-bar.find-in-page .input-caption {
2 | position: absolute;
3 | color: #888;
4 | font: 500 16px "San Francisco", Helvetica;
5 | margin: 0 0 0 25px;
6 | }
7 |
8 | .title-bar.find-in-page .query {
9 | width: calc(100% - 117px);
10 | padding-left: 117px;
11 | }
12 |
13 | .title-bar .quit-find-in-page-button {
14 | font-size: 18px;
15 | color: #888;
16 | top: 5px;
17 | }
18 |
19 | .title-bar .quit-find-in-page-button:hover {
20 | color: #666;
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/list-of-tabs.js:
--------------------------------------------------------------------------------
1 | module.exports = listOfTabs
2 | module.exports.allOpenTabs = allOpenTabs
3 |
4 | function listOfTabs (state, includeClosedRows) {
5 | state = state.tabs || state
6 | let result = []
7 |
8 | for (let key in state) {
9 | if (/^f-/.test(key) && (state[key] || includeClosedRows)) {
10 | result.push(state[key])
11 | }
12 | }
13 |
14 | return result
15 | }
16 |
17 | function allOpenTabs (state) {
18 | return listOfTabs(state).filter(isNotNew)
19 | }
20 |
21 | function isNotNew (tab) {
22 | return !tab.isNew
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/models/find-in-page/effects.js:
--------------------------------------------------------------------------------
1 | const input = require("../../input")
2 |
3 | module.exports = {
4 | enable,
5 | disable,
6 | selectInput: input.select.bind(null, 'query')
7 | }
8 |
9 | function enable (payload, state, send, done) {
10 | send('findInPage:setEnabled', true, done)
11 | send('search:quit', done)
12 | send('findInPage:selectInput', done)
13 | }
14 |
15 | function disable (payload, state, send, done) {
16 | send('findInPage:setQuery', '', done)
17 | send('findInPage:setEnabled', false, done)
18 | send('tabs:quitFindInPage', done)
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/models/search/reducers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setPreview,
3 | setQuery,
4 | setResults,
5 | setAsOpen,
6 | setAsClosed
7 | }
8 |
9 | function setPreview (tab) {
10 | return {
11 | preview: tab
12 | }
13 | }
14 |
15 | function setQuery (query) {
16 | return {
17 | query
18 | }
19 | }
20 |
21 | function setAsOpen () {
22 | return {
23 | isOpen: true
24 | }
25 | }
26 |
27 | function setAsClosed () {
28 | return {
29 | isOpen: false
30 | }
31 | }
32 |
33 | function setResults (results) {
34 | return {
35 | results
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/app/search/filters.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | isUnique,
3 | isValidMeta,
4 | isValidRecord,
5 | isNotPrivate
6 | }
7 |
8 | function isValidMeta (row) {
9 | return !!row.record && isNotPrivate(row)
10 | }
11 |
12 | function isValidRecord (row) {
13 | return !!row.meta && isNotPrivate(row)
14 | }
15 |
16 | function isNotPrivate (row) {
17 | return !row.domain || !row.domain.privateMode
18 | }
19 |
20 | function isUnique () {
21 | const dict = {}
22 | return (row) => {
23 | if (!row || !row.url || dict[row.url]) return false
24 | dict[row.url] = true
25 | return true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/open-in-new-tab-button.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const openInNewTabButton = (row, prev, send) => html`
4 |
7 |
8 |
9 | `
10 |
11 | module.exports = openInNewTabButton
12 |
13 | function openInNewTab (row, prev, send) {
14 | return function () {
15 | send('search:quit')
16 | send('tabs:newTab', { url: `${row.record.protocol}://${row.record.url}` })
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/os-menu/help.js:
--------------------------------------------------------------------------------
1 | module.exports = help
2 |
3 | function help () {
4 | return {
5 | label: 'Help',
6 | role: 'help',
7 | submenu: [
8 | {
9 | label: 'Keyboard Shortcuts',
10 | click: function () {
11 | }
12 | },
13 | {
14 | label: 'Report a Bug',
15 | click: function () {
16 | }
17 | },
18 | {
19 | label: 'Take a Tour',
20 | click: function () {
21 | }
22 | },
23 | {
24 | label: 'View on GitHub',
25 | click: function () {
26 | }
27 | }
28 | ]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/views/preview/search-query/index.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const urls = require("../../../urls")
3 |
4 | const preview = (state, prev, send) => html`
5 |
6 |
7 |
8 |
9 |
Search: ${title(state.search.preview.search.query)}
10 |
On Google
11 |
12 | `
13 |
14 | module.exports = preview
15 |
16 | function title (query) {
17 | if (urls.isURL(query)) {
18 | query = urls.clean(query)
19 | }
20 |
21 | return query
22 | }
23 |
--------------------------------------------------------------------------------
/src/os-menu/osx.js:
--------------------------------------------------------------------------------
1 | const about = require("./about")
2 |
3 | module.exports = osx
4 |
5 | function osx (wm, template) {
6 | if (process.platform != 'darwin') {
7 | return template
8 | }
9 |
10 | template.unshift(about(wm))
11 |
12 | template[1].submenu.push({
13 | type: 'separator'
14 | })
15 |
16 | template[1].submenu.push({
17 | label: 'Preferences',
18 | accelerator: 'CmdOrCtrl+,',
19 | click: function (item, window) {
20 | }
21 | })
22 |
23 | template[3].submenu.push({
24 | type: 'separator'
25 | }, {
26 | label: 'Bring All to Front',
27 | role: 'front'
28 | })
29 |
30 | return template
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/models/likes/reducers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | recover,
3 | recoverFromSearch,
4 | set
5 | }
6 |
7 | function set (payload) {
8 | const update = {}
9 | update[payload.url] = !!payload.value
10 | return update
11 | }
12 |
13 | function recover (rows) {
14 | const state = {}
15 |
16 | for (let row of rows) {
17 | if (row.like) {
18 | state[row.url] = true
19 | }
20 | }
21 |
22 | return state
23 | }
24 |
25 | function recoverFromSearch (results) {
26 | const updates = {}
27 |
28 | for (let row of results) {
29 | if (!row.like) continue
30 | updates[row.url] = true
31 | }
32 |
33 | return updates
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/views/preview/url/button.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | module.exports = button
4 |
5 | function button (options) {
6 | return (state, prev, send) => html`
7 |
17 | `
18 | }
19 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/index.js:
--------------------------------------------------------------------------------
1 | const tab = require("./tab")
2 | const like = require("./like")
3 | const popular = require("./popular")
4 | const history = require("./history")
5 | const searchQuery = require("./search-query")
6 | const separator = require("./separator")
7 |
8 | module.exports = show
9 |
10 | function show (row, state, prev, send) {
11 | return pick(row)(row, state, prev, send)
12 | }
13 |
14 | function pick (row, state, prev, send) {
15 | if (row.separator) return separator
16 | if (row.tab) return tab
17 | if (row.like) return like
18 | if (row.popular) return popular
19 | if (row.search) return searchQuery
20 | return history
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/views/search-results/index.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const preview = require("../preview")
3 | const row = require("./row")
4 | const addSeparators = require("./add-separators")
5 |
6 | const list = (state, prev, send) => html`
7 |
8 |
9 | ${addSeparators(state.search.results).map(r => row(r, state, prev, send))}
10 |
11 | ${preview(state, prev, send)}
12 |
13 |
14 | `
15 |
16 | module.exports = show
17 |
18 | function show (state, prev, send) {
19 | if (state.search.results.length === 0) return null
20 | return list(state, prev, send)
21 | }
22 |
--------------------------------------------------------------------------------
/src/app/models/domains/reducers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | recover,
3 | recoverFromSearch,
4 | set
5 | }
6 |
7 | function set (payload) {
8 | const update = {}
9 | update[payload.domain] = payload.props
10 | return update
11 | }
12 |
13 | function recover (rows) {
14 | const state = {}
15 |
16 | for (let row of rows) {
17 | if (row.domain) {
18 | state[row.domain.domain] = row.domain
19 | }
20 | }
21 |
22 | return state
23 | }
24 |
25 | function recoverFromSearch (results) {
26 | const updates = {}
27 |
28 | for (let row of results) {
29 | if (!row.domain) continue
30 | updates[row.domain.domain] = row.domain
31 | }
32 |
33 | return updates
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/like-button.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const likeButton = (state, prev, send) => {
4 | const selectedTab = state.tabs[state.tabs.selectedId]
5 | const isLiked = !!state.likes[selectedTab.url]
6 |
7 | return html`
8 |
9 |
10 |
`
11 | }
12 |
13 | module.exports = likeButton
14 |
15 | function toggleLike (payload, prev, send) {
16 | return function () {
17 | send(`likes:${payload.isLiked ? 'un' : ''}like`, payload.url)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/spinner.css:
--------------------------------------------------------------------------------
1 | .spinner {
2 | position: absolute;
3 | width: 22px;
4 | height: 22px;
5 | background-color: #333;
6 |
7 | border-radius: 100%;
8 | -webkit-animation: sk-scaleout 1.0s infinite ease-in-out;
9 | animation: sk-scaleout 1.0s infinite ease-in-out;
10 | }
11 |
12 | @-webkit-keyframes sk-scaleout {
13 | 0% { -webkit-transform: scale(0) }
14 | 100% {
15 | -webkit-transform: scale(1.0);
16 | opacity: 0;
17 | }
18 | }
19 |
20 | @keyframes sk-scaleout {
21 | 0% {
22 | -webkit-transform: scale(0);
23 | transform: scale(0);
24 | } 100% {
25 | -webkit-transform: scale(1.0);
26 | transform: scale(1.0);
27 | opacity: 0;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/views/webviews/content.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const error = require("./error")
3 | const newTab = require("./new-tab")
4 | const webview = require("./webview")
5 |
6 | const content = (tab, state, prev, send) => html`
7 |
8 | ${tab.error ? error(tab.error, state, prev, send) : null}
9 | ${tab.isNew ? newTab(tab, state, prev, send) : webview(tab, state, prev, send)}
10 |
11 | `
12 |
13 | module.exports = content
14 |
15 | function onFocus (state, prev, send) {
16 | return function () {
17 | if (state.isNew) return
18 | send('search:quit')
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/views/find-in-page/buttons.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const buttons = (state, prev, send) => html`
4 |
5 | ${button('Next', state.findInPage.query.trim().length, 'chevron-down', () => send('tabs:findNextInPage', { query: state.findInPage.query }))}
6 | ${button('Previous', state.findInPage.query.trim().length, 'chevron-up', () => send('tabs:findPreviousInPage', { query: state.findInPage.query }))}
7 |
8 | `
9 |
10 | const button = (title, isActive, cls, onclick) => html`
11 |
12 |
13 |
14 | `
15 |
16 | module.exports = buttons
17 |
--------------------------------------------------------------------------------
/src/app/search/popular.js:
--------------------------------------------------------------------------------
1 | const embed = require("../db/embed")
2 | const filters = require("./filters")
3 | const maps = require("./maps")
4 | const sort = require("./sort")
5 |
6 | const tabs = require("../db/tabs")
7 | const history = require("../db/history")
8 | const likes = require("../db/likes")
9 | const domains = require("../db/domains")
10 | const meta = require("../db/meta")
11 |
12 | module.exports = popular
13 |
14 | function popular (callback) {
15 | embed(history.popular, [{ limit: 50 }], [likes, meta, tabs, domains], function (error, rows) {
16 | if (error) return callback(error)
17 |
18 | callback(undefined, rows.filter(filters.isUnique()).filter(filters.isValidRecord).slice(0, 10).map(maps.popularRecord))
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/views/style.css:
--------------------------------------------------------------------------------
1 | html, body, main, .main, .main-loading {
2 | width: 100%;
3 | height: 100%;
4 | padding: 0;
5 | margin: 0;
6 | text-rendering: optimizeLegibility !important;
7 | -webkit-font-smoothing: antialiased !important;
8 | }
9 |
10 | main, .main {
11 | width: 100%;
12 | height: 100%;
13 | }
14 |
15 | .private-mode-window-icon {
16 | position: absolute;
17 | top: 0;
18 | left: 85px;
19 | background: rgba(173, 58, 173, 0.8);
20 | color: #fff;
21 | padding: 5px 8px;
22 | z-index: 9999999;
23 | font-size: 21px;
24 | }
25 |
26 | .main-loading {
27 | font: 400 16px "San Francisco", Helvetica;
28 | text-align: center;
29 | padding-top: 25%;
30 | height: 20px;
31 | position: absolute;
32 | top: 0;
33 | left: 0;
34 | letter-spacing: 1px;
35 | }
36 |
37 | .clear {
38 | clear:both;
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/page-buttons.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const reloadButton = (state, prev, send) => html`
4 |
5 | ⟳
6 |
7 | `
8 |
9 | const stopButton = (state, prev, send) => html`
10 |
11 | ✕
12 |
13 | `
14 |
15 | const either = (state, prev, send) => html`
16 | ${state.isLoading ? stopButton(state, prev, send) : reloadButton(state, prev, send)}
17 | `
18 |
19 | module.exports = either
20 |
21 | function reload (tab, send) {
22 | return function () {
23 | send('tabs:reload', {
24 | tab
25 | })
26 | }
27 | }
28 |
29 | function stop (tab, send) {
30 | return function () {
31 | send('tabs:stop', {
32 | tab
33 | })
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/index.js:
--------------------------------------------------------------------------------
1 | const choo = require("choo")
2 | const html = require("choo/html")
3 | const general = require("./models/general")
4 | const tabs = require("./models/tabs")
5 | const search = require("./models/search")
6 | const findInPage = require("./models/find-in-page")
7 | const likes = require("./models/likes")
8 | const domains = require("./models/domains")
9 |
10 | createMainApp((error, app) => {
11 | if (error) throw error
12 | document.body.appendChild(app.start())
13 | })
14 |
15 | function createMainApp (callback) {
16 | const app = choo()
17 |
18 | app.model(tabs)
19 | app.model(search)
20 | app.model(general)
21 | app.model(findInPage)
22 | app.model(likes)
23 | app.model(domains)
24 |
25 | app.router((route) => [
26 | route('/', require('./views/main'))
27 | ])
28 |
29 | callback(undefined, app)
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/views/webviews/index.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const listOfTabs = require("../../list-of-tabs")
3 | const content = require("./content")
4 | const urls = require("../../urls")
5 |
6 | const webviews = (state, prev, send) => html`
7 |
8 | ${listOfTabs(state, true).map(f => {
9 | if (!f) return html`
`
10 | f.isSelected = f.id === state.tabs.selectedId
11 | f.partitionName = isPrivateModeEnabled(state) ? 'kaktus-private' : state.general.partitionName
12 | return content(f, state, prev, send)
13 | })}
14 |
15 | `
16 |
17 | module.exports = webviews
18 |
19 | function isPrivateModeEnabled (state, prev, send) {
20 | const domain = state.domains[urls.domain(state.tabs[state.tabs.selectedId].url)]
21 | return domain && domain.privateMode
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/search/index.js:
--------------------------------------------------------------------------------
1 | const embed = require("../db/embed")
2 | const anglicize = require("anglicize")
3 | const recent = require("./recent")
4 |
5 | const tabs = require("../db/tabs")
6 | const history = require("../db/history")
7 | const likes = require("../db/likes")
8 | const domains = require("../db/domains")
9 | const meta = require("../db/meta")
10 |
11 | const filters = require("./filters")
12 | const sort = require("./sort")
13 |
14 | module.exports = search
15 |
16 | function search (query, callback) {
17 | if (query.trim().length === 0) {
18 | return recent(callback)
19 | }
20 |
21 | embed(meta.search, [anglicize(query)], [history, likes, tabs, domains], (error, result) => {
22 | if (error) return callback(error)
23 |
24 | callback(undefined, result.filter(filters.isUnique()).filter(filters.isValidMeta).sort(sort).slice(0, 10))
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/src/recommended.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "World Music Radio",
4 | "url": "http://pmr.lt",
5 | "icon": "http://pmr.lt/favicons/favicon-72x72.png"
6 | },
7 | {
8 | "title": "Brain Pickings",
9 | "url": "https://www.brainpickings.org",
10 | "icon": "https://i2.wp.com/www.brainpickings.org/wp-content/uploads/2015/09/cropped-BP_icon.png?fit=32%2C32&ssl=1"
11 | },
12 | {
13 | "title": "It's Nice That",
14 | "url": "http://itsnicethat.com",
15 | "icon": "https://www.itsnicethat.com/favicon-32x32.png"
16 | },
17 | {
18 | "title": "Hacker News",
19 | "url": "http://news.ycombinator.com",
20 | "icon": "https://news.ycombinator.com/favicon.ico"
21 | },
22 | {
23 | "title": "GoodReads",
24 | "url": "http://goodreads.com",
25 | "icon": "http://d.gr-assets.com/misc/1454549143-1454549143_goodreads_misc.png"
26 | }
27 | ]
28 |
--------------------------------------------------------------------------------
/src/app/views/preview/url/index.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const prettyURL = require("../../../pretty-url")
3 | const buttons = require("./buttons")
4 |
5 | const image = (state, prev, send) => html`
6 |
7 |
})
8 |
9 | `
10 | const preview = (state, prev, send) => html`
11 |
12 | ${state.search.preview.image || state.search.preview.icon ? image(state, prev, send) : null}
13 |
${state.search.preview.title}
14 |
${state.search.preview.url}
15 | ${buttons(state, prev, send)}
16 |
17 | `
18 |
19 | module.exports = preview
20 |
21 | function fixImageURL (url) {
22 | if (/^\w+:\/\//.test(url)) return url
23 | if (/^\/\//.test(url)) return `http:${url}`
24 | return `http://${url}`
25 | }
26 |
--------------------------------------------------------------------------------
/src/app/models/domains/effects.js:
--------------------------------------------------------------------------------
1 | const db = require("../../db")
2 | const urls = require("../../urls")
3 |
4 | module.exports = {
5 | enablePrivateMode,
6 | disablePrivateMode,
7 | get
8 | }
9 |
10 | function get (url, state, send, done) {
11 | db.domains.get(url, (error, row) => {
12 | if (error) return console.error('can not get domain ', error)
13 | if (!row) return
14 |
15 | send('domains:set', { domain: row.domain, props: row }, done)
16 | })
17 | }
18 |
19 | function enablePrivateMode (url, state, send, done) {
20 | db.domains.setPrivateMode(url, true, error => {
21 | if (error) return console.error('can not set privacy mode %s', id)
22 | get(url, state, send, done)
23 | })
24 | }
25 |
26 | function disablePrivateMode (url, state, send, done) {
27 | db.domains.setPrivateMode(url, false, error => {
28 | if (error) return console.error('can not set privacy mode %s', id)
29 | get(url, state, send, done)
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/src/os-menu/index.js:
--------------------------------------------------------------------------------
1 | const { Menu } = require("electron")
2 | const file = require("./file")
3 | const edit = require("./edit")
4 | const view = require("./view")
5 | const window = require("./window")
6 | const help = require("./help")
7 | const osx = require("./osx")
8 |
9 | module.exports = create
10 |
11 | function create (wm) {
12 | const items = [
13 | file,
14 | edit,
15 | view,
16 | window,
17 | help
18 | ].map(fn => fn(wm))
19 |
20 | const template = dev(wm, osx(wm, items))
21 |
22 | const menu = Menu.buildFromTemplate(template)
23 | Menu.setApplicationMenu(menu)
24 | }
25 |
26 | function dev (wm, template) {
27 | //if (!wm.developerMode) return template
28 |
29 | template[3].submenu.push({
30 | type: 'separator'
31 | })
32 | template[3].submenu.push({
33 | label: 'Inspect Kaktüs',
34 | click: (item, focusedWindow) => {
35 | if (focusedWindow) focusedWindow.toggleDevTools()
36 | }
37 | })
38 |
39 | return template
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/models/tabs/tab.js:
--------------------------------------------------------------------------------
1 | const titleFromURL = require("title-from-url")
2 | const parseURL = require("url").parse
3 | const urls = require("../../urls")
4 |
5 | class Tab {
6 | constructor (id, protocol, url) {
7 | this.id = id
8 | this.protocol = protocol || ''
9 | this.url = url && url.trim() || ''
10 | this.webviewURL = this.protocol ? `${this.protocol}://${this.url}` : this.url
11 | this.title = titleFromURL(this.url)
12 | this.description = ''
13 | this.icon = null
14 | this.image = undefined
15 | this.error = null
16 |
17 | this.createdAt = Date.now()
18 | this.seenAt = 0
19 |
20 | this.canGoBack = false
21 | this.canGoForward = false
22 | this.isDOMReady = false
23 | this.isLiked = false
24 | this.isLoading = false
25 | this.isSelected = false
26 | this.isPlayingMedia = false
27 | this.isNew = url.trim() === ''
28 | this.isMuted = false
29 |
30 | this.zoomLevel = 0
31 | }
32 | }
33 |
34 | module.exports = Tab
35 |
--------------------------------------------------------------------------------
/src/app/models/likes/effects.js:
--------------------------------------------------------------------------------
1 | const db = require("../../db")
2 |
3 | module.exports = {
4 | like,
5 | unlike,
6 | get
7 | }
8 |
9 | function get (url, state, send, done) {
10 | if (state.hasOwnProperty(url)) {
11 | return
12 | }
13 |
14 | db.likes.get(url, (error, row) => {
15 | if (error) return console.error('can not get like ', error)
16 | if (!row) return
17 |
18 | send('likes:set', {
19 | url,
20 | value: true
21 | }, done)
22 | })
23 | }
24 |
25 | function like (url, state, send, done) {
26 | db.likes.like(url, error => {
27 | if (error) return console.error('can not like %s', id)
28 |
29 | send('likes:set', {
30 | url,
31 | value: true
32 | }, done)
33 | })
34 | }
35 |
36 | function unlike (url, state, send, done) {
37 | db.likes.unlike(url, error => {
38 | if (error) return console.error('can not unlike %s', id)
39 |
40 | send('likes:set', {
41 | url,
42 | value: false
43 | }, done)
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/tab.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const closeButton = require("./close-button")
3 | const select = require("./on-select")(_select)
4 |
5 | const tab = (row, state, prev, send) => html`
6 |
9 |
10 |
${row.title || row.url}
11 |
12 | ${row.tab && !row.isNew ? closeButton(row.tab, prev, send) : null}
13 |
14 | `
15 |
16 | module.exports = ifNotNew
17 |
18 | function ifNotNew (row, state, prev, send) {
19 | return tab(row, state, prev, send)
20 | }
21 |
22 | function setPreview (row, prev, send) {
23 | return function () {
24 | if (row.tab.isNew) return
25 | send('search:setPreview', row)
26 | }
27 | }
28 |
29 | function _select (row, prev, send) {
30 | send('tabs:select', row.tab.id)
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/separator.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const tab = createSeparator({ title: 'Tabs', icon: 'bars' })
3 | const history = createSeparator({ title: 'Recently Visited', icon: 'file-o' })
4 | const like = createSeparator({ title: 'Liked', icon: 'heart' })
5 | const popular = createSeparator({ title: 'Popular', icon: 'fire' })
6 | const search = createSeparator({ title: 'Search Suggestions', icon: 'search' })
7 |
8 | module.exports = show
9 |
10 | function show (row, state, prev, send) {
11 | return pick(row)(state, prev, send)
12 | }
13 |
14 | function pick (row) {
15 | if (row.tab) return tab
16 | if (row.like) return like
17 | if (row.popular) return popular
18 | if (row.search) return search
19 | return history
20 | }
21 |
22 | function createSeparator (options) {
23 | return function (state, prev, send) {
24 | return html`
25 | ${options.title}
26 |
`
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/page-icon.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const pageButtons = require("./page-buttons")
3 |
4 | const favicon = (state, prev, send) => html`
5 |
6 | `
7 |
8 | const spinner = (state, prev, send) => html`
9 |
10 | `
11 |
12 | const search = (state, prev, send) => html`
13 |
14 |
15 |
16 | `
17 |
18 | const either = (state, prev, send) => html`
19 |
20 | ${buttons(state, prev, send)}
21 | ${icon(state, prev, send)}
22 |
23 | `
24 |
25 | module.exports = either
26 |
27 | function icon (state, prev, send) {
28 | if (state.isNew) return search(state, prev, send)
29 | if (state.isLoading) return spinner(state, prev, send)
30 | return favicon(state, prev, send)
31 | }
32 |
33 | function buttons (state, prev, send) {
34 | if (state.isNew) return null
35 | return pageButtons(state, prev, send)
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Kaktus",
3 | "version": "0.0.0",
4 | "description": "Web Browser",
5 | "main": "main.js",
6 | "repository": {
7 | "url": "git@github.com:azer/kaktus.git",
8 | "type": "git"
9 | },
10 | "author": "azer",
11 | "license": "BSD",
12 | "dependencies": {
13 | "anglicize": "github:azer/anglicize",
14 | "change-object": "github:azer/change-object",
15 | "choo": "3.3.0",
16 | "debounce-fn": "github:azer/debounce-fn",
17 | "electron": "1.4.0",
18 | "format-text": "github:azer/format-text",
19 | "indexeddb": "github:azer/indexeddb",
20 | "less-common-words": "github:azer/less-common-words",
21 | "mix-objects": "github:azer/mix-objects",
22 | "parallel-loop": "github:azer/parallel-loop",
23 | "title-from-url": "github:azer/title-from-url",
24 | "unique-now": "github:azer/unique-now",
25 | "uniques": "github:azer/uniques"
26 | },
27 | "devDependencies": {
28 | "browserify": "13.1.0",
29 | "electron-packager": "^13.0.0",
30 | "watchify": "3.7.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/like.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const closeButton = require("./close-button")
3 | const select = require("./on-select")(_select)
4 | const openInNewTabButton = require("./open-in-new-tab-button")
5 |
6 | const like = (row, state, prev, send) => html`
7 |
10 |
11 |
${row.title || row.url}
12 |
13 | ${state.search.preview === row && !state.tabs[state.tabs.selectedId].isNew ? openInNewTabButton(row, prev, send) : null}
14 |
15 | `
16 |
17 | module.exports = like
18 |
19 | function setPreview (row, prev, send) {
20 | return function () {
21 | send('search:setPreview', row)
22 | }
23 | }
24 |
25 | function _select (row, state, prev, send) {
26 | send('tabs:go', {
27 | url: `${row.record.protocol}://${row.record.url}`
28 | })
29 | }
30 |
--------------------------------------------------------------------------------
/src/os-menu/edit.js:
--------------------------------------------------------------------------------
1 | module.exports = edit
2 |
3 | function edit (wm) {
4 | return {
5 | label: 'Edit',
6 | submenu: [
7 | {
8 | label: 'Undo',
9 | accelerator: 'CmdOrCtrl+Z',
10 | role: 'undo'
11 | },
12 | {
13 | label: 'Redo',
14 | accelerator: 'Shift+CmdOrCtrl+Z',
15 | role: 'redo'
16 | },
17 | {
18 | type: 'separator'
19 | },
20 | {
21 | label: 'Cut',
22 | accelerator: 'CmdOrCtrl+X',
23 | role: 'cut'
24 | },
25 | {
26 | label: 'Copy',
27 | accelerator: 'CmdOrCtrl+C',
28 | role: 'copy'
29 | },
30 | {
31 | label: 'Paste',
32 | accelerator: 'CmdOrCtrl+V',
33 | role: 'paste'
34 | },
35 | {
36 | label: 'Select All',
37 | accelerator: 'CmdOrCtrl+A',
38 | role: 'selectall'
39 | },
40 | {
41 | type: 'separator'
42 | },
43 | {
44 | label: 'Find',
45 | accelerator: 'CmdOrCtrl+F',
46 | click: wm.sendFn('findInPage:enable')
47 | }
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/history.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const closeButton = require("./close-button")
3 | const select = require("./on-select")(_select)
4 | const openInNewTabButton = require("./open-in-new-tab-button")
5 |
6 | const history = (row, state, prev, send) => html`
7 |
10 |
11 |
12 |
13 |
14 |
${row.title || row.url}
15 |
16 | ${state.search.preview === row && !state.tabs[state.tabs.selectedId].isNew ? openInNewTabButton(row, prev, send) : null}
17 |
18 | `
19 |
20 | module.exports = history
21 |
22 | function setPreview (row, prev, send) {
23 | return function () {
24 | send('search:setPreview', row)
25 | }
26 | }
27 |
28 | function _select (row, prev, send) {
29 | send('tabs:go', {
30 | url: `${row.record.protocol}://${row.record.url}`
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/db/embed.js:
--------------------------------------------------------------------------------
1 | const loop = require("parallel-loop")
2 |
3 | module.exports = embed
4 |
5 | function embed (fn, params, stores, callback) {
6 | if (arguments.length === 3) {
7 | callback = stores
8 | stores = params
9 | params = undefined
10 | }
11 |
12 | const args = params || []
13 |
14 | args.push((error, rows) => {
15 | if (error) return callback(error)
16 | if (rows.length === 0) return callback(undefined, rows)
17 |
18 | loop(rows.length, each, errors => {
19 | if (errors) return callback(errors[0])
20 | callback(undefined, rows)
21 | })
22 |
23 | function each (done, index) {
24 | loop(stores.length, embedEach(rows[index]), done)
25 | }
26 |
27 | function embedEach(row) {
28 | return function (done, index) {
29 | const store = stores[index]
30 | const key = row[store.foreignKey]
31 |
32 | if (key === undefined) return done()
33 |
34 | store.get(key, (error, result) => {
35 | if (error) return done(error)
36 | row[store.foreignName] = result
37 | done()
38 | })
39 | }
40 | }
41 | })
42 |
43 | fn.apply(undefined, args)
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/views/search-results/add-separators.js:
--------------------------------------------------------------------------------
1 | module.exports = add
2 |
3 | function add (rows) {
4 | const result = []
5 | let historySeparator = false
6 | let likeSeparator = false
7 | let tabSeparator = false
8 | let popularSeparator = false
9 | let searchSeparator = false
10 |
11 | for (let row of rows) {
12 | if (row.tab && !tabSeparator) {
13 | tabSeparator = true
14 | result.push({ separator: true, tab: true })
15 | } else if (!row.tab && row.isPopularRecord && !popularSeparator) {
16 | popularSeparator = true
17 | result.push({ separator: true, popular: true })
18 | } else if (!row.tab && row.like && !likeSeparator) {
19 | likeSeparator = true
20 | result.push({ separator: true, like: true })
21 | } else if (row.search && !searchSeparator) {
22 | searchSeparator = true
23 | result.push({ separator: true, search: true })
24 | } else if (!row.tab && !row.like && !row.isPopularRecord && row.record && !historySeparator) {
25 | historySeparator = true
26 | result.push({ separator: true, history: true })
27 | }
28 |
29 | result.push(row)
30 | last = row
31 | }
32 |
33 | return result
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/urls.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | clean,
3 | meta,
4 | protocol,
5 | domain,
6 | normalize,
7 | isURL
8 | }
9 |
10 | function protocol (url) {
11 | const match = url.match(/(^\w+):\/\//)
12 | if (match) {
13 | return match[1]
14 | }
15 |
16 | return 'http'
17 | }
18 |
19 | function clean (url) {
20 | return url
21 | .trim()
22 | .replace(/^\w+:\/\//, '')
23 | .replace(/(\/|\?|\&)*$/, '')
24 | .replace(/^www\./, '')
25 | }
26 |
27 | function meta (url) {
28 | return clean(url)
29 | .toLowerCase()
30 | .replace(/\#.*$/, '')
31 | }
32 |
33 | function domain (url) {
34 | var a = document.createElement('a')
35 | a.href = normalize(url)
36 | return a.hostname
37 | }
38 |
39 | function normalize (input) {
40 | if (isSearchQuery(input)) {
41 | return `https://google.com/search?q=${input}`
42 | }
43 |
44 | if (!/^\w+:\/\//.test(input)) {
45 | return `http://${input}`
46 | }
47 |
48 | return input
49 | }
50 |
51 | function isSearchQuery (input) {
52 | return !isURL(input.trim())
53 | }
54 |
55 | function isURL (input) {
56 | return input.indexOf(' ') === -1 && (/^\w+:\/\//.test(input) || input.indexOf('.') > 0 || input.indexOf(':') > 0)
57 | }
58 |
--------------------------------------------------------------------------------
/src/os-menu/about.js:
--------------------------------------------------------------------------------
1 | const name = 'Kaktus'
2 |
3 | module.exports = about
4 |
5 | function about (wm) {
6 | return {
7 | label: name,
8 | submenu: [
9 | {
10 | label: 'About ' + name,
11 | role: 'about'
12 | },
13 | {
14 | type: 'separator'
15 | },
16 | {
17 | label: 'Preferences',
18 | accelerator: 'CmdOrCtrl+,',
19 | click: function (item, window) {
20 | // wm.send('tabs:open-preferences')
21 | }
22 | },
23 | {
24 | label: 'Services',
25 | role: 'services',
26 | submenu: []
27 | },
28 | {
29 | type: 'separator'
30 | },
31 | {
32 | label: 'Hide ' + name,
33 | accelerator: 'CmdOrCtrl+H',
34 | role: 'hide'
35 | },
36 | {
37 | label: 'Hide Others',
38 | accelerator: 'CmdOrCtrl+Shift+H',
39 | role: 'hideothers'
40 | },
41 | {
42 | label: 'Show All',
43 | role: 'unhide'
44 | },
45 | {
46 | type: 'separator'
47 | },
48 | {
49 | label: 'Quit',
50 | accelerator: 'CmdOrCtrl+Q',
51 | click: function () {
52 | wm.app.quit()
53 | }
54 | }
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/partition.js:
--------------------------------------------------------------------------------
1 | const urls = require("./urls")
2 |
3 | const DEV_MODE = process.env.DEV_MODE === 'ON'
4 | const DEFAULT = 'persist:kaktus-v1'
5 | const DEV = 'persist:kaktus-v1-dev'
6 | const PRIVATE = 'kaktus-private'
7 |
8 | module.exports = {
9 | window: name,
10 | tab,
11 | webviewId,
12 | isPrivateModeDomain,
13 | preferences,
14 | userAgent
15 | }
16 |
17 | function name (isPrivate) {
18 | if (isPrivate) return `${PRIVATE}-${Math.floor(Math.random() * 999999)}`
19 | return DEV_MODE ? DEV : DEFAULT
20 | }
21 |
22 | function tab (t, state) {
23 | if (state.general.privateMode) return state.general.partitionName
24 | return name(isPrivateModeDomain(t || state.tabs[state.tabs.selectedId], state))
25 | }
26 |
27 | function preferences (t, state) {
28 | return "javascript=no"
29 | return ""
30 | }
31 |
32 | function userAgent (t, state) {
33 | return "Lynx/2.8.4rel.1 libwww-FM/2.14 SSL-MM/1.4.1 OpenSSL/0.9.6c"
34 | return "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.110 Safari/537.36"
35 | }
36 |
37 | function webviewId (t, state) {
38 | return `${t.id}${isPrivateModeDomain(t, state) ? '-private' : ''}`
39 | }
40 |
41 | function isPrivateModeDomain (tab, state) {
42 | const domain = state.domains[urls.domain(tab.url)]
43 | return domain && domain.privateMode
44 | }
45 |
--------------------------------------------------------------------------------
/src/app/db/likes.js:
--------------------------------------------------------------------------------
1 | const db = require("./db")
2 | const urls = require("../urls")
3 | const store = db.store('likes', {
4 | key: { keyPath: "url" },
5 | indexes: [
6 | { name: 'likedAt', options: { unique: false } }
7 | ],
8 | upgrade
9 | })
10 |
11 | module.exports = {
12 | foreignName: 'like',
13 | foreignKey: 'url',
14 | store,
15 | like,
16 | unlike,
17 | get,
18 | all
19 | }
20 |
21 | function like (url, callback) {
22 | store.add({
23 | url: urls.clean(url),
24 | likedAt: Date.now()
25 | }, callback)
26 | }
27 |
28 | function unlike (url, callback) {
29 | store.delete(urls.clean(url), callback)
30 | }
31 |
32 | function get (url, callback) {
33 | store.get(urls.clean(url), callback)
34 | }
35 |
36 | function all (options, callback) {
37 | const result = []
38 | const limit = options.limit || 25
39 | const range = options.range || null
40 | const direction = options.direction || 'prev'
41 |
42 | store.selectRange('likedAt', range, direction, (error, row) => {
43 | if (error) return callback(error)
44 | if (!row) return callback(undefined, result)
45 |
46 | result.push(row.value)
47 |
48 | if (result.length >= limit) {
49 | return callback(undefined, result)
50 | }
51 |
52 | row.continue()
53 | })
54 | }
55 |
56 | function upgrade () {
57 | store.createIndex('likedAt', { unique: false })
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/views/search-results/row/search-query.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const closeButton = require("./close-button")
3 | const select = require("./on-select")(_select)
4 | const openInNewTabButton = require("./open-in-new-tab-button")
5 | const urls = require("../../../urls")
6 |
7 | const searchQuery = (row, state, prev, send) => html`
8 |
11 |
12 |
13 |
14 |
15 |
${title(row.search.query)}
16 |
17 | ${state.search.preview === row && !state.tabs[state.tabs.selectedId].isNew ? openInNewTabButton(row, prev, send) : null}
18 |
19 | `
20 |
21 | module.exports = searchQuery
22 |
23 | function setPreview (row, prev, send) {
24 | return function () {
25 | send('search:setPreview', row)
26 | }
27 | }
28 |
29 | function _select (row, prev, send) {
30 | let url = `http://google.com/search?q=${row.search.query}`
31 |
32 | send('tabs:go', {
33 | url: urls.isURL(row.search.query) ? row.search.query : url
34 | })
35 | }
36 |
37 | function title (query) {
38 | if (urls.isURL(query)) {
39 | query = urls.clean(query)
40 | }
41 |
42 | return query
43 | }
44 |
--------------------------------------------------------------------------------
/src/app/views/fonts.css:
--------------------------------------------------------------------------------
1 | /** Ultra Light */
2 | @font-face {
3 | font-family: "San Francisco";
4 | font-weight: 100;
5 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-ultralight-webfont.woff");
6 | }
7 |
8 | /** Thin */
9 | @font-face {
10 | font-family: "San Francisco";
11 | font-weight: 200;
12 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-thin-webfont.woff");
13 | }
14 |
15 | /** Regular */
16 | @font-face {
17 | font-family: "San Francisco";
18 | font-weight: 400;
19 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-regular-webfont.woff");
20 | }
21 |
22 | /** Medium */
23 | @font-face {
24 | font-family: "San Francisco";
25 | font-weight: 500;
26 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-medium-webfont.woff");
27 | }
28 |
29 | /** Semi Bold */
30 | @font-face {
31 | font-family: "San Francisco";
32 | font-weight: 600;
33 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-semibold-webfont.woff");
34 | }
35 |
36 | /** Bold */
37 | @font-face {
38 | font-family: "San Francisco";
39 | font-weight: 700;
40 | src: url("https://applesocial.s3.amazonaws.com/assets/styles/fonts/sanfrancisco/sanfranciscodisplay-bold-webfont.woff");
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/db/domains.js:
--------------------------------------------------------------------------------
1 | const db = require("./db")
2 | const urls = require("../urls")
3 | const store = db.store('domains', {
4 | key: { keyPath: "domain" }
5 | })
6 |
7 | const PRIVATE_BY_DEFAULT = [
8 | 'google.com',
9 | 'google.fr',
10 | 'google.co.uk',
11 | 'google.com.tr',
12 | 'google.co.id'
13 | ]
14 |
15 | module.exports = {
16 | foreignName: 'domain',
17 | foreignKey: 'url',
18 | setPrivateMode,
19 | get
20 | }
21 |
22 | function get (url, callback) {
23 | const domain = urls.domain(url)
24 |
25 | store.get(domain, (error, row) => {
26 | if (error) return callback(error)
27 | if (row) return callback(undefined, row)
28 | if (PRIVATE_BY_DEFAULT.indexOf(domain) === -1) return callback()
29 |
30 | setPrivateMode(domain, true, error => {
31 | if (error) return callback(error)
32 | get(url, callback)
33 | })
34 | })
35 | }
36 |
37 | function _setPrivateMode (domain, value, callback) {
38 | save({
39 | domain,
40 | privateMode: value
41 | }, callback)
42 | }
43 |
44 | function setPrivateMode (url, value, callback) {
45 | _setPrivateMode(urls.domain(url), value, callback)
46 | }
47 |
48 | function save (props, callback) {
49 | store.get(props.domain, function (error, existing) {
50 | if (error) return callback(error)
51 | if (existing) {
52 | return store.update(props, callback)
53 | }
54 |
55 | store.add(props, callback)
56 | })
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/search/maps.js:
--------------------------------------------------------------------------------
1 | const meta = require("../db/meta");
2 |
3 | module.exports = {
4 | tab,
5 | record,
6 | like,
7 | popularRecord
8 | }
9 |
10 | function tab (row) {
11 | const result = row.meta || meta.draft(row.url)
12 | result.record = row.record
13 | result.like = row.like
14 | result.tab = row
15 | result.isTabRecord = true
16 |
17 | delete result.tab.meta
18 | delete result.tab.record
19 | delete result.tab.like
20 |
21 | return result
22 | }
23 |
24 | function record (row) {
25 | if (!row.meta) return null
26 |
27 | const result = row.meta || meta.draft(row.url)
28 | result.tab = row.tab
29 | result.like = row.like
30 | result.record = row
31 | result.isHistoryRecord = true
32 |
33 | delete result.record.meta
34 | delete result.record.tab
35 | delete result.record.like
36 |
37 | return result
38 | }
39 |
40 | function like (row) {
41 | const result = row.meta
42 | result.tab = row.tab
43 | result.record = row.record
44 | result.like = row
45 | result.isLikeRecord = true
46 | return result
47 | }
48 |
49 | function popularRecord (row) {
50 | if (!row.meta) return null
51 |
52 | const result = row.meta || meta.draft(row.url)
53 | result.tab = row.tab
54 | result.like = row.like
55 | result.record = row
56 | result.isPopularRecord = true
57 |
58 | delete result.record.meta
59 | delete result.record.tab
60 | delete result.record.like
61 |
62 | return result
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/views/main.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const { ipcRenderer } = electronRequire('electron')
3 | const top = require("./top-bar")
4 | const listOfTabs = require("../list-of-tabs")
5 | const webviews = require("./webviews")
6 | let initialized = false
7 |
8 | const main = (state, prev, send) => {
9 | if (!ready(state, prev, send) || listOfTabs(state).length === 0) {
10 | return loading(state, prev, send)
11 | }
12 |
13 | return html`
14 |
15 | ${privacyIcon(state, prev, send)}
16 | ${top(state, prev, send)}
17 | ${webviews(state, prev, send)}
18 |
19 | `
20 | }
21 |
22 | const loading = (state, prev, send) => html`Kaktüs
`
23 |
24 | module.exports = main
25 |
26 | function ready (state, prev, send) {
27 | if (initialized) {
28 | return true
29 | }
30 |
31 | initialized = true
32 |
33 | ipcRenderer.on('action', function (event, message) {
34 | console.log(message.name, message.payload)
35 | send(message.name, message.payload)
36 | })
37 | }
38 |
39 |
40 | function privacyIcon (state) {
41 | if (!state.general.privateMode) return
42 |
43 | return html`
44 |
45 |
`
46 | }
47 |
48 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/index.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const icon = require("./page-icon")
3 | const titleFromURL = require("title-from-url")
4 | const likeButton = require("./like-button")
5 | const createTabButton = require("./create-tab")
6 | const findInPageBar = require("../find-in-page").bar
7 | const privateModeIcon = require("./private-mode-icon")
8 | const urls = require("../../urls")
9 |
10 | module.exports = createTitleBar
11 |
12 | function createTitleBar (children, onClick) {
13 | return (state, prev, send) => {
14 | if (state.findInPage.enabled) return findInPageBar(state, prev, send)
15 |
16 | const selectedTab = state.tabs[state.tabs.selectedId]
17 |
18 | return html`
19 |
20 | ${icon(selectedTab, prev, send)}
21 | ${children}
22 | ${button(state, prev, send)}
23 |
`
24 | }
25 | }
26 |
27 | function button (state, prev, send) {
28 | if (state.tabs[state.tabs.selectedId].isNew) return null
29 | if (state.search.isOpen) {
30 | return createTabButton(state, prev, send)
31 | }
32 |
33 | if (isPrivateModeEnabled(state)) return privateModeIcon(state, prev, send)
34 |
35 | return likeButton(state, prev, send)
36 | }
37 |
38 | function isPrivateModeEnabled (state, prev, send) {
39 | const domain = state.domains[urls.domain(state.tabs[state.tabs.selectedId].url)]
40 | return state.general.privateMode || ( domain && domain.privateMode )
41 | }
42 |
--------------------------------------------------------------------------------
/src/os-menu/view.js:
--------------------------------------------------------------------------------
1 | module.exports = view
2 |
3 | function view (wm) {
4 | return {
5 | label: 'View',
6 | submenu: [
7 | {
8 | label: 'Full Screen',
9 | accelerator: process.platform == 'darwin' ? 'Ctrl+Command+F' : 'F11',
10 | role: 'togglefullscreen'
11 | },
12 | {
13 | label: 'Focus Mode',
14 | accelerator: 'CmdOrCtrl+O',
15 | type: 'checkbox',
16 | checked: wm.focusMode,
17 | click: wm.toggleFocusMode.bind(wm)
18 | },
19 | {
20 | type: 'separator'
21 | },
22 | {
23 | label: 'Show Tab Menu',
24 | accelerator: 'CmdOrCtrl+Space',
25 | click: () => {
26 | wm.send('search:open', { search: '' })
27 | }
28 | },
29 | {
30 | type: 'separator'
31 | },
32 | {
33 | label: 'Actual size',
34 | accelerator: 'CmdOrCtrl+0',
35 | click: wm.sendFn('tabs:resetZoom')
36 | },
37 | {
38 | label: 'Zoom In',
39 | accelerator: 'CmdOrCtrl+Plus',
40 | click: wm.sendFn('tabs:zoomIn')
41 | },
42 | {
43 | label: 'Zoom Out',
44 | accelerator: 'CmdOrCtrl+-',
45 | click: wm.sendFn('tabs:zoomOut')
46 | },
47 | {
48 | type: 'separator'
49 | },
50 | {
51 | label: 'Developer Tools',
52 | accelerator: process.platform === 'darwin' ? 'Cmd+Alt+I' : 'Ctrl+Shift+I',
53 | click: wm.sendFn('tabs:openDevTools')
54 | }
55 | ]
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/views/top-bar/surfing-bar.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const titleFromURL = require("title-from-url")
3 | const createTitleBar = require("../title-bar")
4 | const movementButtons = require("./movement-buttons")
5 | const tabs = require("../../models/tabs")
6 | const isButton = require("../is-button")
7 | const urls = require("../../urls");
8 |
9 | const titleText = (tab, prev, send) => html`
10 |
11 | ${tab.isNew ? "New Tab" : prettyTitle(tab.title || tab.url) || "Loading..."}
12 |
13 | `
14 |
15 | const errorText = (tab, prev, send) => html`
16 |
17 | ${urls.clean(tab.webviewURL)} (Failed)
18 |
19 | `
20 |
21 | const surfingBar = (state, prev, send) => {
22 | let text = titleText(state.tabs[state.tabs.selectedId], prev, send)
23 |
24 | if (state.tabs[state.tabs.selectedId].error) {
25 | text = errorText(state.tabs[state.tabs.selectedId], prev, send)
26 | }
27 |
28 | return html`
29 |
30 | ${movementButtons(state, prev, send)}
31 | ${createTitleBar(text, onClick)(state, prev, send)}
32 |
33 | `
34 | }
35 |
36 | module.exports = surfingBar
37 |
38 | function prettyTitle (original) {
39 | if (urls.isURL(original)) {
40 | return titleFromURL(original)
41 | }
42 |
43 | return original
44 | }
45 |
46 | function onClick (tab, state, prev, send) {
47 | return function (event) {
48 | if (isButton(event.target)) {
49 | return
50 | }
51 |
52 | send('search:open', { query: '', selectFirstItem: true })
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/os-menu/file.js:
--------------------------------------------------------------------------------
1 | module.exports = file
2 |
3 | function file (wm) {
4 | return {
5 | label: 'File',
6 | submenu: [
7 | {
8 | label: 'Open URL',
9 | accelerator: 'Ctrl+Space',
10 | click: () => {
11 | wm.send('search:open', { query: '' })
12 | }
13 | },
14 | {
15 | label: 'New Tab',
16 | accelerator: 'CmdOrCtrl+t',
17 | click: function (item, window) {
18 | wm.send('tabs:newTab')
19 | }
20 | },
21 | /*
22 | Disabled until we store tabs with session ids
23 | {
24 | label: 'New Window',
25 | accelerator: 'CmdOrCtrl+n',
26 | click: function (item, window) {
27 | wm.createWindow()
28 | }
29 | },*/
30 | {
31 | label: 'New Private Window',
32 | accelerator: 'shift+CmdOrCtrl+n',
33 | click: function (item, window) {
34 | wm.createPrivateWindow()
35 | }
36 | },
37 | {
38 | label: 'Close Tab',
39 | accelerator: 'CmdOrCtrl+w',
40 | click: wm.sendFn('tabs:closeSelectedTab')
41 | },
42 | {
43 | type: 'separator'
44 | },
45 | {
46 | label: 'Show Tab Menu',
47 | accelerator: 'CmdOrCtrl+Space',
48 | click: () => {
49 | wm.send('search:open', { search: '' })
50 | }
51 | },
52 | {
53 | type: 'separator'
54 | },
55 | {
56 | label: 'Print',
57 | accelerator: 'CmdOrCtrl+p',
58 | click: function (item, window) {
59 | wm.send('tabs:print')
60 | }
61 | }
62 | ]
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/views/find-in-page/bar.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 |
3 | const icon = (state, prev, send) => html`
4 |
5 |
6 |
7 | `
8 |
9 | const caption = (state, prev, send) => html`
10 |
11 | Find In Page:
12 |
13 | `
14 |
15 | const button = (state, prev, send) => html`
16 | send('findInPage:disable')}>
17 |
18 |
`
19 |
20 | const input = (state, prev, send) => html`
21 |
27 | `
28 |
29 | const bar = (state, prev, send) => html`
30 |
31 | ${icon(state, prev, send)}
32 | ${caption(state, prev, send)}
33 | ${input(state, prev, send)}
34 | ${button(state, prev, send)}
35 |
36 | `
37 |
38 | module.exports = bar
39 |
40 | function onInput (state, prev, send) {
41 | return function (e) {
42 | send('findInPage:setQuery', e.target.value)
43 |
44 | if (e.target.value.trim() === '') {
45 | return send('tabs:quitFindInPage')
46 | }
47 |
48 | send('tabs:findInPage', {
49 | query: e.target.value
50 | })
51 | }
52 | }
53 |
54 | function onKeyUp (state, prev, send) {
55 | return function (e) {
56 | if (e.keyCode === 27) {
57 | return send('findInPage:disable')
58 | }
59 |
60 | if (e.keyCode === 13) {
61 | return send('tabs:findNextInPage', {
62 | query: e.target.value
63 | })
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PID=/tmp/kaktus-dev.pid
2 | .PHONY: all
3 |
4 | .PHONY: build
5 | build: build-js build-css
6 |
7 | build-js:
8 | @echo " 🛠 Building..."
9 | @./node_modules/.bin/browserify src/app/index.js > build/min.js
10 |
11 | build-css:
12 | @echo " 🛠 Building CSS..."
13 | @cat src/app/views/fonts.css src/app/views/style.css src/app/views/top-bar/style.css src/app/views/title-bar/style.css src/app/views/search-results/style.css src/app/views/preview/style.css src/app/views/webviews/style.css src/app/views/find-in-page/style.css src/app/views/title-bar/spinner.css > build/style.css
14 |
15 | watch-css:
16 | @echo " 👓 Watching for changes (CSS)..."
17 | @fswatch -o src/app/views/*.css src/app/views/**/*.css | xargs -n1 -I{} make build-css
18 |
19 | watch-js:
20 | @echo " 👓 Watching for changes (JS)..."
21 | @./node_modules/.bin/watchify src/app/index.js -o build/dev.js --debug --verbose
22 |
23 | start:
24 | @echo " ▶️ Starting"
25 | @DEV_MODE=ON ./node_modules/.bin/electron . & echo $$! > $(PID)
26 |
27 | stop:
28 | @echo " ⏹ Stopping"
29 | @-touch $(PID)
30 | @-kill `cat $(PID)` 2> /dev/null || true
31 |
32 | clean:
33 | @rm -rf dist
34 |
35 | osx: build
36 | @./node_modules/.bin/electron-packager . Kaktüs --out=dist/osx --platform=darwin --arch=x64 --icon=kaktus.icns --ignore=dist --ignore=README.md --ignore=.gitignore
37 |
38 | linux: build
39 | @./node_modules/.bin/electron-packager . Kaktüs --out=dist/linux --platform=linux --arch=x64 --icon=kaktus.icns --ignore=dist --ignore=README.md --ignore=.gitignore
40 |
41 | win: build
42 | @export PATH=$PATH:/Applications/Wine.app/Contents/Resources/bin
43 | @./node_modules/.bin/electron-packager . Kaktüs --out=dist/win32 --platform=win32 --arch=x64 --icon=kaktus.icns --ignore=dist --ignore=README.md --ignore=.gitignore
44 |
--------------------------------------------------------------------------------
/src/app/search/sort.js:
--------------------------------------------------------------------------------
1 | module.exports = sort
2 |
3 | /**
4 | * Sort given database rows. They can be;
5 | * - History record
6 | * - Tab
7 | * - Like
8 | */
9 | function sort (a, b) {
10 | if (!a) return 1
11 | if (!b) return -1
12 |
13 | // rule 1: tabs first
14 | if (a.tab && !b.tab) {
15 | return -1
16 | }
17 |
18 | if (b.tab && !a.tab) {
19 | return 1
20 | }
21 |
22 | // rule 2: if both tab, higher lastSeenAt first
23 | if (a.tab && b.tab && a.tab.lastSeenAt > b.tab.lastSeenAt) {
24 | return -1
25 | }
26 |
27 | if (a.tab && b.tab && a.tab.lastSeenAt < b.tab.lastSeenAt) {
28 | return 1
29 | }
30 |
31 | // rule 3: popular first
32 | if (a.isPopularRecord && !a.like && !b.isPopularRecord) {
33 | return -1
34 | }
35 |
36 | if (!a.isPopularRecord && b.isPopularRecord && !b.like) {
37 | return 1
38 | }
39 |
40 | if (a.isPopularRecord && b.isPopularRecord && !a.like && !b.like && a.record.lastUpdatedAt > b.record.lastUpdatedAt) {
41 | return -1
42 | } else if (a.isPopularRecord && b.isPopularRecord) {
43 | return 1
44 | }
45 |
46 | // rule 4: liked first
47 | if (a.like && !b.like) {
48 | return -1
49 | }
50 |
51 | if (b.like && !a.like) {
52 | return 1
53 | }
54 |
55 | // rule 5: if both liked, higher likedAt first
56 | if (a.like && b.like && a.like.likedAt < b.like.likedAt) {
57 | return 1
58 | }
59 |
60 | if (a.like && b.like && a.like.likedAt > b.like.likedAt) {
61 | return -1
62 | }
63 |
64 | // ?
65 | if (!b.record) {
66 | return 1
67 | }
68 |
69 | if (!a.record) {
70 | return -1
71 | }
72 |
73 | // rule 5: latest visited URLs comes first
74 | if (a.record.lastUpdatedAt > b.record.lastUpdatedAt) {
75 | return -1
76 | }
77 |
78 | if (a.record.lastUpdatedAt < b.record.lastUpdatedAt) {
79 | return 1
80 | }
81 |
82 | return 0
83 | }
84 |
--------------------------------------------------------------------------------
/src/app/views/top-bar/style.css:
--------------------------------------------------------------------------------
1 | .focus-mode .top-bar {
2 | display: none;
3 | }
4 |
5 | .top-bar {
6 | position: absolute;
7 | width: 100%;
8 | height: 45px;
9 | border-bottom: 1px solid #ddd;
10 | background: linear-gradient(to bottom, #f2f2f2 0%,#eee 100%);
11 | -webkit-app-region: drag;
12 | z-index: 9999;
13 | }
14 |
15 | .top-bar .buttons {
16 | position: absolute;
17 | top: 7px;
18 | right: 10px;
19 | width: 200px;
20 | height: 38px;
21 | }
22 |
23 | .top-bar .buttons .button {
24 | float: right;
25 | font: 22px Arial, sans-serif;
26 | color: #ccc;
27 | padding: 3px 10px;
28 | background: #f8f8f8;
29 | box-shadow: 0 1px #e5e5e5;
30 | height: 26px;
31 | }
32 |
33 | .top-bar .buttons .button:first-child {
34 | border-right;: 1px solid #eee;
35 | border-top-right-radius: 5px;
36 | border-bottom-right-radius: 5px;
37 | box-shadow: 1px 1px #e5e5e5;
38 | }
39 |
40 | .top-bar .buttons .button:last-child {
41 | border-left; 1px solid #eee;
42 | border-top-left-radius: 5px;
43 | border-bottom-left-radius: 5px;
44 | box-shadow: 0 1px #e5e5e5;
45 | }
46 |
47 | .top-bar .buttons .audio {
48 | min-width: 20px;
49 | }
50 |
51 | .top-bar .buttons .audio .mute, .top-bar .buttons .audio.muted .unmute {
52 | padding-top: 1px;
53 | }
54 |
55 | .top-bar .buttons .audio .mute,
56 | .top-bar .buttons .audio.muted .unmute,
57 | .top-bar .buttons .audio:hover .unmute,
58 | .top-bar .buttons .audio.muted:hover .mute {
59 | display: none;
60 | }
61 |
62 | .top-bar .buttons .audio:hover .mute,
63 | .top-bar .buttons .audio.muted .mute,
64 | .top-bar .buttons .audio.muted:hover .unmute {
65 | display: block;
66 | }
67 |
68 | .top-bar .buttons .button.active:hover {
69 | background: #fff;
70 | }
71 |
72 | .top-bar .buttons .button.active {
73 | color: #888;
74 | }
75 |
76 | .top-bar .find-in-page-buttons .button {
77 | font-size: 16px;
78 | padding: 7px 10px 0 10px;
79 | }
80 |
--------------------------------------------------------------------------------
/src/app/views/webviews/style.css:
--------------------------------------------------------------------------------
1 | .content {
2 | width: 100%;
3 | height: calc(100% - 46px);
4 | position: absolute;
5 | top: -9999px;
6 | left: -9999px;
7 | z-index: 1;
8 | }
9 |
10 | .content.selected {
11 | visibility: visible;
12 | top: 45px;
13 | left: 0;
14 | }
15 |
16 | .focus-mode .content.selected {
17 | top: 0;
18 | height: 100%;
19 | }
20 |
21 | .content.has-error .webview {
22 | display: none;
23 | }
24 |
25 | .webview, .error, .private-mode-wrapper {
26 | width: 100%;
27 | height: 100%;
28 | }
29 |
30 | .error {
31 | background: rgb(255, 243, 100);
32 | }
33 |
34 | .error .error-info {
35 | position: absolute;
36 | top: calc(50% - 100px);
37 | left: 50%;
38 | transform: translate(-50%, 0);
39 | margin: 25px auto;
40 | }
41 |
42 | .error .big-reload-button {
43 | position: absolute;
44 | margin: -8px 0 0 -90px;
45 | font: 48px Arial;
46 | /*border: 1px solid rgba(7, 212, 108, 0.4);*/
47 | background: #fff;
48 | color: #aaa;
49 | border-radius: 50px;
50 | padding: 0 14px 3px 15px;
51 | cursor: pointer;
52 | opacity: 0.9;
53 | color: rgba(0, 202, 192, 0.8);
54 | }
55 |
56 | .error .big-reload-button:hover {
57 | opacity: 1;
58 | color: rgb(0, 202, 192);
59 | }
60 |
61 | .error h1 {
62 | font: 600 32px Helvetica, Arial;
63 | margin: 0 0 5px 0;
64 | }
65 |
66 | .error h1 span {
67 | background: rgba(255, 0, 100, 0.5);
68 | padding: 5px 8px;
69 | color: #fff;
70 | }
71 |
72 | .error h2 {
73 | margin: 0;
74 | font: 21px Helvetica, Arial;
75 | color: rgba(0, 0, 0, 0.7);
76 | padding: 20px 10px;
77 | background: rgba(255, 255, 255, 0.6);
78 | }
79 |
80 | .error h3 {
81 | color: rgba(0, 0, 0, 0.5);
82 | font: 12px Arial;
83 | text-shadow: 1px 1px rgba(0, 0, 0, 0.2)
84 | }
85 |
86 | .content .new-tab {
87 | background: #f7f7f7;
88 | padding: 20px;
89 | font: 400 21px "San Francisco", Helvetica;
90 | color: #666;
91 | height: calc(100% - 40px);
92 | }
93 |
--------------------------------------------------------------------------------
/src/app/views/preview/style.css:
--------------------------------------------------------------------------------
1 | .preview {
2 | float: right;
3 | width: calc(40% - 10px);
4 | padding: 5px;
5 | min-height: 100px;
6 | }
7 |
8 | .preview .preview-image {
9 | width: 100%;
10 | height: 100px;
11 | text-align: center;
12 | vertical-align: middle;
13 | display: -webkit-box;
14 | -webkit-box-pack: center;
15 | -webkit-box-align: center;
16 | }
17 |
18 | .preview .preview-image img {
19 | max-width: 100%;
20 | max-height: 80px;
21 | }
22 |
23 | .preview .preview-text, .preview .preview-url, .preview-button-text, .preview-button-icon {
24 | font: 500 14px "San Francisco", Helvetica;
25 | text-rendering: optimizeLegibility !important;
26 | -webkit-font-smoothing: antialiased !important;
27 | overflow: hidden;
28 | text-overflow: ellipsis;
29 | white-space: nowrap;
30 | }
31 |
32 | .preview .preview-text {
33 | border-top: 1px solid #eee;
34 | margin-top: 5px;
35 | padding: 10px 10px 0px 10px;
36 | }
37 |
38 | .preview .preview-url {
39 | color: #888;
40 | padding: 3px 10px 10px 10px;
41 | }
42 |
43 | .preview-buttons {
44 | margin-top: 5px;
45 | padding: 5px;
46 | }
47 |
48 | .preview-button {
49 | padding: 5px;
50 | margin-bottom: 1px;
51 | color: #666;
52 | cursor: pointer;
53 | }
54 |
55 | .preview-button .preview-button-icon {
56 | color: #aaa;
57 | }
58 |
59 | .preview-button:hover, .preview-button:hover .preview-button-icon {
60 | color: #444;
61 | }
62 |
63 | .preview-button.active {
64 | color: #000;
65 | }
66 |
67 | .preview-button-icon {
68 | position: absolute;
69 | }
70 |
71 | .preview-button-text {
72 | margin-left: 20px;
73 | }
74 |
75 | .preview-button.liked.active .preview-button-icon {
76 | color: rgba(225, 25, 0, 0.9);
77 | }
78 |
79 | .preview-button.private-mode.active .preview-button-icon, .preview-button.nineteens-mode.active .preview-button-icon {
80 | color: rgb(90, 62, 121);
81 | }
82 |
83 | .preview-button.copy-url:active .preview-button-text span {
84 | display: none;
85 | }
86 |
87 | .preview-button.copy-url:active .preview-button-text:after {
88 | content: "Copied"
89 | }
90 |
--------------------------------------------------------------------------------
/src/app/views/top-bar/movement-buttons.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const forwardButton = movementButton('forward')
3 | const backButton = movementButton('back')
4 | const findInPageButtons = require("../find-in-page").buttons
5 |
6 | const audioButton = (state, prev, send) => html`
7 |
10 |
11 |
12 |
13 | `
14 |
15 | const movementButtons = (state, prev, send) => {
16 | if (state.findInPage.enabled) return findInPageButtons(state, prev, send)
17 |
18 | const selectedTab = state.tabs[state.tabs.selectedId]
19 |
20 | return html`
21 |
22 | ${forwardButton(selectedTab.canGoForward, e => forward(selectedTab, prev, send)) }
23 | ${backButton(selectedTab.canGoBack, e => back(selectedTab, prev, send)) }
24 | ${selectedTab.isPlayingMedia ? audioButton(selectedTab, prev, send) : null}
25 |
26 | `
27 | }
28 |
29 | module.exports = movementButtons
30 |
31 | function movementButton (type) {
32 | let icon = type === 'forward' ? '❯' : '❮'
33 | return (isActive, onclick) => html`
34 |
35 | ${icon}
36 |
`
37 | }
38 |
39 | function mute (state, send) {
40 | return function () {
41 | send('tabs:mute', {
42 | tab: state
43 | })
44 | }
45 | }
46 |
47 | function unmute (state, send) {
48 | return function () {
49 | send('tabs:unmute', {
50 | tab: state
51 | })
52 | }
53 | }
54 |
55 | function forward (state, prev, send) {
56 | if (!state.canGoForward) {
57 | return
58 | }
59 |
60 | send('tabs:forward', {
61 | tab: state
62 | })
63 | }
64 |
65 | function back (state, prev, send) {
66 | if (!state.canGoBack) {
67 | return
68 | }
69 |
70 | send('tabs:back', {
71 | tab: state
72 | })
73 | }
74 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## kaktüs
2 |
3 | A new minimalistic web browser.
4 |
5 | Screenshots: [Simple View](https://cldup.com/6jOWAjYdpo.png) | [Tabbing](https://cldup.com/wDadS2XGrb.gif) | [Private Mode for Sites](https://cldup.com/qsYAu0F-ja.png)
6 |
7 | * [Download](#download)
8 | * [Keyboard Shortcuts](#keyboard-shortcuts)
9 | * [Roadmap](#roadmap)
10 | * [Call for Designers](#call-for-designers)
11 | * [Development](#development)
12 |
13 | 
14 |
15 | ## Download
16 |
17 | * [macOS](https://www.dropbox.com/s/zk0hd9ec6k0a0yc/Kaktus-v1.2.0.zip?dl=0)
18 |
19 | #### Keyboard Shortcuts:
20 | * Command+T: New Tab
21 | * Control+Space: Open Menu
22 | * Command+O: Focus Mode
23 | * Shift+Command+F: Full Screen
24 | * Shift+Command+N: Open New Window with Privacy Mode
25 | * Command+F: Find in the page
26 | * Command+W: Close Tab
27 |
28 | ## How It Works
29 |
30 | * [Tabbing]()
31 |
32 | ## Roadmap
33 |
34 | What is missing ?
35 | * A logo
36 | * Download manager
37 | * PDF Support
38 | * Right-click Context Menu
39 |
40 | ## Call For Designers
41 |
42 | Kaktüs needs a lot of help on design. Here is what's missing;
43 |
44 | * **Logo:** Current logo is something I had to pick up from Dribble (with the permission of the designer) and it's temporary.
45 | * **Icons:** Kaktüs needs an icon set to interact with user better/
46 | * **Ideas:** Kaktüs needs your ideas on how to improve tabbing and search.
47 |
48 | ## Development
49 |
50 | #### Building From Source
51 |
52 | Install all dependencies:
53 |
54 | ```bash
55 | npm install
56 | ```
57 |
58 | And get the build out for your target platform. Available platforms are:
59 |
60 | * macOS
61 | * win
62 | * linux
63 |
64 | So, I usually run;
65 |
66 | ```
67 | make osx
68 | ```
69 |
70 | Command to get my build for macOS.
71 |
72 | #### Making Changes
73 | Kaktüs is built with [choo](https://github.com/yoshuawuyts/choo) and [electron](https://github.com/electron/electron). Here is the commands I run to start the development:
74 |
75 | ```bash
76 | $ make watch-css
77 | $ make watch-js
78 | $ make start
79 | ```
80 |
81 | ## Logo
82 |
83 | I'm temporarily using [a cactus image I found on Dribble](https://dribbble.com/shots/1842263-Cactus) as a logo.
84 |
--------------------------------------------------------------------------------
/src/app/models/tabs/reducers.js:
--------------------------------------------------------------------------------
1 | const change = require("change-object")
2 | const titleFromURL = require("title-from-url")
3 | const Tab = require("./tab")
4 | const parseURL = require("url").parse
5 | const urls = require("../../urls")
6 |
7 | module.exports = {
8 | openURL,
9 | startLoading,
10 | stopLoading,
11 | update,
12 | setSelectedId,
13 | setTabAsClosed,
14 | create,
15 | setState
16 | }
17 |
18 | function openURL (payload) {
19 | return changeTab(payload.tab, newURLProps(urls.normalize(payload.url)))
20 | }
21 |
22 | function setSelectedId (selectedId) {
23 | return {
24 | selectedId
25 | }
26 | }
27 |
28 | function update (payload) {
29 | return changeTab(payload.tab, payload.props)
30 | }
31 |
32 | function startLoading (payload) {
33 | return changeTab(payload.tab, payload.props)
34 | }
35 |
36 | function stopLoading (payload) {
37 | return changeTab(payload.tab, payload.props)
38 | }
39 |
40 | function changeTab (tab, updates) {
41 | var result = {}
42 | var changed = false
43 | result[tab.id] = tab
44 |
45 | for (let key in updates) {
46 | if (tab[key] === updates[key]) continue
47 | result[tab.id][key] = updates[key]
48 |
49 | if (key === 'url' && /^\w+:\/\//.test(updates[key])) {
50 | result[tab.id].webviewURL = updates.url
51 | result[tab.id].protocol = urls.protocol(updates.url)
52 | result[tab.id].url = urls.clean(updates.url)
53 | }
54 |
55 | changed = true
56 | }
57 |
58 | if (!changed) return
59 |
60 | return result
61 | }
62 |
63 | function setTabAsClosed (id) {
64 | var result = {}
65 | result[id] = null
66 | return result
67 | }
68 |
69 | function create (options) {
70 | var result = {}
71 | var tab = new Tab(options.id, urls.protocol(options.url), urls.clean(options.url))
72 |
73 | if (options.select) {
74 | result['selectedId'] = tab.id
75 | }
76 |
77 | result[tab.id] = tab
78 |
79 | return result
80 | }
81 |
82 | function newURLProps (url) {
83 | return {
84 | protocol: urls.protocol(url),
85 | url: urls.clean(url),
86 | webviewURL: url,
87 | title: titleFromURL(url),
88 | description: '',
89 | icon: '',
90 | image: undefined,
91 | error: null,
92 | isDOMReady: false,
93 | isNew: false
94 | }
95 | }
96 |
97 | function setState (state) {
98 | return state
99 | }
100 |
--------------------------------------------------------------------------------
/src/app/search/recent.js:
--------------------------------------------------------------------------------
1 | const tabs = require("../db/tabs")
2 | const history = require("../db/history")
3 | const likes = require("../db/likes")
4 | const meta = require("../db/meta")
5 | const domains = require("../db/domains")
6 | const popular = require("./popular")
7 |
8 | const embed = require("../db/embed")
9 | const filters = require("./filters")
10 | const maps = require("./maps")
11 | const sort = require("./sort")
12 |
13 | const RECENT_RESULTS_MIN_LEN = 15
14 |
15 | module.exports = recent
16 |
17 | // Returns all open tabs, and may include some items from likes and history if there is room for stuff
18 | function recent (callback) {
19 | allTabs((error, tabs) => {
20 | if (error) return callback(error)
21 | if (tabs.length >= RECENT_RESULTS_MIN_LEN) return callback(undefined, tabs.sort(sort))
22 |
23 | popular((error, popularSites) => {
24 | if (error) return callback(error)
25 |
26 | recentLikes((error, likes) => {
27 | if (error) return callback(error)
28 |
29 | recentHistory((error, history) => {
30 | if (error) return callback(error)
31 |
32 | const combined = combine([
33 | { list: tabs },
34 | { list: likes, max: 3 },
35 | { list: history, max: 5 },
36 | { list: popularSites, max: 3 }
37 | ])
38 |
39 | callback(undefined, combined.sort(sort).slice(0, 15))
40 | })
41 | })
42 | })
43 | })
44 | }
45 |
46 | // basically all tabs
47 | function allTabs (callback) {
48 | let result = []
49 |
50 | embed(tabs.all, [likes, meta, history, domains], function (error, rows) {
51 | if (error) return callback(error)
52 |
53 | callback(undefined, rows.map(maps.tab))
54 | })
55 | }
56 |
57 | function recentHistory (callback) {
58 | embed(history.all, [{ limit: 50 }], [likes, meta, tabs, domains], function (error, rows) {
59 | if (error) return callback(error)
60 | callback(undefined, rows.filter(r => !r.domain || !r.domain.privateMode).map(maps.record))
61 | })
62 | }
63 |
64 | function recentLikes (callback) {
65 | embed(likes.all, [{ limit: 50 }], [history, meta, tabs, domains], function (error, rows) {
66 | if (error) return callback(error)
67 | callback(undefined, rows.map(maps.like))
68 | })
69 | }
70 |
71 | function combine (lists) {
72 | const added = {}
73 | const result = []
74 |
75 | for (l of lists) {
76 | let i = -1
77 | let len = l.list.length
78 |
79 | while (++i < len) {
80 | let row = l.list[i]
81 | if (!row || (row.tab && row.tab.url.trim() === "")) continue
82 | if (added[row.url]) continue
83 | if (l.max && l.counter >= l.max) break
84 | if (!l.counter) l.counter = 0
85 |
86 | l.counter++
87 | added[row.url] = true
88 | result.push(row)
89 | }
90 | }
91 |
92 | return result
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/views/top-bar/search-bar.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const debounce = require("debounce-fn")
3 | const movementButtons = require("./movement-buttons")
4 | const searchResults = require("../search-results")
5 | const createTitleBar = require('../title-bar')
6 | let search
7 |
8 | const input = (state, prev, send) => {
9 | const selectedTab = state.tabs[state.tabs.selectedId]
10 | search || (search = _search(send))
11 |
12 | return html`
13 |
19 | `
20 | }
21 |
22 | const searchBar = (state, prev, send) => {
23 | const titleBar = createTitleBar(input(state, prev, send))
24 |
25 | return html`
26 |
27 | ${movementButtons(state, prev, send)}
28 | ${titleBar(state, prev, send)}
29 | ${searchResults(state, prev, send)}
30 |
`
31 | }
32 |
33 | module.exports = searchBar
34 |
35 | function onBlur (state, prev, send) {
36 | return function (event) {
37 | var classes = event.target.classList
38 | if (!classes.contains("navigation-bar") && !classes.contains("movement-buttons")) {
39 | return
40 | }
41 |
42 | if (state.tabs[state.tabs.selectedId].isNew) return
43 |
44 | send('search:quit')
45 | }
46 | }
47 |
48 | function onKeyUp (state, prev, send) {
49 | return function (e) {
50 | if (e.keyCode === 38) {
51 | e.stopPropagation()
52 | e.preventDefault()
53 | send('search:up')
54 | return false
55 | }
56 |
57 | if (e.keyCode === 40) {
58 | e.stopPropagation()
59 | e.preventDefault()
60 | send('search:down')
61 | return false
62 | }
63 |
64 | if (e.keyCode === 13) {
65 | send('search:quit')
66 |
67 | if (selectedTab(state)) {
68 | return send('tabs:select', state.search.preview.tab.id)
69 | }
70 |
71 | return send('tabs:go', {
72 | url: e.target.value,
73 | tab: state.tabs[state.tabs.selectedId]
74 | })
75 | }
76 |
77 | if (e.keyCode === 27) {
78 | return send('search:quit')
79 | }
80 | }
81 | }
82 |
83 | function onInput (tab, search, send) {
84 | return function (e) {
85 | send('search:setQuery', e.target.value)
86 | search(e.target.value)
87 | }
88 | }
89 |
90 | function selectedTab (state) {
91 | const preview = state.search.preview
92 | if (!preview) return false
93 |
94 | const isPreviewTab = preview.tab
95 | if (!isPreviewTab) return false
96 |
97 | return state.search.preview.tab.id !== state.tabs.selectedId
98 | }
99 |
100 | function _search (send) {
101 | return debounce(function (query) {
102 | send('search:search', {
103 | query
104 | })
105 | })
106 | }
107 |
--------------------------------------------------------------------------------
/src/app/db/history.js:
--------------------------------------------------------------------------------
1 | const db = require("./db")
2 | const urls = require("../urls")
3 | const meta = require("./meta")
4 | const recommended = require("../../recommended")
5 | const loop = require("parallel-loop")
6 | const store = db.store('history', {
7 | key: { keyPath: "url" },
8 | indexes: [
9 | { name: 'lastUpdatedAt', options: { unique: false } },
10 | { name: 'counter', options: { unique: false } }
11 | ]
12 | })
13 |
14 | module.exports = {
15 | foreignName: 'record',
16 | foreignKey: 'url',
17 | store,
18 | visit,
19 | get,
20 | all,
21 | select,
22 | popular
23 | }
24 |
25 | function visit (url, callback) {
26 | save({
27 | protocol: urls.protocol(url),
28 | url: urls.clean(url),
29 | webviewURL: url,
30 | lastUpdatedAt: Date.now()
31 | }, callback)
32 | }
33 |
34 | function save (props, callback) {
35 | store.get(props.url, function (error, existing) {
36 | if (error) return callback(error)
37 | if (existing) {
38 | props.counter = (existing.counter || 0) + 1
39 | return store.update(props, callback)
40 | }
41 |
42 | store.add(props, callback)
43 | })
44 | }
45 |
46 | function all (options, callback) {
47 | options.index = options.index || 'lastUpdatedAt'
48 | select(options, callback)
49 | }
50 |
51 | function popular (options, callback) {
52 | if (!options) {
53 | options = {}
54 | }
55 |
56 | options.index = 'counter'
57 |
58 | select(options, (error, rows) => {
59 | if (error) return callback(error)
60 |
61 | if (rows && rows.length) return callback(undefined, rows)
62 |
63 | addRecommendedSites((error) => {
64 | if (error) return callback(error)
65 |
66 | popular(options, callback)
67 | })
68 | })
69 | }
70 |
71 | function select (options, callback) {
72 | const result = []
73 | const limit = options.limit || 25
74 | const range = options.range || null
75 | const direction = options.direction || 'prev'
76 |
77 | store.selectRange(options.index, range, direction, (error, row) => {
78 | if (error) return callback(error)
79 | if (!row) return callback(undefined, result)
80 |
81 | result.push(row.value)
82 |
83 | if (result.length >= limit) {
84 | return callback(undefined, result)
85 | }
86 |
87 | row.continue()
88 | })
89 | }
90 |
91 | function get (url, callback) {
92 | store.get(url, callback)
93 | }
94 |
95 | function addRecommendedSites (callback) {
96 | loop(recommended.length, each, callback)
97 |
98 | function each (done, index) {
99 | recommended[index].isPopularRecord = true
100 |
101 | save({
102 | protocol: urls.protocol(recommended[index].url),
103 | url: urls.clean(recommended[index].url),
104 | webviewURL: recommended[index].url,
105 | lastUpdatedAt: Date.now() - (index * 1000),
106 | counter: 5,
107 | isPopularRecord: true
108 | }, error => {
109 | if (error) return done(error)
110 | meta.save(recommended[index], done)
111 | })
112 |
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/src/app/views/webviews/error.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const format = require("format-text")
3 | const parseURL = require('url').parse
4 |
5 | const error6 = (props) => html`
6 | ${props.url} was not found.
7 | `
8 |
9 | const error21 = (props) => html`
10 | Can not access the network. Check your internet connection please.
11 | `
12 |
13 | const error101 = (props) => html`
14 | Can not connect to ${props.hostname}. Check your connection, and reload this page later.
15 | `
16 |
17 | const error102 = (props) => html`
18 | ${props.hostname} refused to connect.
19 | `
20 |
21 | const error109 = (props) => html`
22 | ${props.hostname} is not available. Please check your connection and firewall.
23 | `
24 |
25 | const error105 = (props) => html`
26 | ${props.hostname} can't be reached :( Are you sure it's the right address?
27 | `
28 |
29 | const error106 = (props) => html`
30 | It looks like you're not connected to internet currently.
31 | `
32 |
33 | const error501 = (props) => html`
34 | ${props.hostname} has tried to load insecure resources.
35 | `
36 |
37 | const error312 = (props) => html`
38 | ${props.hostname} is not reachable because the port :${props.port} is not safe.
39 | `
40 |
41 | const error324 = (props) => html`
42 | ${props.hostname} can't be reached :( It refused to connect. Please check your internet connection and proxy/firewall configuration.
43 | `
44 |
45 | const crash = (props) => html`
46 | ${props.hostname} has just crashed.
47 | `
48 |
49 | const generic = (props) => html`
50 | ${props.hostname} has generated error that Kaktüs is not familiar with yet.
51 | `
52 |
53 | const templates = {
54 | '-6': error6,
55 | '-21': error21,
56 | '-101': error101,
57 | '-102': error102,
58 | '-105': error105,
59 | '-106': error106,
60 | '-109': error109,
61 | '-312': error312,
62 | '-324': error324,
63 | '-501': error501,
64 | 'crash': crash
65 | }
66 |
67 | module.exports = render
68 |
69 | function render (error, tab, prev, send) {
70 | var template = templates[error.code] || generic
71 |
72 | return html`
73 |
74 |
75 |
⟳
76 |
Error
77 |
${template(props(error, tab))}
78 |
${error.code} - ${error.description}
79 |
80 |
81 | `
82 | }
83 |
84 | function props (error, tab) {
85 | return {
86 | hostname: hostname(error.url),
87 | port: parseURL(error.url).port,
88 | url: error.url,
89 | tab,
90 | error
91 | }
92 | }
93 |
94 | function reload (tab, send) {
95 | return function () {
96 | send('tabs:reload', {
97 | tab
98 | })
99 | }
100 | }
101 |
102 | function hostname (url) {
103 | return parseURL(url).hostname
104 | }
105 |
--------------------------------------------------------------------------------
/src/app/views/title-bar/style.css:
--------------------------------------------------------------------------------
1 | .title-bar {
2 | position: relative;
3 | top: 9px;
4 | width: calc(50% - 10px);
5 | margin: 0 auto;
6 | padding: 5px;
7 | background: #fff;
8 | border-radius: 3px;
9 | box-shadow: 1px 2px #e5e5e5;
10 | opacity: 0.8;
11 | }
12 |
13 | .title-bar .search-icon {
14 | color: #aaa;
15 | position: absolute;
16 | width: 22px;
17 | height: 22px;
18 | margin: 1px 2px;
19 | }
20 |
21 | .surfing-bar:hover .title-bar, .navigation-bar .title-bar {
22 | opacity: 1;
23 | }
24 |
25 | .title-bar .title-text {
26 | background: no-repeat 3px 1px;
27 | background-size: 20px 20px;
28 | text-align: center;
29 | font: 500 16px "San Francisco", Helvetica;
30 | color: #333;
31 | overflow: hidden;
32 | text-overflow: ellipsis;
33 | white-space: nowrap;
34 | width: 85%;
35 | margin: 0 auto;
36 | height: 19px;
37 | }
38 |
39 | .title-bar .page-icon {
40 | position: absolute;
41 | width: 22px;
42 | height: 22px;
43 | margin-top: -2px;
44 | }
45 |
46 | .title-bar .page-button {
47 | padding-top: 3px;
48 | text-align: center;
49 | display: none;
50 | color: #666;
51 | font: 500 18px Arial, sans-serif;
52 | }
53 |
54 | .title-bar .page-button:hover {
55 | color: #333;
56 | }
57 |
58 | .title-bar .page-button.reload {
59 | padding-top: -2px;
60 | margin-top: -5px;
61 | }
62 |
63 | .top-bar:hover .title-bar .page-button, .navigation-bar .page-button {
64 | display: block;
65 | }
66 |
67 | .top-bar:hover .title-bar .spinner, .top-bar:hover .title-bar .favicon, .navigation-bar .spinner, .navigation-bar .favicon {
68 | display: none;
69 | }
70 |
71 | .title-bar .like-button, .title-bar .create-tab-button, .title-bar .quit-find-in-page-button, .title-bar .private-mode-icon {
72 | position: absolute;;
73 | right: 7px;
74 | top: 7px;
75 | cursor: pointer;
76 | color: rgb(180, 180, 180);
77 | }
78 |
79 | .title-bar .create-tab-button:hover {
80 | color: rgb(80, 80, 80);
81 | }
82 |
83 | .title-bar .like-button:hover {
84 | color: rgb(80, 80, 80);
85 | }
86 |
87 | .title-bar .liked.like-button, .title-bar .like-button.liked:hover {
88 | color: rgb(225, 25, 0);
89 | }
90 |
91 |
92 |
93 | .title-bar input {
94 | font: 500 16px "San Francisco", Helvetica;
95 | border: 0;
96 | width: calc(100% - 50px);
97 | outline: none;
98 | -webkit-font-smoothing: inherit;
99 | color: #666;
100 | height: 17px;
101 | display: block;
102 | padding-left: 25px;
103 | }
104 |
105 | .title-bar input::selection {
106 | color: #fff;
107 | background: #ff84a6;
108 | padding: 2px;
109 | }
110 |
111 | .title-bar input:focus {
112 | color: #333;
113 | }
114 |
115 | .title-bar .favicon {
116 | background: no-repeat center;
117 | background-size: 21px 21px;
118 | width: 100%;
119 | height: 100%;
120 | }
121 |
122 | .navigation-bar .title-bar {
123 | border-bottom-left-radius: 0;
124 | border-bottom-right-radius: 0;
125 | }
126 |
127 | .title-bar.find-in-page .input-caption {
128 | position: absolute;
129 | color: #666;
130 | font: 400 16px "San Francisco", Helvetica;
131 | margin: 0 0 0 25px;
132 | }
133 |
134 | .title-bar.find-in-page .query {
135 | width: calc(100% - 117px);
136 | padding-left: 117px;
137 | }
138 |
--------------------------------------------------------------------------------
/src/app/db/tabs.js:
--------------------------------------------------------------------------------
1 | const now = require("unique-now")
2 | const db = require("./db")
3 | const urls = require("../urls")
4 |
5 | const store = db.store('tabs', {
6 | key: { keyPath: "id" },
7 | indexes: [
8 | { name: 'isSelected', options: { unique: false } },
9 | { name: 'createdAt', options: { unique: false } },
10 | { name: 'lastSeenAt', options: { unique: false } },
11 | { name: 'url', options: { unique: false } }
12 | ]
13 | })
14 |
15 | module.exports = {
16 | foreignName: 'tab',
17 | foreignKey: 'url',
18 | store,
19 | all,
20 | create,
21 | select,
22 | unselect,
23 | updateURL,
24 | close,
25 | get
26 | }
27 |
28 | function all (callback) {
29 | const result = []
30 |
31 | store.cursor((error, row) => {
32 | if (error) return callback(error)
33 | if (!row) return callback(undefined, result.sort(compareLastSeen))
34 |
35 | result.push(row.value)
36 | row.continue()
37 | })
38 | }
39 |
40 | function create (url, callback) {
41 | if (arguments.length === 1) {
42 | callback = url
43 | url = ''
44 | }
45 |
46 | if (!url || !url.trim()) {
47 | url = ''
48 | }
49 |
50 | get(url, (error, tab) => {
51 | if (error) return callback(error)
52 | if (!tab) return _create(url, callback)
53 | callback(undefined, tab.id)
54 | })
55 | }
56 |
57 |
58 | function _create (url, callback) {
59 | store.add({
60 | id: generateId(),
61 | createdAt: Date.now(),
62 | isSelected: 1,
63 | url
64 | }, callback)
65 | }
66 |
67 | function select (id, callback) {
68 | unselect(error => {
69 | if (error) return callback(error)
70 | _select(id, callback)
71 | })
72 | }
73 |
74 | function unselect (callback) {
75 | console.log('unselecting ...')
76 |
77 | store.select('isSelected', 1, (error, selectedTab) => {
78 | if (error) return callback(error)
79 |
80 | if (!selectedTab) return callback()
81 |
82 | console.log('update %s from %d to %d', selectedTab.url, selectedTab.lastSeenAt, Date.now())
83 | selectedTab.lastSeenAt = Date.now()
84 | selectedTab.isSelected = 0
85 | store.update(selectedTab, callback)
86 | })
87 | }
88 |
89 | function get (url, callback) {
90 | store.select('url', url, callback)
91 | }
92 |
93 | function updateURL (id, newURL, callback) {
94 | store.get(id, (error, rec) => {
95 | if (error) return callback(error)
96 |
97 | rec.url = urls.clean(newURL)
98 | store.update(rec, callback)
99 | })
100 | }
101 |
102 | function close (url, callback) {
103 | store.delete(urls.clean(url), callback)
104 | }
105 |
106 | function _select (id, callback) {
107 | store.get(id, function (error, rec) {
108 | if (error) return callback(error)
109 | if (!rec) return callback(new Error('Unexisting tab record.'))
110 |
111 | rec.isSelected = 1
112 | rec.lastSeenAt = Date.now()
113 |
114 | store.update(rec, callback)
115 | })
116 | }
117 |
118 | function compareLastSeen (a, b) {
119 | if (a.lastSeenAt < b.lastSeenAt || b.isSelected) {
120 | return -1
121 | }
122 |
123 | if (a.lastSeenAt > b.lastSeenAt || a.isSelected) {
124 | return 1
125 | }
126 |
127 | return 0
128 | }
129 |
130 | function generateId () {
131 | return `f-${now()}`
132 | }
133 |
--------------------------------------------------------------------------------
/src/app/db/meta.js:
--------------------------------------------------------------------------------
1 | const mix = require("mix-objects")
2 | const lessCommonWords = require("less-common-words")
3 | const titleFromURL = require("title-from-url");
4 | const anglicize = require("anglicize")
5 | const uniques = require("uniques")
6 | const loop = require("parallel-loop")
7 |
8 | const db = require("./db")
9 | const urls = require("../urls")
10 | const store = db.store('meta', {
11 | key: { keyPath: "metaURL" },
12 | indexes: [
13 | { name: 'url', options: { unique: true } },
14 | { name: 'tags', options: { multiEntry: true, unique: false } }
15 | ]
16 | })
17 |
18 | module.exports = {
19 | foreignName: 'meta',
20 | foreignKey: 'url',
21 | store,
22 | save,
23 | search,
24 | get,
25 | draft
26 | }
27 |
28 | function save (props, callback) {
29 | props.url = urls.clean(props.url)
30 | props.metaURL = urls.meta(props.url)
31 | props.lastUpdatedAt = Date.now()
32 |
33 | store.get(props.metaURL, function (error, existing) {
34 | if (error) return callback(error)
35 |
36 | if (existing) {
37 | let mixed = mix(props, [existing])
38 | mixed.tags = extractTags(mixed)
39 | return store.update(mixed, callback)
40 | }
41 |
42 | props.tags = extractTags(props)
43 | store.add(props, callback)
44 | })
45 | }
46 |
47 | function _search (query, each, done) {
48 | store.selectRange('tags', { only: query }, function (error, result) {
49 | if (error) return callback(error)
50 | if (result) {
51 | each(result.value)
52 | return result.continue()
53 | }
54 |
55 | store.selectRange('url', { from: query, to: query + 'uffff' }, function (error, result) {
56 | if (error) return callback(error)
57 | if (result) {
58 | each(result.value)
59 | return result.continue()
60 | }
61 |
62 | done()
63 | })
64 | })
65 | }
66 |
67 | function search (query, callback) {
68 | var keywords = query.trim().split(/[^\w]+/).filter(q => q.length)
69 | const rows = []
70 | const added = {}
71 |
72 | console.log('Searching ', keywords)
73 |
74 | loop(keywords.length, each, errors => {
75 | if (errors) return callback(errors[0])
76 | callback(undefined, rows)
77 | })
78 |
79 | function each (done, index) {
80 | _search(keywords[index], add, done)
81 | }
82 |
83 | function add (row) {
84 | if (added[row.url]) return
85 |
86 | added[row.url] = true
87 | rows.push(row)
88 | }
89 | }
90 |
91 | function get (url, callback) {
92 | store.get(urls.meta(url), (error, result) => {
93 | callback(error, result)
94 | })
95 | }
96 |
97 | function extractTags (props) {
98 | const all = extractTagsFromURL(props.url)
99 | .concat(extractTagsFromText(props.title || ''))
100 | .concat(extractTagsFromText(props.description || ''))
101 | .concat(extractTagsFromText(props.keywords || ''))
102 |
103 | return uniques(all)
104 | }
105 |
106 | function extractTagsFromText (text) {
107 | return lessCommonWords(anglicize(text.toLowerCase()))
108 | }
109 |
110 | function extractTagsFromURL (url) {
111 | url = url.replace(/\.(com|net|org)(\/|$|\?|\#)/, '$2')
112 | url = url.replace(/^\w+:\/\//, '')
113 | return lessCommonWords(url)
114 | }
115 |
116 | function draft (url) {
117 | return {
118 | url: url,
119 | title: url ? titleFromURL(url) : 'New Tab',
120 | description: '',
121 | icon: '',
122 | image: '',
123 | tags: ''
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/window-manager.js:
--------------------------------------------------------------------------------
1 | const { BrowserWindow, ipcMain } = require('electron')
2 | const createOSMenu = require('./os-menu')
3 | const partition = require("./app/partition")
4 |
5 | const DEV_MODE = process.env.DEV_MODE === 'ON'
6 |
7 | const defaultOptions = {
8 | title: "Kaktüs",
9 | width: 1000,
10 | height: 600,
11 | titleBarStyle: 'hidden-inset',
12 | icon: `file://${__dirname}/icon.png`,
13 | webPreferences: {
14 | partition: partition.window()
15 | }
16 | }
17 |
18 | const privateOptions = {
19 | title: "Kaktüs Private",
20 | width: 1000,
21 | height: 600,
22 | titleBarStyle: 'hidden-inset',
23 | icon: `file://${__dirname}/icon.png`,
24 | webPreferences: {
25 | partition: partition.window(true)
26 | }
27 | }
28 |
29 | class WindowManager {
30 | constructor (app) {
31 | this.app = app
32 | this.counter = 0
33 | this.focusMode = false
34 | this.windows = []
35 | this.developerMode = DEV_MODE
36 |
37 | this.app.on('ready', () => {
38 | this.createWindow()
39 | createOSMenu(this)
40 | })
41 |
42 | this.app.on('window-all-closed', this.onWindowsClose.bind(this))
43 | this.app.on('activate', this.onActivate.bind(this))
44 |
45 |
46 | }
47 |
48 | _createWindow (options, actions) {
49 | const win = new BrowserWindow(options)
50 | win.loadURL(`file://${__dirname}/../build/${ this.developerMode ? "index.dev" : "index" }.html`)
51 |
52 | const ind = this.windows.push(win) - 1
53 | this.counter++
54 |
55 | win.on('closed', () => {
56 | this.windows[ind] = null
57 | this.counter--
58 | })
59 |
60 | actions.push({ name: 'general:setFocusMode', payload: this.focusMode })
61 |
62 | if (this.counter === 1) {
63 | actions.push({ name: 'tabs:recoverTabs', payload: null })
64 | } else {
65 | actions.push({ name: 'tabs:newTab', payload: null })
66 | }
67 |
68 | win.webContents.on('did-finish-load', () => {
69 | actions.forEach(a => this._send(win, a.name, a.payload))
70 | })
71 |
72 | if (this.developerMode) {
73 | win.webContents.openDevTools()
74 | }
75 |
76 | return win
77 | }
78 |
79 | createWindow () {
80 | return this._createWindow(defaultOptions, [])
81 | }
82 |
83 | createPrivateWindow () {
84 | const partitionName = partition.window(true)
85 | privateOptions.webPreferences.partition = partitionName
86 |
87 | return this._createWindow(privateOptions, [
88 | { name: 'general:setPrivateMode', payload: true },
89 | { name: 'general:setPartitionName', payload: partitionName }
90 | ])
91 | }
92 |
93 | focusedWindow () {
94 | return BrowserWindow.getFocusedWindow() || this.createWindow()
95 | }
96 |
97 | _send (window, name, payload) {
98 | window.webContents.send('action', {
99 | name,
100 | payload
101 | })
102 | }
103 |
104 | sendFn (name, payload) {
105 | return e => {
106 | this.send(name, payload)
107 | }
108 | }
109 |
110 | send (name, payload) {
111 | this._send(this.focusedWindow(), name, payload)
112 | }
113 |
114 | sendAllWindows (name, payload) {
115 | let i = this.windows.length
116 | while (i--) {
117 | if (this.windows[i] === null) continue
118 | this._send(this.windows[i], name, payload)
119 | }
120 | }
121 |
122 | toggleFocusMode () {
123 | this.focusMode = !this.focusMode
124 | this.sendAllWindows('general:setFocusMode', this.focusMode)
125 | }
126 |
127 | onWindowsClose () {
128 | if (process.platform !== 'darwin') {
129 | this.app.quit()
130 | }
131 | }
132 |
133 | onActivate () {
134 | if (this.counter < 1) {
135 | this.createWindow()
136 | }
137 | }
138 | }
139 |
140 | module.exports = WindowManager
141 |
--------------------------------------------------------------------------------
/src/app/views/search-results/style.css:
--------------------------------------------------------------------------------
1 | .search-results {
2 | position: relative;
3 | background: #fff;
4 | width: 50%;
5 | margin-top: 9px;
6 | left: calc(25% - 1px);
7 | border-radius: 3px;
8 | box-shadow: 1px 2px #e5e5e5;
9 | border-top: 1px solid #e5e5e5;
10 | border-top-left-radius: 0;
11 | border-top-right-radius: 0;
12 | border-left: 1px solid #eee;
13 | }
14 |
15 | .search-results .rows {
16 | float: left;
17 | width: calc(60% - 1px);
18 | border-right: 1px solid #eee;
19 | }
20 |
21 | .search-results .rows .row {
22 | overflow: hidden;
23 | text-overflow: ellipsis;
24 | white-space: nowrap;
25 | filter: grayscale(1) opacity(0.9);
26 | }
27 |
28 | .search-results .rows .row.tab, .search-results .rows .row.selected {
29 | filter: grayscale(0) opacity(1);
30 | }
31 |
32 | /*.search-results .rows .row.selected {
33 | background-color: rgba(100, 100, 100, 0.03);
34 | }*/
35 |
36 | .search-results .rows .row.selected, .search-results .rows .row.selected .row-text:hover {
37 | cursor: pointer;
38 | background-color: #e5e5e5;
39 | }
40 |
41 | .search-results .rows .row.tab.selected, .search-results .rows .row.tab.selected .row-text:hover {
42 | cursor: pointer;
43 | background-color: #0dc7da;
44 | }
45 |
46 | .search-results .rows .row.selected:hover {
47 | background-color: #0dc7da;
48 | }
49 |
50 | .search-results .rows .row.selected:hover .row-text, .search-results .rows .row.selected:hover .search-row-icon, .search-results .rows .row.selected:hover .close-button {
51 | color: #fff;
52 | text-shadow: 1px 1px rgba(0, 0, 0, 0.1);
53 | }
54 |
55 | .search-results .rows .row.selected .row-text:hover {
56 | color: #444;
57 | }
58 |
59 | .search-results .rows .row.tab.selected:hover {
60 | background-color: rgba(255, 71, 0, 0.75);
61 | }
62 |
63 | .search-results .rows .row .row-text {
64 | font: 500 14px "San Francisco", Helvetica;
65 | padding: 7px 5px 7px 30px;
66 | color: #444;
67 | background: no-repeat 5px 6px;
68 | background-size: 20px 20px;
69 | }
70 |
71 | .search-results .rows .row.tab.selected .row-text {
72 | color: #444;
73 | text-shadow: 1px 1px rgba(0, 0, 0, 0.1);
74 | width: 100%;
75 | }
76 |
77 | .search-results .rows .row.tab.selected .row-text {
78 | color: #fff;
79 | text-shadow: 1px 1px rgba(0, 0, 0, 0.1);
80 | }
81 |
82 | .search-results .rows .row .row-text-wrapper {
83 | overflow: hidden;
84 | text-overflow: ellipsis;
85 | white-space: nowrap;
86 | width: calc(100% - 5px);
87 | }
88 |
89 | .search-results .rows .row.selected .row-text-wrapper {
90 | overflow: hidden;
91 | text-overflow: ellipsis;
92 | white-space: nowrap;
93 | width: calc(100% - 60px);
94 | }
95 |
96 | .search-results .rows .row .row-button {
97 | position: absolute;
98 | font: 900 18px Arial, sans-serif;
99 | color: rgb(255, 255, 255);
100 | padding: 5px 5px;
101 | left: calc(60% - 30px);
102 | margin-top: -30px;
103 | }
104 |
105 | .search-results .rows .row .open-in-new-tab-button {
106 | color: #bdbdbd;
107 | }
108 |
109 | .search-results .rows .open-in-new-tab-button:hover {
110 | color: #fff;
111 | text-shadow: 1px 1px rgba(0, 0, 0, 0.1);
112 | }
113 |
114 | .search-results .rows .row.selected.closing .row-button {
115 | color: #fff;
116 | text-shadow: 1px 1px rgba(0, 0, 0, 0.1);
117 | }
118 |
119 | .search-results .no-results {
120 | font: 500 14px "San Francisco", Helvetica;
121 | padding: 5px;
122 | color: #666;
123 | }
124 |
125 | .search-results .search-row-icon {
126 | position: absolute;
127 | margin-left: -20px;
128 | color: #999;
129 | }
130 |
131 | .search-results .separator {
132 | padding: 3px 10px;
133 | color: #aaa;
134 | background: #fafafa;
135 | font: 500 14px "San Francisco", Helvetica;
136 | }
137 |
138 | .search-results .separator i {
139 | margin-right: 5px;
140 | color: #ccc;
141 | }
142 |
--------------------------------------------------------------------------------
/src/app/views/preview/url/buttons.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const button = require("./button")
3 | const urls = require("../../../urls")
4 | const clipboard = electronRequire("electron").clipboard
5 |
6 | const likeButton = button({ title: 'Like', 'icon': 'heart', onclick: like })
7 | const unlikeButton = button({ title: 'Liked', classes: ['active'], icon: 'heart', onclick: unlike })
8 | const enablePrivateModeButton = button({ title: 'Private Mode', icon: 'user-secret', onclick: enablePrivateMode })
9 | const disablePrivateModeButton = button({ title: 'Private Mode', classes: ['active'], icon: 'user-secret', onclick: disablePrivateMode })
10 | const enableNineteensModeButton = button({ title: '90s Mode', icon: 'record-vinyl', onclick: enablePrivateMode })
11 | const disableNineteensModeButton = button({ title: '90s Mode', classes: ['nineteens-mode active'], icon: 'record-vinyl', onclick: disablePrivateMode })
12 | const removeFromHistoryButton = button({ title: 'Remove From History', icon: 'trash-o', onclick: removeFromHistory })
13 | const muteButton = button({ title: 'Mute', icon: 'volume-up', onclick: mute })
14 | const unmuteButton = button({ title: 'Muted', icon: 'volume-off', onclick: unmute })
15 | const openInNewTabButton = button({ title: 'Open In New Tab', icon: 'plus', onclick: openInNewTab })
16 | const openButton = button({ title: 'Open', icon: 'link', onclick: openInSameTab })
17 | const closeTabButton = button({ title: 'Close', icon: 'close', onclick: closeTab })
18 | const copyURLButton = button({ title: 'Copy URL', icon: 'copy', onclick: copyURL })
19 |
20 | const view = (state, prev, send) => html`
21 |
22 | ${buttons(state).filter(b => !!b).map(b => b(state.search.preview, prev, send))}
23 |
24 | `
25 |
26 | module.exports = view
27 |
28 | function buttons (state) {
29 | if (state.search.preview.tab && state.tabs[state.search.preview.tab.id]) return tabButtons(state)
30 |
31 | const preview = state.search.preview
32 | const domain = state.domains[urls.domain(preview.url)]
33 | const selectedTab = state.tabs[state.tabs.selectedId]
34 |
35 | return [
36 | selectedTab.url !== preview.url ? openButton : null,
37 | selectedTab.isNew && selectedTab.url !== preview.url ? null : openInNewTabButton,
38 | state.likes[state.search.preview.url] ? unlikeButton : likeButton,
39 | domain && domain.privateMode ? disablePrivateModeButton : enablePrivateModeButton,
40 | domain && domain.privateMode ? disableNineteensModeButton : enableNineteensModeButton,
41 | copyURLButton
42 | ]
43 | }
44 |
45 | function tabButtons (state) {
46 | const domain = state.domains[urls.domain(state.search.preview.url)]
47 | const tab = state.tabs[state.search.preview.tab.id]
48 |
49 | const result = [
50 | state.likes[state.search.preview.url] ? unlikeButton : likeButton,
51 | domain && domain.privateMode ? disablePrivateModeButton : enablePrivateModeButton,
52 | domain && domain.privateMode ? disableNineteensModeButton : enableNineteensModeButton,
53 | closeTabButton
54 | ]
55 |
56 | if (tab.isPlayingMedia) {
57 | result.push(tab.isMuted ? unmuteButton : muteButton)
58 | }
59 |
60 | result.push(copyURLButton)
61 |
62 | return result
63 | }
64 |
65 | function like (row, prev, send) {
66 | send('likes:like', row.url)
67 | }
68 |
69 | function unlike (row, prev, send) {
70 | send('likes:unlike', row.url)
71 | }
72 |
73 | function enablePrivateMode (row, prev, send) {
74 | send('domains:enablePrivateMode', row.url, send)
75 | }
76 |
77 | function disablePrivateMode (row, prev, send) {
78 | send('domains:disablePrivateMode', row.url, send)
79 | }
80 |
81 | function removeFromHistory () {
82 |
83 | }
84 |
85 | function mute (row, prev, send) {
86 | send('tabs:mute', { tabId: row.tab.id })
87 | }
88 |
89 | function unmute (row, prev, send) {
90 | send('tabs:unmute', { tabId: row.tab.id })
91 | }
92 |
93 | function openInNewTab (row, prev, send) {
94 | send('tabs:newTab', { url: row.url })
95 | }
96 |
97 | function openInSameTab (row, prev, send) {
98 | send('tabs:go', { url: row.url })
99 | }
100 |
101 | function closeTab (row, prev, send) {
102 | send('tabs:close', row.tab.id)
103 | send('search:setPreview', null)
104 | }
105 |
106 | function copyURL (row, prev, send) {
107 | clipboard.writeText(`${row.record.protocol}://${row.record.url}`)
108 | }
109 |
--------------------------------------------------------------------------------
/src/app/models/search/effects.js:
--------------------------------------------------------------------------------
1 | const suggest = require("debounce-fn")(_suggest)
2 | const http = electronRequire('http')
3 |
4 | const searchDB = require("../../search")
5 | const input = require("../../input")
6 | const urls = require("../../urls")
7 |
8 | const SUGGESTION_LIMIT = 5
9 |
10 | module.exports = {
11 | open,
12 | quit,
13 | up,
14 | down,
15 | suggest,
16 | addResults,
17 | search: _search,
18 | selectInput: input.select.bind(null, 'url')
19 | }
20 |
21 | function open (payload, state, send, done) {
22 | send('search:setQuery', payload && payload.query || '', done)
23 | send('search:setPreview', payload && payload.preview || null, done)
24 | send('search:setResults', payload && payload.results || [], done)
25 | send('search:setAsOpen', done)
26 | send('findInPage:disable', done)
27 |
28 | if (payload && payload.results && payload.results.length > 0) {
29 | send('likes:recoverFromSearch', payload.results, done)
30 | send('domains:recoverFromSearch', payload.results, done)
31 | }
32 |
33 | if (payload && payload.select) {
34 | input.select('url')
35 | } else {
36 | input.focus('url')
37 | }
38 |
39 | if (payload && payload.query !== undefined) {
40 | _search(payload, state, send, done)
41 | }
42 | }
43 |
44 | function quit (payload, state, send, done) {
45 | if (!state.isOpen) return
46 | send('search:setAsClosed', done)
47 | }
48 |
49 |
50 | function up (payload, state, send, done) {
51 | if (state.results.length === 0) return
52 | const index = findPreviewIndex(state)
53 | const prev = state.results[ index < 1 ? state.results.length - 1 : index - 1 ]
54 | send('search:setPreview', prev, done)
55 | send('search:setQuery', prev.search ? prev.search.query : prev.url, done)
56 | send('search:selectInput', send)
57 | }
58 |
59 | function down (payload, state, send, done) {
60 | if (state.results.length === 0) return
61 | const index = findPreviewIndex(state)
62 | const next = state.results[ (index + 1) % state.results.length ]
63 | console.log(index, state.results)
64 | send('search:setPreview', next, done)
65 | send('search:setQuery', next.search ? next.search.query : next.url, done)
66 | send('search:selectInput', send)
67 | }
68 |
69 | function findPreviewIndex (state) {
70 | if (!state.preview) return -1
71 |
72 | const rows = state.results
73 |
74 | let index = -1
75 | let i = -1
76 | const len = rows.length
77 | while (++i < len) {
78 | if (!isSameRow(rows[i], state.preview)) continue
79 | index = i
80 | break
81 | }
82 |
83 | return index
84 | }
85 |
86 | function addResults (payload, state, send, done) {
87 | if (payload.query.trim() != state.query.trim()) return console.error('Got results for previous search %s (current: %s)', payload.query, state.query, payload.rows.length)
88 | send('search:setResults', state.results.concat(payload.rows), done)
89 | }
90 |
91 | function _search (payload, state, send, done) {
92 | const query = payload && payload.query !== undefined ? payload.query : state.query
93 |
94 | searchDB(query, (error, results) => {
95 | if (error) return console.error('Failed to update search results: ', query)
96 |
97 | send('search:setResults', results, done)
98 | send('likes:recoverFromSearch', results, done)
99 | send('domains:recoverFromSearch', results, done)
100 |
101 | if (payload.query.trim().length) suggest(payload, state, send, done)
102 |
103 | if (payload.selectFirstItem) {
104 | send('search:setPreview', results[0], done)
105 | }
106 | })
107 | }
108 |
109 | function _suggest (payload, state, send, done) {
110 | console.log('Getting search suggestions for %s', payload.query)
111 |
112 | http.get(`http://google.com/complete/search?client=chrome&q=${encodeURI(payload.query)}`, (res) => {
113 | res.setEncoding('utf8')
114 | let body = ''
115 |
116 | res.on('data', part => {
117 | body += part
118 | })
119 |
120 | res.on('error', e => console.error('Failed to get suggestions', e))
121 |
122 | res.on('end', () => {
123 | const parsed = JSON.parse(body)
124 | const results = []
125 | const queries = parsed[1].slice(0, SUGGESTION_LIMIT)
126 |
127 | for (let query of queries) {
128 | results.push({ search: { query } })
129 | }
130 |
131 | send('search:addResults', { rows: results, query: payload.query }, done)
132 | })
133 | })
134 | }
135 |
136 | function isSameRow (a, b) {
137 | if (a.search) {
138 | return b.search && a.search.query === b.search.query
139 | }
140 |
141 | return a.url === b.url
142 | }
143 |
--------------------------------------------------------------------------------
/src/app/views/webviews/webview.js:
--------------------------------------------------------------------------------
1 | const html = require('choo/html')
2 | const partition = require("../../partition")
3 |
4 | const private = (tab, state, prev, send) => html`
5 |
6 | ${webview(tab, state, prev, send)}
7 |
8 | `
9 |
10 | const webview = (tab, state, prev, send) => {
11 | let tree = html`
12 | `
16 |
17 | tree.addEventListener('did-start-loading', onLoadStateChange('start', tab, tree, send))
18 | tree.addEventListener('did-stop-loading', onLoadStateChange('stop', tab, tree, send))
19 | tree.addEventListener('did-fail-load', onLoadFails(tab, tree, send))
20 |
21 | tree.addEventListener('crashed', onCrash(tab, tree, send))
22 | tree.addEventListener('gpu-crashed', onCrash(tab, tree, send))
23 | tree.addEventListener('media-started-playing', onMediaStateChange(true, tab, tree, send))
24 | tree.addEventListener('media-paused', onMediaStateChange(false, tab, tree, send))
25 |
26 | tree.addEventListener('page-title-updated', (event) => updateURLMeta(send, tab, {
27 | title: event.title
28 | }))
29 |
30 | tree.addEventListener('page-favicon-updated', (event) => updateURLMeta(send, tab, {
31 | icon: event.favicons[0]
32 | }))
33 |
34 | tree.addEventListener('will-navigate', (event) => update(send, tab, {
35 | url: event.url,
36 | icon: '',
37 | image: null,
38 | canGoBack: tree.canGoBack(),
39 | canGoForward: tree.canGoForward()
40 | }))
41 |
42 | tree.addEventListener('did-navigate', (event) => updateURL(send, tab, event.url))
43 | tree.addEventListener('did-navigate-in-page', (event) => updateURL(send, tab, event.url))
44 |
45 | tree.addEventListener('new-window', (event) => send('tabs:newTab', { url: event.url }))
46 |
47 | tree.addEventListener('dom-ready', (event) => {
48 | copyMetaInfo(tab, tree, send)
49 | update(send, tab, {
50 | isDOMReady: true
51 | })
52 | })
53 |
54 | return tree
55 | }
56 |
57 | module.exports = (tab, state, prev, send) => {
58 | return (partition.isPrivateModeDomain(tab, state) ? private : webview)(tab, state, prev, send)
59 | }
60 |
61 | function onLoadStateChange (eventName, tab, tree, send) {
62 | const start = eventName === 'start'
63 |
64 | return function () {
65 | console.log(start ? 'start loading' : 'stop loading')
66 |
67 | send(`tabs:${eventName}Loading`, {
68 | tab,
69 | props: {
70 | canGoBack: tree.canGoBack(),
71 | canGoForward: tree.canGoForward(),
72 | isLoading: start,
73 | isDOMReady: !start
74 | }
75 | })
76 | }
77 | }
78 |
79 | function onLoadFails (tab, tree, send) {
80 | return function (event) {
81 | if (event.errorCode == -3 || event.errorCode == 0) return
82 |
83 | if (!event.isMainFrame) {
84 | console.error('Ignoring an error.', event.errorCode, event.errorDescription, event.validatedURL)
85 | return
86 | }
87 |
88 | send('tabs:update', {
89 | tab,
90 | props: {
91 | isLoading: false,
92 | url: event.validatedURL,
93 | icon: '',
94 | image: null,
95 | canGoBack: tree.canGoBack(),
96 | canGoForward: tree.canGoForward(),
97 | error: {
98 | code: event.errorCode,
99 | description: event.errorDescription,
100 | url: event.validatedURL
101 | }
102 | }
103 | })
104 | }
105 | }
106 |
107 | function onCrash (tab, tree, send) {
108 | return function (event) {
109 | send('tabs:update', {
110 | tab,
111 | props: {
112 | isLoading: false,
113 | error: {
114 | code: 'crash',
115 | description: '',
116 | url: tab.url
117 | }
118 | }
119 | })
120 | }
121 | }
122 |
123 | function update(send, tab, props) {
124 | send('tabs:update', {
125 | tab,
126 | props
127 | })
128 | }
129 |
130 | function updateURL (send, tab, url) {
131 | if (tab.url === url) return console.error('The updated URL is same with existing one', url)
132 |
133 | send('tabs:updateURL', {
134 | tab,
135 | url
136 | })
137 | }
138 |
139 | function updateURLMeta (send, tab, props) {
140 | send('tabs:updateURLMeta', {
141 | tab,
142 | props
143 | })
144 | }
145 |
146 | function onMediaStateChange (isPlaying, tab, tree, send) {
147 | return function (event) {
148 | send('tabs:update', {
149 | tab,
150 | props: {
151 | isPlayingMedia: isPlaying
152 | }
153 | })
154 | }
155 | }
156 |
157 | function copyMetaInfo(tab, webview, send) {
158 | getMetaProperty(webview, 'og:image', (error, image) => {
159 | getMetaProperty(webview, 'og:description', (error, desc) => {
160 | getMetaProperty(webview, 'keywords', (error, keywords) => {
161 |
162 | if (!image && !desc) return
163 |
164 | send('tabs:updateURLMeta', {
165 | tab,
166 | props: {
167 | image: image || '',
168 | description: desc || '',
169 | keywords: keywords || ''
170 | }
171 | })
172 | })
173 | })
174 | })
175 | }
176 |
177 | function getMetaProperty (webview, name, callback) {
178 | webview.executeJavaScript(`document.querySelector("meta[property='${name}']") && document.querySelector("meta[property='${name}']").getAttribute('content')`, false, (result) => {
179 | if (!result) return callback()
180 |
181 | callback(undefined, result)
182 | })
183 | }
184 |
--------------------------------------------------------------------------------
/src/app/models/tabs/effects.js:
--------------------------------------------------------------------------------
1 | const db = require("../../db")
2 | const searchRows = require("../../models/tabs").searchRows
3 | const listOfTabs = require("../../list-of-tabs")
4 | const Tab = require("./tab")
5 | const urls = require("../../urls")
6 |
7 | const MAX_ZOOM_LEVEL = 9
8 | const MIN_ZOOM_LEVEL = -8
9 | const DEFAULT_ZOOM_LEVEL = 0
10 | const ZOOM_INCR_VAL = 1
11 |
12 | module.exports = {
13 | recoverTabs,
14 | go,
15 | newTab,
16 | select,
17 | close,
18 | closeSelectedTab,
19 | updateURL,
20 | updateURLMeta,
21 | updateByURL,
22 | back: withWebView(back),
23 | forward: withWebView(forward),
24 | reload: withWebView(reload),
25 | stop: withWebView(stop),
26 | print: withWebView(print),
27 | mute: withWebView(setAudioMuted(true)),
28 | unmute: withWebView(setAudioMuted(false)),
29 | zoomIn: withWebView(zoomIn),
30 | zoomOut: withWebView(zoomOut),
31 | resetZoom: withWebView(resetZoom),
32 | openDevTools: withWebView(openDevTools),
33 | findInPage: withWebView(findInPage),
34 | quitFindInPage: withWebView(quitFindInPage),
35 | findNextInPage: withWebView(findNextInPage),
36 | findPreviousInPage: withWebView(findPreviousInPage)
37 | }
38 |
39 | function recoverTabs (payload, state, send, done) {
40 | db.embed(db.tabs.all, [db.meta, db.history, db.likes, db.domains], (error, all) => {
41 | if (error) return console.error('can not recover tabs', error)
42 | if (all.length === 0) return newTab(null, state, send, done)
43 |
44 | send('likes:recover', all, done)
45 | send('domains:recover', all, done)
46 |
47 | let newState = {}
48 |
49 | all.forEach(el => {
50 | let protocol = ""
51 | let url = ""
52 |
53 | if (el.record) {
54 | protocol = el.record.protocol
55 | url = el.record.url
56 | }
57 |
58 | const t = new Tab(el.id, protocol, url)
59 |
60 | if (el.meta) {
61 | t.image = el.meta.image
62 | t.icon = el.meta.icon
63 | t.title = el.meta.title
64 | t.description = el.meta.description
65 | }
66 |
67 | t.createdAt = el.createdAt
68 | t.isSelected = !!el.isSelected
69 |
70 | if (t.isSelected) {
71 | newState.selectedId = t.id
72 | }
73 |
74 | newState[t.id] = t
75 | })
76 |
77 | send('tabs:setState', newState, done)
78 |
79 | if (newState[newState.selectedId].isNew) {
80 | send('search:open', { query: '' }, done)
81 | }
82 | })
83 | }
84 |
85 | function close (id, state, send, done) {
86 | db.tabs.close(id, error => {
87 | if (error) return console.error('can not close tab', error)
88 |
89 | let next = -1
90 | const all = listOfTabs(state)
91 | let i = all.length
92 | while (i--) {
93 | if (all[i].id !== id) continue
94 | next = (i + 1) % all.length
95 | break
96 | }
97 |
98 | if (next === -1 || all.length === 1) {
99 | newTab(null, state, send, done)
100 | } else {
101 | select(all[next].id, state, send, done)
102 | }
103 |
104 | send('tabs:setTabAsClosed', id, done)
105 | send('search:search', { query: '' }, done)
106 | })
107 | }
108 |
109 | function closeSelectedTab (payload, state, send, done) {
110 | close(state.selectedId, state, send, done)
111 | }
112 |
113 | function newTab (payload, state, send, done) {
114 | if (switchToExistingNewTab(payload, state, send, done)) return
115 | if (state[state.selectedId] && state[state.selectedId].isNew) {
116 | if (payload && payload.url) return _go({ url: payload.url, tab: state[state.selectedId] }, state, send, done)
117 | return
118 | }
119 |
120 | db.tabs.create(payload ? payload.url : '', (error, id) => {
121 | if (error) return console.error('Fatal, can not create tab: ', error)
122 |
123 | db.tabs.select(id, error => {
124 | if (error) console.error('Can not select the new tab created', error)
125 |
126 | send('tabs:create', { id, url: payload && payload.url || '', select: true }, done)
127 |
128 | if (!payload || !payload.url) {
129 | send('search:open', { query: '' }, done)
130 | } else {
131 | send('search:quit', done)
132 | }
133 | })
134 | })
135 | }
136 |
137 | function switchToExistingNewTab (payload, state, send, done) {
138 | if (payload && payload.url) return
139 |
140 | const all = listOfTabs(state)
141 | let existing
142 |
143 | for (let tab of all) {
144 | if (!tab.isNew) continue
145 | existing = tab
146 | break
147 | }
148 |
149 | if (!existing) return
150 |
151 | select(existing.id, state, send, done)
152 | send('search:open', { query: '' }, done)
153 | return true
154 | }
155 |
156 | function select (id, state, send, done) {
157 | send('tabs:setSelectedId', id, done)
158 |
159 | db.tabs.select(id, error => {
160 | if (error) return console.error('can not select tab', error)
161 | })
162 | }
163 |
164 | function updateByURL(payload, state, send, done) {
165 | const url = payload.url
166 | let tab = null
167 |
168 | for (let t of state) {
169 | if (t.url === url) {
170 | tab = t
171 | break
172 | }
173 | }
174 |
175 | if (!tab) return console.error('URL is not opened as a tab')
176 |
177 | send('tabs:update', {
178 | tab,
179 | props: payload.props
180 | })
181 | }
182 |
183 | function go (payload, state, send, done) {
184 | if (!payload.tab) {
185 | payload.tab = state[state.selectedId]
186 | }
187 |
188 | db.tabs.get(payload.url, (error, tab) => {
189 | if (error) return console.error('can not get tabs')
190 | if (!tab) return _go(payload, state, send, done)
191 | if (tab.id === payload.tab.id) return
192 |
193 | db.tabs.close(payload.tab.id, error => {
194 | if (error) return console.error('can not close tab', error)
195 |
196 | select(tab.id, state, send, done)
197 | send('tabs:setTabAsClosed', payload.tab.id, done)
198 | })
199 | })
200 | }
201 |
202 | function _go (payload, state, send, done) {
203 | console.log('Go to %s', payload.url)
204 |
205 | const url = urls.normalize(payload.url)
206 |
207 | send('tabs:openURL', payload, done)
208 |
209 | db.tabs.updateURL(payload.tab.id, url, error => {
210 | if (error) console.error('Can not update tab url', payload.url, error)
211 | })
212 |
213 | db.history.visit(url, (error) => {
214 | if (error) return console.error('Can not add %s to history', payload.url, error)
215 | })
216 |
217 | db.domains.get(url, (error, domain) => {
218 |
219 | })
220 | }
221 |
222 | function updateURL (payload, state, send, done) {
223 | console.log('Update URL to %s', payload.url)
224 |
225 | send('tabs:update', {
226 | tab: payload.tab,
227 | props: {
228 | protocol: urls.protocol(payload.url),
229 | url: urls.clean(payload.url),
230 | webviewURL: payload.url,
231 | }
232 | }, done)
233 |
234 | send('likes:get', urls.clean(payload.url), done)
235 | send('domains:get', urls.clean(payload.url), done)
236 |
237 | if (!/^\w+:\/\//.test(payload.url)) return
238 |
239 | db.tabs.updateURL(payload.tab.id, payload.url, error => {
240 | if (error) console.error('Can not update tab url', payload.url, error)
241 | })
242 |
243 | db.history.visit(payload.url, function (error, result) {
244 | if (error) return console.error('Can not add %s to history', payload.url, error)
245 | })
246 | }
247 |
248 | function updateURLMeta (payload, state, send, done) {
249 | send('tabs:update', payload, done)
250 |
251 | const props = {
252 | url: payload.tab.url
253 | }
254 |
255 | for (let key in payload.props) {
256 | props[key] = payload.props[key]
257 | }
258 |
259 | db.meta.save(props, (error, result) => {
260 | if (error) return console.error('Can not save %s meta props', props.url, error)
261 | })
262 | }
263 |
264 | function back (webview) {
265 | webview.goBack()
266 | }
267 |
268 | function forward (webview) {
269 | webview.goForward()
270 | }
271 |
272 | function reload (webview, payload, send, done) {
273 | webview.reloadIgnoringCache()
274 |
275 | send('tabs:update', {
276 | tab: payload.tab,
277 | props: {
278 | error: null
279 | }
280 | }, done)
281 | }
282 |
283 | function stop (webview) {
284 | webview.stop()
285 | }
286 |
287 | function print (webview, payload, send, done) {
288 | webview.print()
289 | }
290 |
291 | function zoomIn (webview, payload) {
292 | if (payload.tab.zoomLevel >= MAX_ZOOM_LEVEL) {
293 | return
294 | }
295 |
296 | payload.tab.zoomLevel += ZOOM_INCR_VAL
297 |
298 | webview.setZoomLevel(payload.tab.zoomLevel)
299 | }
300 |
301 | function zoomOut (webview, payload) {
302 | if (payload.tab.zoomLevel <= MIN_ZOOM_LEVEL) {
303 | return
304 | }
305 |
306 | payload.tab.zoomLevel -= ZOOM_INCR_VAL
307 |
308 | webview.setZoomLevel(payload.tab.zoomLevel)
309 | }
310 |
311 | function resetZoom (webview, payload) {
312 | payload.tab.zoomLevel = DEFAULT_ZOOM_LEVEL
313 | webview.setZoomLevel(payload.tab.zoomLevel)
314 | }
315 |
316 | function openDevTools (webview) {
317 | webview.openDevTools()
318 | }
319 |
320 | function setAudioMuted (bool) {
321 | return function (webview, payload, send, done) {
322 | webview.setAudioMuted(bool)
323 |
324 | send('tabs:update', {
325 | tab: payload.tab,
326 | props: {
327 | isMuted: bool
328 | }
329 | }, done)
330 | }
331 | }
332 |
333 | function findInPage (webview, payload) {
334 | webview.findInPage(payload.query)
335 | }
336 |
337 | function findNextInPage (webview, payload) {
338 | webview.findInPage(payload.query, {
339 | findNext: true,
340 | forward: true
341 | })
342 | }
343 |
344 | function findPreviousInPage (webview, payload) {
345 | webview.findInPage(payload.query, {
346 | findNext: true,
347 | forward: false
348 | })
349 | }
350 |
351 | function quitFindInPage (webview) {
352 | webview.stopFindInPage('clearSelection')
353 | }
354 |
355 | function withWebView (method) {
356 | return function (payload, state, send, done) {
357 | if (!payload) {
358 | payload = {}
359 | }
360 |
361 | if (!payload.tab && payload.tabId) {
362 | payload.tab = state[payload.tabId]
363 | }
364 |
365 | if (!payload.tab) {
366 | payload.tab = state[state.selectedId]
367 | }
368 |
369 | var webview = document.querySelector(`#${payload.tab.id}`) || document.querySelector(`#${payload.tab.id}-private`)
370 | if (!webview) return
371 |
372 | method(webview, payload, send, done)
373 | }
374 | }
375 |
--------------------------------------------------------------------------------