├── .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 |
options.onclick(state, prev, send)} 9 | title=${options.title}> 10 |
11 | 12 |
13 |
14 | ${options.title} 15 |
16 |
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 | ![](https://cldup.com/6jOWAjYdpo.png) 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 | ` 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 | --------------------------------------------------------------------------------