Compare commits

..

No commits in common. "develop" and "v1.0.0" have entirely different histories.

31 changed files with 5829 additions and 4637 deletions

View File

@ -1,93 +1,34 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"parserOptions": {
"project": [
"./tsconfig.backend.json",
"./tsconfig.frontend.json"
]
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"indent": [
"error",
4,
{
"SwitchCase": 1
}
"tab"
],
"no-trailing-spaces": "error",
"max-len": [
"linebreak-style": [
"error",
{
"code": 120,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
"unix"
],
"semi": "off",
"@typescript-eslint/semi": [
"error"
],
"no-extra-semi": "error",
"eol-last": "error",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": [
"quotes": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "always-multiline",
"enums": "always-multiline",
"generics": "always-multiline",
"tuples": "always-multiline"
}
"single"
],
"no-extra-parens": "off",
"@typescript-eslint/no-extra-parens": [
"error"
],
"no-nested-ternary": "error",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/explicit-module-boundary-types": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-non-null-assertion": "error",
"no-useless-return": "error",
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": [
"error"
],
"no-return-await": "off",
"@typescript-eslint/return-await": [
"semi": [
"error",
"always"
],
"@typescript-eslint/explicit-member-accessibility": [
"error",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/no-floating-promises": "error"
},
"ignorePatterns": [
"build/**/*",
"resources/**/*",
"webpack.config.js"
]
}
}

View File

@ -1,5 +1,7 @@
# Tabs
/!\ Still in development! I aim to improve security, usability and add more features. Wait for the 1.0 release to use tabs for critical jobs. /!\
Tabs is an electron app than allows you to make **persistent** and **isolated** sessions on any website.
You could create `Services` for Facebook Messenger, GMail, or your personnal nextcloud instance. There is not limit to what home URL you can set.
@ -8,15 +10,15 @@ You could create `Services` for Facebook Messenger, GMail, or your personnal nex
- A service can be either automatically loaded or not
- You can customize a service css (i.e. remove unwanted elements like menus or nav bars)
Tabs aims to provide both complete and simple customization.
You can navigate inside the focused service using the navigation buttons (previous page, next page).
## When's next major version being released?
## When's 1.0 being released?
You can track the development progress [here](https://github.com/ArisuOngaku/tabs/projects).
You can track the development progress [here](https://github.com/ArisuOngaku/tabs/projects/3).
## Contributing
Pull requests are welcome and appreciated!
## License
[GPLv3 - see LICENSE file](LICENSE)
[MIT - see LICENSE file](LICENSE)

View File

@ -1,5 +1,6 @@
provider: generic
url: "https://update.eternae.ink/ashpie/tabs"
owner: ArisuOngaku
repo: tabs
provider: github
updaterCacheDirName: tabs-updater
publisherName:
- Alice Gaudon

View File

@ -3,7 +3,6 @@
"files": "ts/files.ts",
"layout": "sass/layout.scss",
"index": "sass/index.scss",
"error": "sass/error.scss",
"service-settings": "sass/service-settings.scss"
}
}

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>An error occured</title>
<meta http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/error.css">
</head>
<body>
<h1>Oops</h1>
<p>A connection error has occurred.</p>
<p>Please check your internet connection and this service's URL.</p>
<p>If you can load the actual URL in your favorite web browser, please file us a bug report.</p>
</body>
</html>

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>An error occured</title>
<meta http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/error.css">
</head>
<body>
<h1>Oops</h1>
<p>File not found.</p>
</body>
</html>

View File

@ -4,7 +4,10 @@
<meta charset="UTF-8">
<title>Tabs</title>
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'sha256-oPC0l5nxLnJ2LX6qU9Laxa4/cjhuHDRIqdUsBDWYqnw='">
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline' https://use.fontawesome.com; font-src 'self' https://use.fontawesome.com; script-src 'self' 'sha256-oPC0l5nxLnJ2LX6qU9Laxa4/cjhuHDRIqdUsBDWYqnw='">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css"
integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/index.css">
@ -47,7 +50,7 @@
<div id="services">
<div class="loader"></div>
<div id="url-preview" class="invisible"></div>
<div id="url-preview" class="hidden"></div>
<div id="empty-message">Load a service using the menu on the left.</div>
</div>
</body>

View File

@ -1,9 +0,0 @@
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
align-self: center;
}

View File

@ -1,147 +1,46 @@
:root {
--nav-width: 48px;
}
body {
display: flex;
flex-direction: row;
}
#navigation {
display: flex;
flex-direction: column;
height: 100%;
width: 48px;
}
#navigation > :not(#service-buttons) {
flex-shrink: 0;
}
#service-buttons {
flex-grow: 1;
overflow: hidden auto;
}
*:focus {
outline-color: rgb(118, 93, 176);
}
#navigation {
display: flex;
flex-direction: column;
height: 100%;
width: var(--nav-width);
body.fullscreen & {
display: none;
}
> :not(#service-buttons) {
flex-shrink: 0;
}
button {
position: relative;
display: block;
width: var(--nav-width);
height: var(--nav-width);
margin: 0;
padding: 0;
color: #fff;
border: 0;
background: transparent;
cursor: pointer;
border-radius: 0;
&:focus {
outline: none;
background: #fff3 !important;
}
&:hover {
background-color: #fff3;
}
i {
font-size: calc(var(--nav-width) / 2);
}
img {
width: calc(var(--nav-width) / 2);
}
}
}
#service-selector {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
&::-webkit-scrollbar {
#service-selector::-webkit-scrollbar {
width: 6px;
}
li {
position: relative;
button {
border-radius: 0;
}
&.active button {
background-color: #fff2;
}
&.loading, &.loaded {
button::before {
content: "";
display: block;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 75%;
border-right: 4px solid #ffffff3e;
}
}
&.loading button::before {
animation: loading-button-after linear 500ms infinite alternate;
height: 45%;
@keyframes loading-button-after {
from {
opacity: 0.1;
}
to {
opacity: 0.5;
}
}
}
}
[draggable] {
#service-selector [draggable] {
user-select: none;
background-color: rgb(43, 43, 43);
}
img {
#service-selector [draggable] img {
-webkit-user-drag: none;
user-drag: none;
}
}
.drag-target-self button::after {
height: var(--nav-width);
border: 1px dashed #fff;
transform: none;
}
.drag-target button::after {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.drag-target::after {
top: 75% !important;
}
}
#service-selector .drag-target button::after,
#service-last-drag-position.drag-target {
@ -153,6 +52,96 @@ body {
background: #fff9;
}
#service-selector .drag-target-self button::after {
height: 48px;
border: 1px dashed #fff;
transform: none;
}
#service-selector .drag-target button::after {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
#service-selector .drag-target::after {
top: 75% !important;
}
*:focus {
outline-color: rgb(118, 93, 176);
}
#navigation button {
position: relative;
display: block;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
color: #fff;
border: 0;
background: transparent;
cursor: pointer;
}
#navigation button:focus {
outline: none;
background: #fff3 !important;
}
#navigation button img {
width: 24px;
}
#navigation button i {
font-size: 24px;
}
#service-selector li {
position: relative;
}
#service-selector li button,
#navigation > button {
border-radius: 0;
}
#service-selector li.active button {
background-color: #fff2;
}
#service-selector li.loaded button::before {
content: "";
display: block;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 75%;
border-right: 4px solid #ffffff3e;
}
/*#service-selector li.loaded::after {*/
/* content: "";*/
/* position: absolute;*/
/* top: 50%;*/
/* right: 2px;*/
/* transform: translateY(-50%);*/
/* width: 4px;*/
/* height: 4px;*/
/* background-color: #fff;*/
/* border-radius: 100%;*/
/*}*/
#navigation button:hover {
background-color: #fff3;
}
#history {
display: flex;
flex-direction: column;
@ -163,11 +152,11 @@ body {
#history button,
#history .status {
display: inline;
width: calc(var(--nav-width) / 2);
height: calc(var(--nav-width) / 2);
width: 24px;
height: 24px;
margin: 2px;
padding: initial;
font-size: calc(var(--nav-width) / 4);
font-size: 12px;
background: #fff1;
@ -226,7 +215,7 @@ body {
#history #status > * .tip {
position: absolute;
z-index: 30;
z-index: 1;
left: 100%;
top: 50%;
transform: translateY(-50%);
@ -276,22 +265,10 @@ body {
height: 100%;
}
#services > :not(.active):not(.loader):not(#url-preview):not(webview) {
#services > :not(.active):not(.loader):not(#url-preview) {
display: none;
}
#services > webview {
position: absolute;
width: 100%;
height: 100%;
z-index: 10;
background-color: rgb(43, 43, 43);
}
#services > webview.active {
z-index: 20;
}
#services > .loader {
width: 64px;
height: 64px;
@ -305,7 +282,6 @@ body {
#url-preview {
position: absolute;
z-index: 10000;
bottom: 0;
left: 0;
display: block;
@ -337,7 +313,7 @@ body {
border-radius: 5px 0 0 0;
}
#url-preview.invisible {
#url-preview.hidden {
opacity: 0;
}

View File

@ -1,5 +1,3 @@
@import url('../../node_modules/@fortawesome/fontawesome-free/css/all.css');
* {
box-sizing: border-box;
}

View File

@ -71,28 +71,22 @@ form {
margin: 8px;
grid-template-columns: 0fr auto 0fr;
&.no-expand {
display: flex;
flex-direction: row;
justify-content: center;
}
> * {
.form-group > * {
margin: 8px;
padding: 8px;
white-space: nowrap;
align-self: center;
}
> :first-child {
.form-group > :first-child {
justify-self: end;
}
> :not(:first-child) {
.form-group > :not(:first-child) {
margin-left: 8px;
}
}
label.form-group {
line-height: 29px;

View File

@ -4,7 +4,10 @@
<meta charset="UTF-8">
<title>Service settings</title>
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'sha256-5gY/z34s5Mtc3YL8GkwZQhzk9LymQIuFUQRVvs7Gh0o='">
<meta http-equiv="Content-Security-Policy" content="style-src 'self' 'unsafe-inline' https://use.fontawesome.com; font-src 'self' https://use.fontawesome.com; script-src 'self' 'sha256-5gY/z34s5Mtc3YL8GkwZQhzk9LymQIuFUQRVvs7Gh0o='">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css"
integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/service-settings.css">
@ -13,7 +16,7 @@
</head>
<body>
<form>
<form action="javascript: save();">
<div class="form-header">
<h1>Loading...</h1>
</div>
@ -39,19 +42,12 @@
<textarea name="customCSS" id="custom-css" rows="3"></textarea>
</div>
<fieldset>
<legend>Disguise</legend>
<div class="form-group">
<label for="custom-user-agent">Custom UserAgent (i.e. google services)</label>
<input type="text" name="customUserAgent" id="custom-user-agent">
<button type="button" id="userAgentAutoFill">Auto-fill</button>
</div>
<div class="form-group no-expand">
<button type="button" id="userAgentAutoFillFirefox">Disguise as firefox</button>
<button type="button" id="userAgentAutoFillChrome">Disguise as chrome</button>
</div>
</fieldset>
<div id="icon-choice">
<div class="form-group-header">
<h2>Service icon</h2>
@ -82,7 +78,7 @@
<div class="form-footer">
<div class="form-group" id="buttons">
<button id="cancel-button" type="button">Cancel</button>
<button id="cancel-button">Cancel</button>
<button type="submit">Save</button>
</div>
</div>

View File

@ -5,7 +5,10 @@
<title>Service settings</title>
<meta http-equiv="Content-Security-Policy"
content="style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self' 'sha256-UoPUIMX0PZl7cy3YoegZ0EDleSaHxTURPMyK09xsa0E='">
content="style-src 'self' 'unsafe-inline' https://use.fontawesome.com; font-src 'self' https://use.fontawesome.com; script-src 'self' 'sha256-UoPUIMX0PZl7cy3YoegZ0EDleSaHxTURPMyK09xsa0E='">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.2/css/all.css"
integrity="sha384-oS3vJWv+0UjzBfQzYUhtDYW+Pj2yciDJxpsK1OYPAYjqT085Qq/1cq5FLXAZQ7Ay" crossorigin="anonymous">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/service-settings.css">
@ -14,7 +17,7 @@
</head>
<body>
<form>
<form action="javascript: save();">
<div class="form-header">
<h1>Settings</h1>
</div>
@ -26,19 +29,6 @@
<button id="download-button" type="button" class="hidden">Download</button>
</div>
<section>
<h2 class="form-header">Startup</h2>
<label class="form-group"><input type="checkbox" name="start-minimized" id="start-minimized"> Start minimized in system tray</label>
</section>
<section>
<h2 class="form-header">Appearance</h2>
<label class="form-group"><input type="checkbox" name="big-nav-bar" id="big-nav-bar"> Increase the size of the navigation bar</label>
</section>
<section>
<h2 class="form-header">History navigation buttons</h2>
<label class="form-group">
@ -51,7 +41,6 @@
<input type="checkbox" name="forward-button" id="forward-button"> Go forward button</label>
<label class="form-group">
<input type="checkbox" name="refresh-button" id="refresh-button"> Refresh page button</label>
</section>
</div>
<div class="form-footer">

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
function requireAll(r: any) {
r.keys().forEach(r);
}

View File

@ -1,50 +1,143 @@
import {DidFailLoadEvent, ipcRenderer, PageFaviconUpdatedEvent, UpdateTargetUrlEvent} from "electron";
import Service from "../../src/Service";
import {IconProperties, IconSet, SpecialPages} from "../../src/Meta";
import Config from "../../src/Config";
import {
clipboard,
ipcRenderer,
PageFaviconUpdatedEvent,
remote,
shell,
UpdateTargetUrlEvent,
WebContents
} from "electron";
const appInfo: {
title?: string;
} = {};
let icons: IconProperties[] = [];
const {
Menu,
MenuItem,
dialog,
session,
} = remote;
let services: (FrontService | undefined)[] = [];
let selectedServiceId: number | null = null;
let securityButton: HTMLElement | null = null,
homeButton: HTMLElement | null = null,
forwardButton: HTMLElement | null = null,
backButton: HTMLElement | null = null,
refreshButton: HTMLElement | null = null;
const appInfo: any = {};
let icons: any[] = [];
let services: any[] = [];
let selectedService: any = null;
let securityButton: HTMLElement | null,
homeButton: HTMLElement | null,
forwardButton: HTMLElement | null,
backButton: HTMLElement | null,
refreshButton: HTMLElement | null;
let addButton, settingsButton;
let specialPages: SpecialPages | null = null;
let urlPreview: HTMLElement | null = null;
let serviceSelector: HTMLElement | null = null;
let emptyPage: string;
let urlPreview: HTMLElement | null;
let serviceSelector: HTMLElement | null;
// Service reordering
let lastDragPosition: HTMLElement | null = null;
let oldActiveService: number | null = null;
let lastDragPosition: HTMLElement | null, oldActiveService: number;
// Service context menu
function openServiceContextMenu(event: Event, serviceId: number) {
event.preventDefault();
const service = services[serviceId];
const menu = new Menu();
const ready = service.view && service.viewReady, notReady = !service.view && !service.viewReady;
menu.append(new MenuItem({
label: 'Home', click: () => {
service.view.loadURL(service.url)
.catch(console.error);
},
enabled: ready,
}));
menu.append(new MenuItem({
label: ready ? 'Reload' : 'Load', click: () => {
reloadService(serviceId);
},
enabled: ready || notReady,
}));
menu.append(new MenuItem({
label: 'Close', click: () => {
unloadService(serviceId);
},
enabled: ready,
}));
menu.append(new MenuItem({type: "separator"}));
let permissionsMenu = [];
if (ready) {
for (const domain in service.permissions) {
if (service.permissions.hasOwnProperty(domain)) {
const domainPermissionsMenu = [];
const domainPermissions = service.permissions[domain];
for (const permission of domainPermissions) {
domainPermissionsMenu.push({
label: (permission.authorized ? '✓' : '❌') + ' ' + permission.name,
submenu: [{
label: 'Toggle',
click: () => {
permission.authorized = !permission.authorized;
updateServicePermissions(serviceId);
},
}, {
label: 'Forget',
click: () => {
service.permissions[domain] = domainPermissions.filter((p: any) => p !== permission);
},
}],
});
}
if (domainPermissionsMenu.length > 0) {
permissionsMenu.push({
label: domain,
submenu: domainPermissionsMenu,
});
}
}
}
}
menu.append(new MenuItem({
label: 'Permissions',
enabled: ready,
submenu: permissionsMenu,
}));
menu.append(new MenuItem({type: "separator"}));
menu.append(new MenuItem({
label: 'Edit', click: () => {
ipcRenderer.send('openServiceSettings', serviceId);
}
}));
menu.append(new MenuItem({
label: 'Delete', click: () => {
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'question',
title: 'Confirm',
message: 'Are you sure you want to delete this service?',
buttons: ['Cancel', 'Confirm'],
cancelId: 0,
}).then(result => {
if (result.response === 1) {
ipcRenderer.send('deleteService', serviceId);
}
}).catch(console.error);
}
}));
menu.popup({window: remote.getCurrentWindow()});
}
ipcRenderer.on('data', (
event,
appTitle: string,
iconSets: IconSet[],
activeServiceId: number,
_specialPages: SpecialPages,
config: Config,
) => {
ipcRenderer.on('data', (event, appData, iconSets, actualSelectedService, emptyUrl, config) => {
// App info
appInfo.title = appTitle;
appInfo.title = appData.title;
// Icons
icons = [];
for (const set of iconSets) {
icons.push(...set);
icons = icons.concat(set);
}
// Special pages
specialPages = _specialPages;
console.log('Updating services ...');
services = config.services;
@ -61,7 +154,7 @@ ipcRenderer.on('data', (
}
for (let i = 0; i < services.length; i++) {
createServiceNavigationElement(i);
createService(i);
}
// Init drag last position
@ -71,7 +164,7 @@ ipcRenderer.on('data', (
const index = services.length;
if (draggedId !== index && draggedId !== index - 1) {
resetDrag();
lastDragTarget = index;
lastDragTarget = dragTargetId = index;
lastDragPosition?.classList.remove('hidden');
lastDragPosition?.classList.add('drag-target');
}
@ -79,20 +172,22 @@ ipcRenderer.on('data', (
}
// Set active service
if (activeServiceId < 0 || activeServiceId >= services.length) {
activeServiceId = 0;
if (actualSelectedService < 0 || actualSelectedService >= services.length) {
actualSelectedService = 0;
}
setActiveService(activeServiceId);
setActiveService(actualSelectedService);
// Empty
emptyPage = emptyUrl;
// Url preview element
urlPreview = document.getElementById("url-preview");
if (urlPreview) {
const _urlPreview = urlPreview;
urlPreview.addEventListener('mouseover', () => {
if (_urlPreview.classList.contains('right')) {
_urlPreview.classList.remove('right');
if (urlPreview!.classList.contains('right')) {
urlPreview!.classList.remove('right');
} else {
_urlPreview.classList.add('right');
urlPreview!.classList.add('right');
}
});
}
@ -112,91 +207,44 @@ ipcRenderer.on('data', (
// Other elements
serviceSelector = document.getElementById('service-selector');
// Navbar size
document.documentElement.style.setProperty('--nav-width', config.bigNavBar ? '64px' : '48px');
});
ipcRenderer.on('load-service-home', (event, serviceId: number) => {
const service = services[serviceId];
if (!service) throw new Error('Service doesn\'t exist.');
service.view?.loadURL(service.url)
.catch(console.error);
});
ipcRenderer.on('reload-service', (event, serviceId: number) => {
reloadService(serviceId);
});
ipcRenderer.on('unload-service', (event, serviceId: number) => {
unloadService(serviceId);
});
ipcRenderer.on('reset-service-zoom-level', (event, serviceId: number) => {
const service = services[serviceId];
if (service?.view) {
service.view.setZoomFactor(1);
service.view.setZoomLevel(0);
}
});
ipcRenderer.on('zoom-in-service', (event, serviceId: number) => {
const service = services[serviceId];
if (service?.view) {
service.view.setZoomLevel(service.view.getZoomLevel() + 1);
}
});
ipcRenderer.on('zoom-out-service', (event, serviceId: number) => {
const service = services[serviceId];
if (service?.view) {
service.view.setZoomLevel(service.view.getZoomLevel() - 1);
}
});
function removeServiceFeatures(id: number): Element | null {
function removeServiceFeatures(id: number): HTMLElement | null {
// Remove nav
const nav = document.querySelector('#service-selector');
let oldNavButton: HTMLElement | null = null;
let nextSibling: Element | null = null;
if (nav) {
oldNavButton = nav.querySelector('li:nth-of-type(' + (id + 1) + ')');
if (oldNavButton) {
nextSibling = oldNavButton.nextElementSibling;
nav.removeChild(oldNavButton);
}
}
// Remove webview
const service = services[id];
if (service) {
const view = service.view;
if (view) document.querySelector('#services')?.removeChild(view);
if (services[id] && services[id].view) {
document.querySelector('#services')?.removeChild(services[id].view);
}
return nextSibling;
return oldNavButton;
}
ipcRenderer.on('updateService', (e, id: number | null, data: Service) => {
ipcRenderer.on('updateService', (e, id, data) => {
if (id === null) {
console.log('Adding new service');
services.push(data);
createServiceNavigationElement(services.length - 1);
createService(services.length - 1);
} else {
console.log('Updating existing service', id);
const nextSibling = removeServiceFeatures(id);
const oldNavButton = removeServiceFeatures(id);
// Create new service
services[id] = data;
createServiceNavigationElement(id, nextSibling);
if (selectedServiceId === id) {
createService(id, oldNavButton ? oldNavButton.nextElementSibling : null);
if (parseInt(selectedService) === id) {
setActiveService(id);
}
}
});
ipcRenderer.on('reorderService', (e, serviceId: number, targetId: number) => {
ipcRenderer.on('reorderService', (e, serviceId, targetId) => {
const oldServices = services;
services = [];
@ -220,69 +268,48 @@ ipcRenderer.on('reorderService', (e, serviceId: number, targetId: number) => {
serviceSelector.innerHTML = '';
}
for (let i = 0; i < services.length; i++) {
const service = services[i];
if (service) service.li = undefined;
createServiceNavigationElement(i);
services[i].li = undefined;
createService(i);
}
setActiveService(newId);
});
ipcRenderer.on('deleteService', (e, id: number) => {
ipcRenderer.on('deleteService', (e, id) => {
removeServiceFeatures(id);
if (selectedServiceId === id) {
if (parseInt(selectedService) === id) {
setActiveService(0);
}
services.splice(id, 1);
delete services[id];
services = services.filter(s => s !== null);
});
function createServiceNavigationElement(index: number, nextNavButton?: Element | null) {
const service = services[index];
if (!service) throw new Error('Service doesn\'t exist.');
const li = document.createElement('li') as NavigationElement;
function createService(index: number, nextNavButton?: Element | null) {
let service = services[index];
let li = <any>document.createElement('li');
service.li = li;
const button = document.createElement('button');
let button = document.createElement('button');
button.dataset.serviceId = '' + index;
button.dataset.tooltip = service.name;
button.addEventListener('click', () => {
const rawId = button.dataset.serviceId;
if (rawId) {
const id = parseInt(rawId);
setActiveService(id);
ipcRenderer.send('setActiveService', id);
if (button.dataset.serviceId) {
setActiveService(parseInt(button.dataset.serviceId));
}
ipcRenderer.send('setActiveService', button.dataset.serviceId);
});
button.addEventListener('contextmenu', e => {
e.preventDefault();
button.addEventListener('contextmenu', e => openServiceContextMenu(e, index));
const service = services[index];
if (!service) throw new Error('Service doesn\'t exist.');
ipcRenderer.send(
'open-service-navigation-context-menu',
index,
!!service.view && !!service.viewReady,
!service.view && !service.viewReady,
service.view?.getZoomFactor() !== 1 && service.view?.getZoomLevel() !== 0,
);
});
let icon: HTMLImageElement | HTMLElement;
let icon: any;
if (service.useFavicon && service.favicon != null) {
icon = document.createElement('img');
if (icon instanceof HTMLImageElement) {
icon.src = service.favicon;
icon.alt = service.name;
}
} else if (service.isImage && service.icon) {
} else if (service.isImage) {
icon = document.createElement('img');
if (icon instanceof HTMLImageElement) {
icon.src = service.icon;
icon.alt = service.name;
}
} else {
icon = document.createElement('i');
let iconProperties = icons.find(i => `${i.set}/${i.name}` === service.icon);
@ -294,8 +321,6 @@ function createServiceNavigationElement(index: number, nextNavButton?: Element |
iconProperties.faIcon.split(' ').forEach((cl: string) => {
icon.classList.add(cl);
});
} else {
icon.classList.add('fas', 'fa-circle');
}
}
@ -321,8 +346,10 @@ function createServiceNavigationElement(index: number, nextNavButton?: Element |
let draggedId: number;
let lastDragTarget = -1;
let dragTargetId = -1;
let dragTargetCount = 0;
function initDrag(index: number, li: NavigationElement) {
function initDrag(index: number, li: any) {
li.serviceId = index;
li.draggable = true;
li.addEventListener('dragstart', (event: DragEvent) => {
@ -334,7 +361,7 @@ function initDrag(index: number, li: NavigationElement) {
});
li.addEventListener('dragover', (e: DragEvent) => {
let realIndex = index;
const rect = li.getBoundingClientRect();
let rect = li.getBoundingClientRect();
if ((e.clientY - rect.y) / rect.height >= 0.5) {
realIndex++;
@ -345,18 +372,12 @@ function initDrag(index: number, li: NavigationElement) {
}
resetDrag();
const el = realIndex === services.length ?
lastDragPosition :
services[realIndex]?.li;
lastDragTarget = realIndex;
let el = realIndex === services.length ? lastDragPosition : services[realIndex].li;
lastDragTarget = dragTargetId = realIndex;
lastDragPosition?.classList.remove('hidden');
if (el) {
el.classList.add('drag-target');
if (draggedId === realIndex || draggedId === realIndex - 1)
el.classList.add('drag-target-self');
}
if (draggedId === realIndex || draggedId === realIndex - 1) el.classList.add('drag-target-self');
});
li.addEventListener('dragend', () => {
reorderService(draggedId, lastDragTarget);
@ -366,6 +387,8 @@ function initDrag(index: number, li: NavigationElement) {
function resetDrag() {
lastDragTarget = -1;
dragTargetId = -1;
dragTargetCount = 0;
serviceSelector?.querySelectorAll('li').forEach(li => {
li.classList.remove('drag-target');
li.classList.remove('drag-target-self');
@ -378,7 +401,7 @@ function resetDrag() {
function reorderService(serviceId: number, targetId: number) {
console.log('Reordering service', serviceId, targetId);
if (targetId >= 0) {
oldActiveService = selectedServiceId;
oldActiveService = selectedService;
setActiveService(-1);
ipcRenderer.send('reorderService', serviceId, targetId);
}
@ -400,7 +423,7 @@ document.addEventListener('DOMContentLoaded', () => {
refreshButton?.addEventListener('click', () => reload());
addButton = document.getElementById('add-button');
addButton?.addEventListener('click', () => ipcRenderer.send('create-new-service', null));
addButton?.addEventListener('click', () => ipcRenderer.send('openServiceSettings', null));
settingsButton = document.getElementById('settings-button');
settingsButton?.addEventListener('click', () => ipcRenderer.send('openSettings', null));
@ -414,91 +437,135 @@ function setActiveService(serviceId: number) {
}
// Hide previous service
if (typeof selectedServiceId === 'number') {
const selectedService = services[selectedServiceId];
if (selectedService && selectedService.view) {
selectedService.view.classList.remove('active');
}
if (services[selectedService] && services[selectedService].view) {
services[selectedService].view.classList.remove('active');
}
// Show service
currentService?.view?.classList.add('active');
if (currentService) {
currentService.view.classList.add('active');
}
// Save active service ID
selectedServiceId = serviceId;
selectedService = serviceId;
// Refresh navigation
updateNavigation();
});
}
function loadService(serviceId: number, service: FrontService) {
function loadService(serviceId: number, service: any) {
// Load service if not loaded yet
if (!service.view && !service.viewReady) {
console.log('Loading service', serviceId);
document.querySelector('#services > .loader')?.classList.remove('hidden');
const view = service.view = document.createElement('webview');
updateNavigation(); // Start loading animation
view.setAttribute('enableRemoteModule', 'false');
view.setAttribute('partition', 'persist:service_' + service.partition);
view.setAttribute('autosize', 'true');
view.setAttribute('allowpopups', 'true');
if (specialPages) view.setAttribute('src', specialPages.empty);
service.view = document.createElement('webview');
service.view.setAttribute('enableRemoteModule', 'false');
service.view.setAttribute('partition', 'persist:service_' + service.partition);
service.view.setAttribute('autosize', 'true');
service.view.setAttribute('src', emptyPage);
// Error handling
view.addEventListener('did-fail-load', (e: DidFailLoadEvent) => {
if (e.errorCode <= -100 && e.errorCode > -200) {
if (specialPages) view.setAttribute('src', specialPages.connectionError);
} else if (e.errorCode === -6) {
if (specialPages) view.setAttribute('src', specialPages.fileNotFound);
} else if (e.errorCode !== -3) {
console.error('Unhandled error:', e);
}
});
// Enable context isolation. This is currently not used as there is no preload script; however it could prevent
// eventual future human mistakes.
service.view.setAttribute('webpreferences', 'contextIsolation=yes');
// Append element to DOM
document.querySelector('#services')?.appendChild(view);
document.querySelector('#services')?.appendChild(service.view);
// Load chain
let listener: () => void;
view.addEventListener('dom-ready', listener = () => {
view.removeEventListener('dom-ready', listener);
let listener: Function;
service.view.addEventListener('dom-ready', listener = () => {
service.view.removeEventListener('dom-ready', listener);
view.addEventListener('dom-ready', listener = () => {
service.view.addEventListener('dom-ready', listener = () => {
if (service.customCSS) {
view.insertCSS(service.customCSS)
.catch(console.error);
service.view.insertCSS(service.customCSS);
}
document.querySelector('#services > .loader')?.classList.add('hidden');
service.li?.classList.add('loaded');
service.li.classList.add('loaded');
service.viewReady = true;
updateNavigation();
if (selectedServiceId === null) {
if (selectedService === null) {
setActiveService(serviceId);
}
});
const webContents = remote.webContents.fromId(service.view.getWebContentsId());
// Set custom user agent
if (typeof service.customUserAgent === 'string') {
ipcRenderer.send('set-web-contents-user-agent', view.getWebContentsId(), service.customUserAgent);
webContents.setUserAgent(service.customUserAgent);
}
// Set context menu
ipcRenderer.send('open-service-content-context-menu', view.getWebContentsId());
setContextMenu(webContents);
// Set permission request handler
ipcRenderer.send('set-partition-permissions', serviceId, view.partition);
function getUrlDomain(url: string) {
let matches = url.match(/^https?:\/\/((.+?)\/|(.+))/i);
if (matches !== null) {
let domain = matches[1];
if (domain.endsWith('/')) domain = domain.substr(0, domain.length - 1);
return domain;
}
view.setAttribute('src', service.url);
return '';
}
function getDomainPermissions(domain: string) {
let domainPermissions = service.permissions[domain];
if (!domainPermissions) domainPermissions = service.permissions[domain] = [];
return domainPermissions;
}
let serviceSession = session.fromPartition(service.view.partition);
serviceSession.setPermissionRequestHandler(((webContents, permissionName, callback, details) => {
let domain = getUrlDomain(details.requestingUrl);
let domainPermissions = getDomainPermissions(domain);
let existingPermissions = domainPermissions.filter((p: any) => p.name === permissionName);
if (existingPermissions.length > 0) {
callback(existingPermissions[0].authorized);
return;
}
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'question',
title: 'Grant ' + permissionName + ' permission',
message: 'Do you wish to grant the ' + permissionName + ' permission to ' + domain + '?',
buttons: ['Deny', 'Authorize'],
cancelId: 0,
}).then(result => {
const authorized = result.response === 1;
domainPermissions.push({
name: permissionName,
authorized: authorized,
});
updateServicePermissions(serviceId);
console.log(authorized ? 'Granted' : 'Denied', permissionName, 'for domain', domain);
callback(authorized);
}).catch(console.error);
}));
serviceSession.setPermissionCheckHandler((webContents1, permissionName, requestingOrigin, details) => {
console.log('Permission check', permissionName, requestingOrigin, details);
let domain = getUrlDomain(details.requestingUrl);
let domainPermissions = getDomainPermissions(domain);
let existingPermissions = domainPermissions.filter((p: any) => p.name === permissionName);
return existingPermissions.length > 0 && existingPermissions[0].authorized;
});
service.view.setAttribute('src', service.url);
});
// Load favicon
view.addEventListener('page-favicon-updated', (event: PageFaviconUpdatedEvent) => {
service.view.addEventListener('page-favicon-updated', (event: PageFaviconUpdatedEvent) => {
console.debug('Loaded favicons for', service.name, event.favicons);
if (event.favicons.length > 0 && service.favicon !== event.favicons[0]) {
ipcRenderer.send('setServiceFavicon', serviceId, event.favicons[0]);
@ -507,21 +574,19 @@ function loadService(serviceId: number, service: FrontService) {
img.src = event.favicons[0];
img.alt = service.name;
img.onload = () => {
if (service.li) {
service.li.button.innerHTML = '';
service.li.button.appendChild(img);
}
};
}
}
});
// Display target urls
view.addEventListener('update-target-url', (event: UpdateTargetUrlEvent) => {
service.view.addEventListener('update-target-url', (event: UpdateTargetUrlEvent) => {
if (event.url.length === 0) {
urlPreview?.classList.add('invisible');
urlPreview?.classList.add('hidden');
} else {
urlPreview?.classList.remove('invisible');
urlPreview?.classList.remove('hidden');
if (urlPreview) {
urlPreview.innerHTML = event.url;
}
@ -532,28 +597,21 @@ function loadService(serviceId: number, service: FrontService) {
function unloadService(serviceId: number) {
const service = services[serviceId];
if (!service) throw new Error('Service doesn\'t exist.');
if (service.view && service.viewReady) {
service.view.remove();
service.view = undefined;
service.li?.classList.remove('loaded');
service.view = null;
service.li.classList.remove('loaded');
service.viewReady = false;
if (selectedServiceId === serviceId) {
selectedServiceId = null;
if (parseInt(selectedService) === serviceId) {
selectedService = null;
for (let i = 0; i < services.length; i++) {
const otherService = services[i];
if (otherService && otherService.view && otherService.viewReady) {
if (services[i].view && services[i].viewReady) {
setActiveService(i);
break;
}
}
// false positive:
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selectedServiceId === null) {
if (selectedService === null) {
updateNavigation();
}
}
@ -562,8 +620,6 @@ function unloadService(serviceId: number) {
function reloadService(serviceId: number) {
const service = services[serviceId];
if (!service) throw new Error('Service doesn\'t exist.');
if (service.view && service.viewReady) {
console.log('Reloading service', serviceId);
document.querySelector('#services > .loader')?.classList.remove('hidden');
@ -573,32 +629,30 @@ function reloadService(serviceId: number) {
}
}
function updateServicePermissions(serviceId: number) {
const service = services[serviceId];
ipcRenderer.send('updateServicePermissions', serviceId, service.permissions);
}
function updateNavigation() {
console.debug('Updating navigation');
// Update active list element
for (let i = 0; i < services.length; i++) {
const service = services[i];
if (!service) continue;
if (!service.li) continue;
// Active?
if (selectedServiceId === i) service.li.classList.add('active');
if (parseInt(selectedService) === i) service.li.classList.add('active');
else service.li.classList.remove('active');
// Loading?
if (service.view && !service.viewReady) service.li.classList.add('loading');
else service.li.classList.remove('loading');
// Loaded?
if (service.viewReady) service.li.classList.add('loaded');
else service.li.classList.remove('loaded');
}
if (selectedServiceId !== null && services[selectedServiceId]?.viewReady) {
if (selectedService !== null && services[selectedService].viewReady) {
console.debug('Updating navigation buttons because view is ready');
// Update history navigation
const view = services[selectedServiceId]?.view;
let view = services[selectedService].view;
homeButton?.classList.remove('disabled');
@ -617,70 +671,164 @@ function updateNavigation() {
}
function updateStatusButton() {
if (typeof selectedServiceId !== 'number') return;
const protocol = services[selectedServiceId]?.view?.getURL().split('://')[0] || 'unknown';
securityButton?.childNodes.forEach(el => {
if (el instanceof HTMLElement) {
if (el.classList.contains(protocol)) el.classList.add('active');
else el.classList.remove('active');
let protocol = services[selectedService].view.getURL().split('://')[0];
if (!protocol) protocol = 'unknown';
for (const c of <any>securityButton?.children) {
if (c.classList.contains(protocol)) c.classList.add('active');
else c.classList.remove('active');
}
});
}
function updateWindowTitle() {
if (selectedServiceId === null) {
ipcRenderer.send('update-window-title', null);
} else {
const service = services[selectedServiceId];
if (service?.viewReady && service.view) {
ipcRenderer.send('update-window-title', selectedServiceId, service.view.getWebContentsId());
}
if (selectedService === null) {
ipcRenderer.send('updateWindowTitle', null);
} else if (services[selectedService].viewReady) {
ipcRenderer.send('updateWindowTitle', selectedService, remote.webContents.fromId(services[selectedService].view.getWebContentsId()).getTitle());
}
}
function goHome() {
if (selectedServiceId === null) return;
const service = services[selectedServiceId];
if (!service) throw new Error('Service doesn\'t exist.');
service.view?.loadURL(service.url)
let service = services[selectedService];
service.view.loadURL(service.url)
.catch(console.error);
}
function goForward() {
if (selectedServiceId === null) return;
const view = services[selectedServiceId]?.view;
if (view) ipcRenderer.send('go-forward', view.getWebContentsId());
let view = services[selectedService].view;
if (view) remote.webContents.fromId(view.getWebContentsId()).goForward();
}
function goBack() {
if (selectedServiceId === null) return;
const view = services[selectedServiceId]?.view;
if (view) ipcRenderer.send('go-back', view.getWebContentsId());
let view = services[selectedService].view;
if (view) remote.webContents.fromId(view.getWebContentsId()).goBack();
}
function reload() {
if (selectedServiceId === null) return;
reloadService(selectedServiceId);
reloadService(selectedService);
}
ipcRenderer.on('fullscreenchange', (e, fullscreen: boolean) => {
if (fullscreen) document.body.classList.add('fullscreen');
else document.body.classList.remove('fullscreen');
function setContextMenu(webContents: WebContents) {
webContents.on('context-menu', (event, props) => {
const menu = new Menu();
const {editFlags} = props;
// linkURL
if (props.linkURL.length > 0) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Copy link URL',
click: () => {
clipboard.writeText(props.linkURL);
},
}));
menu.append(new MenuItem({
label: 'Open URL in default browser',
click: () => {
if (props.linkURL.startsWith('https://')) {
shell.openExternal(props.linkURL)
.catch(console.error);
}
},
}));
}
// Image
if (props.hasImageContents) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Copy image',
click: () => {
webContents.copyImageAt(props.x, props.y);
},
}));
menu.append(new MenuItem({
label: 'Save image as',
click: () => {
webContents.downloadURL(props.srcURL);
},
}));
}
// Text clipboard
if (editFlags.canUndo || editFlags.canRedo || editFlags.canCut || editFlags.canCopy || editFlags.canPaste || editFlags.canDelete) {
if (editFlags.canUndo || editFlags.canRedo) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
if (editFlags.canUndo) {
menu.append(new MenuItem({
label: 'Undo',
role: 'undo',
}));
}
if (editFlags.canRedo) {
menu.append(new MenuItem({
label: 'Redo',
role: 'redo',
}));
}
}
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Cut',
role: 'cut',
enabled: editFlags.canCut,
}));
menu.append(new MenuItem({
label: 'Copy',
role: 'copy',
enabled: editFlags.canCopy,
}));
menu.append(new MenuItem({
label: 'Paste',
role: 'paste',
enabled: editFlags.canPaste,
}));
menu.append(new MenuItem({
label: 'Delete',
role: 'delete',
enabled: editFlags.canDelete,
}));
}
if (editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Select all',
role: 'selectAll',
}));
}
// Inspect element
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Inspect element',
click: () => {
webContents.inspectElement(props.x, props.y);
},
}));
menu.popup({
window: remote.getCurrentWindow(),
});
type FrontService = Service & {
view?: Electron.WebviewTag;
viewReady?: boolean;
li?: NavigationElement;
};
type NavigationElement = HTMLLIElement & {
serviceId: number;
button: HTMLButtonElement;
};
});
}

View File

@ -1,6 +1,4 @@
import {ipcRenderer} from "electron";
import Service from "../../src/Service";
import {IconProperties, IconSet} from "../../src/Meta";
import {ipcRenderer, remote} from "electron";
let isImageCheckbox: HTMLInputElement | null;
let builtInIconSearchField: HTMLInputElement | null;
@ -14,22 +12,22 @@ let autoLoadInput: HTMLInputElement | null;
let customCssInput: HTMLInputElement | null;
let customUserAgentInput: HTMLInputElement | null;
let serviceId: number | null = null;
let service: Service | null = null;
let serviceId: number;
let service: any;
ipcRenderer.on('syncIcons', (event, iconSets: IconSet[]) => {
let icons: IconProperties[] = [];
ipcRenderer.on('syncIcons', (event, iconSets) => {
let icons: any[] = [];
for (const set of iconSets) {
icons = icons.concat(set);
}
loadIcons(icons);
});
ipcRenderer.on('loadService', (e, id: number | null, data?: Service) => {
ipcRenderer.on('loadService', (e, id, data) => {
console.log('Load service', id);
if (id === null || !data) {
if (id === null) {
document.title = 'Add a new service';
service = null;
service = {};
if (h1) h1.innerText = 'Add a new service';
} else {
@ -56,56 +54,41 @@ document.addEventListener('DOMContentLoaded', () => {
isImageCheckbox?.addEventListener('click', () => {
updateIconChoiceForm(!!isImageCheckbox?.checked);
updateIconChoiceForm(isImageCheckbox?.checked);
});
updateIconChoiceForm(!!isImageCheckbox?.checked);
updateIconChoiceForm(isImageCheckbox?.checked);
builtInIconSearchField?.addEventListener('input', updateIconSearchResults);
document.getElementById('cancel-button')?.addEventListener('click', e => {
e.preventDefault();
ipcRenderer.send('close-window', 'ServiceSettingsWindow');
});
document.querySelector('form')?.addEventListener('submit', e => {
e.preventDefault();
save();
remote.getCurrentWindow().close();
});
ipcRenderer.send('sync-settings');
document.getElementById('userAgentAutoFillFirefox')?.addEventListener('click', () => {
const customUserAgent = document.querySelector<HTMLInputElement>('#custom-user-agent');
document.getElementById('userAgentAutoFill')?.addEventListener('click', () => {
let customUserAgent = document.getElementById('custom-user-agent');
if (customUserAgent) {
customUserAgent.value = 'Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0';
}
});
document.getElementById('userAgentAutoFillChrome')?.addEventListener('click', () => {
const customUserAgent = document.querySelector<HTMLInputElement>('#custom-user-agent');
if (customUserAgent) {
customUserAgent.value = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36';
(<HTMLInputElement>customUserAgent).value = 'Mozilla/5.0 (X11; Linux x86_64; rv:73.0) Gecko/20100101 Firefox/73.0';
}
});
});
function updateIconSearchResults() {
if (!builtInIconSearchField) throw new Error('builtInIconSearchField no initialized.');
const searchStr: string = builtInIconSearchField.value;
iconSelect?.childNodes.forEach((el) => {
if (el instanceof HTMLElement) {
const parts = el.dataset.icon?.split('/') || '';
const iconName = parts[1] || parts[0];
const searchStr: string = builtInIconSearchField!.value;
(<any>iconSelect?.childNodes).forEach((c: HTMLElement) => {
let parts = c.dataset.icon?.split('/') || '';
let iconName = parts[1] || parts[0];
if (iconName.match(searchStr) || searchStr.match(iconName)) {
el.classList.remove('hidden');
c.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
c.classList.add('hidden');
}
});
}
function loadIcons(icons: IconProperties[]) {
function loadIcons(icons: any[]) {
icons.sort((a, b) => a.name > b.name ? 1 : -1);
for (const icon of icons) {
if (icon.name.length === 0) continue;
@ -138,16 +121,16 @@ function loadIcons(icons: IconProperties[]) {
function selectIcon(choice: HTMLElement) {
if (builtInIconSearchField) builtInIconSearchField.value = choice.dataset.icon || '';
if (iconSelect) {
iconSelect.childNodes.forEach(el => {
if (el instanceof Element) el.classList.remove('selected');
});
for (const otherChoice of <any>iconSelect.children) {
otherChoice.classList.remove('selected');
}
}
choice.classList.add('selected');
const radio: HTMLInputElement | null = choice.querySelector('input[type=radio]');
let radio: HTMLInputElement | null = choice.querySelector('input[type=radio]');
if (radio) radio.checked = true;
}
function updateIconChoiceForm(isUrl: boolean) {
function updateIconChoiceForm(isUrl: any) {
if (isUrl) {
iconSelect?.classList.add('hidden');
builtInIconSearchField?.parentElement?.classList.add('hidden');
@ -165,22 +148,21 @@ function loadServiceValues() {
}
if (nameInput) nameInput.value = service.name;
if (urlInput) urlInput.value = service.url || '';
if (urlInput) urlInput.value = service.url;
if (useFaviconInput) useFaviconInput.checked = service.useFavicon;
if (autoLoadInput) autoLoadInput.checked = service.autoLoad;
if (customCssInput) customCssInput.value = service.customCSS || '';
if (customUserAgentInput) customUserAgentInput.value = service.customUserAgent || '';
if (customCssInput) customCssInput.value = service.customCSS;
if (customUserAgentInput) customUserAgentInput.value = service.customUserAgent;
isImageCheckbox.checked = service.isImage;
if (service.isImage && service.icon) {
if (service.isImage) {
if (iconUrlField) iconUrlField.value = service.icon;
} else {
if (builtInIconSearchField && service.icon) builtInIconSearchField.value = service.icon;
if (builtInIconSearchField) builtInIconSearchField.value = service.icon;
updateIconSearchResults();
const labels = iconSelect?.querySelectorAll('label');
let labels = iconSelect?.querySelectorAll('label');
if (labels) {
const _service = service;
const icon = Array.from(labels).find(i => i.dataset.icon === _service.icon);
const icon = Array.from(labels).find(i => i.dataset.icon === service.icon);
if (icon) {
selectIcon(icon);
}
@ -188,59 +170,43 @@ function loadServiceValues() {
}
}
function save() {
if (!service) {
// Don't use new Service() to avoid depending on src/ file.
service = {
name: '',
partition: '',
url: '',
isImage: false,
useFavicon: false,
autoLoad: false,
permissions: {},
};
}
const form = document.querySelector('form');
(window as any).save = () => {
let form = document.querySelector('form');
if (!form) return;
const formData = new FormData(form);
service.name = String(formData.get('name'));
if (service.partition.length === 0) {
service.name = formData.get('name');
if (typeof service.partition !== 'string' || service.partition.length === 0) {
service.partition = service.name.replace(/ /g, '-');
service.partition = service.partition.replace(/[^a-zA-Z-_]/g, '');
}
service.url = String(formData.get('url'));
service.url = formData.get('url');
service.isImage = formData.get('isImage') === 'on';
service.icon = String(formData.get('icon'));
service.icon = formData.get('icon');
service.useFavicon = formData.get('useFavicon') === 'on';
service.autoLoad = formData.get('autoLoad') === 'on';
service.customCSS = String(formData.get('customCSS'));
service.customCSS = formData.get('customCSS');
const customUserAgent = (<string>formData.get('customUserAgent')).trim();
service.customUserAgent = customUserAgent.length === 0 ? undefined : customUserAgent;
let customUserAgent = (<string>formData.get('customUserAgent')).trim();
service.customUserAgent = customUserAgent.length === 0 ? null : customUserAgent;
if (!isValid()) {
return;
}
ipcRenderer.send('saveService', serviceId, service);
ipcRenderer.send('close-window', 'ServiceSettingsWindow');
}
remote.getCurrentWindow().close();
};
function isValid() {
if (!service) return false;
if (service.name.length === 0) {
if (typeof service.name !== 'string' || service.name.length === 0) {
console.log('Invalid name');
return false;
}
if (service.partition.length === 0) {
if (typeof service.partition !== 'string' || service.partition.length === 0) {
console.log('Invalid partition');
return false;
}
if (service.url.length === 0) {
if (typeof service.url !== 'string' || service.url.length === 0) {
console.log('Invalid url');
return false;
}

View File

@ -1,17 +1,10 @@
import {ipcRenderer, shell} from "electron";
import Config from "../../src/Config";
import {SemVer} from "semver";
import {UpdateInfo} from "electron-updater";
import {ipcRenderer, remote, shell} from "electron";
let currentVersion: HTMLElement | null;
let updateStatus: HTMLElement | null;
let updateInfo: UpdateInfo;
let updateInfo: any;
let updateButton: HTMLElement | null;
let config: Config;
let startMinimizedField: HTMLInputElement | null;
let bigNavBarField: HTMLInputElement | null;
let config: any;
let securityButtonField: HTMLInputElement | null,
homeButtonField: HTMLInputElement | null,
@ -19,16 +12,12 @@ let securityButtonField: HTMLInputElement | null,
forwardButtonField: HTMLInputElement | null,
refreshButtonField: HTMLInputElement | null;
ipcRenderer.on('current-version', (e, version: SemVer) => {
ipcRenderer.on('current-version', (e, version) => {
if (currentVersion) currentVersion.innerText = `Version: ${version.version}`;
});
ipcRenderer.on('config', (e, c: Config) => {
ipcRenderer.on('config', (e, c) => {
config = c;
if (startMinimizedField) startMinimizedField.checked = config.startMinimized;
if (bigNavBarField) bigNavBarField.checked = config.bigNavBar;
if (securityButtonField) securityButtonField.checked = config.securityButton;
if (homeButtonField) homeButtonField.checked = config.homeButton;
if (backButtonField) backButtonField.checked = config.backButton;
@ -36,7 +25,7 @@ ipcRenderer.on('config', (e, c: Config) => {
if (refreshButtonField) refreshButtonField.checked = config.refreshButton;
});
ipcRenderer.on('updateStatus', (e, available: boolean, version: UpdateInfo) => {
ipcRenderer.on('updateStatus', (e, available, version) => {
console.log(available, version);
updateInfo = version;
if (available) {
@ -47,15 +36,11 @@ ipcRenderer.on('updateStatus', (e, available: boolean, version: UpdateInfo) => {
}
});
function save() {
const form = document.querySelector('form');
(window as any).save = () => {
let form = document.querySelector('form');
if (!form) return;
const formData = new FormData(form);
config.startMinimized = formData.get('start-minimized') === 'on';
config.bigNavBar = formData.get('big-nav-bar') === 'on';
config.securityButton = formData.get('security-button') === 'on';
config.homeButton = formData.get('home-button') === 'on';
config.backButton = formData.get('back-button') === 'on';
@ -63,22 +48,18 @@ function save() {
config.refreshButton = formData.get('refresh-button') === 'on';
ipcRenderer.send('save-config', config);
ipcRenderer.send('close-window', 'SettingsWindow');
}
remote.getCurrentWindow().close();
};
document.addEventListener('DOMContentLoaded', () => {
currentVersion = document.getElementById('current-version');
updateStatus = document.getElementById('update-status');
updateButton = document.getElementById('download-button');
updateButton?.addEventListener('click', () => {
shell.openExternal(`https://update.eternae.ink/ashpie/tabs/${updateInfo.path}`)
shell.openExternal(`https://github.com/ArisuOngaku/tabs/releases/download/v${updateInfo.version}/${updateInfo.path}`)
.catch(console.error);
});
startMinimizedField = <HTMLInputElement>document.getElementById('start-minimized');
bigNavBarField = <HTMLInputElement>document.getElementById('big-nav-bar');
securityButtonField = <HTMLInputElement>document.getElementById('security-button');
homeButtonField = <HTMLInputElement>document.getElementById('home-button');
backButtonField = <HTMLInputElement>document.getElementById('back-button');
@ -87,12 +68,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('cancel-button')?.addEventListener('click', e => {
e.preventDefault();
ipcRenderer.send('close-window', 'SettingsWindow');
});
document.querySelector('form')?.addEventListener('submit', e => {
e.preventDefault();
save();
remote.getCurrentWindow().close();
});
ipcRenderer.send('syncSettings');

View File

@ -1,24 +1,22 @@
{
"name": "tabs",
"version": "1.3.1",
"version": "1.0.0",
"description": "Persistent and separate browser tabs in one window.",
"author": {
"name": "Alice Gaudon",
"email": "alice@gaudon.pro"
},
"homepage": "https://eternae.ink/ashpie/tabs",
"homepage": "https://gitlab.com/ArisuOngaku/tabs",
"license": "GPL-3.0-only",
"main": "build/main.js",
"scripts": {
"clean": "(! test -d build || rm -r build) && (! test -d resources || rm -r resources)",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"compile": "yarn compile-common && webpack --mode production",
"compile-dev": "yarn compile-common && webpack --mode development",
"compile-common": "yarn clean && tsc -p tsconfig.backend.json && tsc -p tsconfig.frontend.json && rm -r resources/js/src && mv resources/js/frontend/ts/* resources/js/ && rm -r resources/js/frontend",
"compile": "yarn clean && tsc -p tsconfig.backend.json && tsc -p tsconfig.frontend.json && webpack --mode production",
"compile-dev": "yarn clean && tsc -p tsconfig.backend.json && tsc -p tsconfig.frontend.json && webpack --mode development",
"start": "yarn compile && electron .",
"dev": "yarn compile-dev && concurrently -k -n \"Electron,Webpack,TSC-Frontend\" -p \"[{name}]\" -c \"green,yellow\" \"electron . --dev\" \"webpack --mode development --watch\" \"tsc -p tsconfig.frontend.json --watch\"",
"build": "yarn compile && electron-builder -wl",
"release": "yarn lint && yarn compile && GH_TOKEN=$(cat GH_TOKEN) electron-builder -wlp always"
"release": "yarn compile && GH_TOKEN=$(cat GH_TOKEN) electron-builder -wlp always"
},
"dependencies": {
"appdata-path": "^1.0.0",
@ -30,31 +28,28 @@
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@fortawesome/fontawesome-free": "^6.1.0",
"@types/node": "^14.6.2",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"@fortawesome/fontawesome-free": "^5.13.0",
"@types/node": "^12.12.41",
"babel-loader": "^8.1.0",
"concurrently": "^7.0.0",
"copy-webpack-plugin": "^10.2.4",
"css-loader": "^6.3.0",
"electron": "^17.1.2",
"electron-builder": "^22.11.5",
"eslint": "^8.11.0",
"image-minimizer-webpack-plugin": "^3.2.3",
"imagemin": "^8.0.1",
"concurrently": "^5.2.0",
"copy-webpack-plugin": "^6.0.1",
"css-loader": "^3.5.3",
"electron": "^9.0.0",
"electron-builder": "^22.4.0",
"file-loader": "^6.0.0",
"imagemin": "^7.0.1",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-pngquant": "^9.0.2",
"imagemin-svgo": "^10.0.1",
"mini-css-extract-plugin": "^2.1.0",
"sass": "^1.32.12",
"sass-loader": "^12.1.0",
"svgo": "^2.3.1",
"ts-loader": "^9.1.2",
"typescript": "^4.0.2",
"webpack": "^5.2.0",
"webpack-cli": "^4.1.0"
"imagemin-mozjpeg": "^8.0.0",
"imagemin-pngquant": "^8.0.0",
"imagemin-svgo": "^8.0.0",
"img-loader": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.14.1",
"sass-loader": "^8.0.2",
"ts-loader": "^7.0.4",
"typescript": "^3.9.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"build": {
"appId": "tabs-app",
@ -62,33 +57,48 @@
"resources/**/*",
"build/**/*"
],
"publish": [
{
"provider": "generic",
"url": "https://update.eternae.ink/ashpie/tabs"
}
],
"linux": {
"target": "AppImage",
"icon": "frontend/images/logo.png",
"icon": "resources/logo.png",
"category": "Utility",
"executableName": "tabs",
"desktop": {
"StartupWMClass": "Tabs",
"MimeType": "x-scheme-handler/tabs"
},
"publish": [
{
"provider": "github",
"owner": "ArisuOngaku",
"repo": "tabs"
}
]
},
"win": {
"target": "nsis",
"icon": "frontend/images/logo.png",
"icon": "resources/logo.png",
"publisherName": "Alice Gaudon",
"verifyUpdateCodeSignature": "false"
"verifyUpdateCodeSignature": "true",
"publish": [
{
"provider": "github",
"owner": "ArisuOngaku",
"repo": "tabs"
}
]
},
"mac": {
"target": "default",
"icon": "frontend/images/logo.png",
"category": "public.app-category.utilities"
"icon": "resources/logo.png",
"category": "public.app-category.utilities",
"publish": [
{
"provider": "github",
"owner": "ArisuOngaku",
"repo": "tabs"
}
]
},
"electronVersion": "15.0.0"
"electronVersion": "9.0.0"
}
}

View File

@ -1,9 +1,8 @@
import {app, dialog, Menu, shell, Tray} from "electron";
import {app, Menu, shell, Tray} from "electron";
import Meta from "./Meta";
import Config from "./Config";
import Updater from "./Updater";
import MainWindow from "./windows/MainWindow";
import * as os from "os";
export default class Application {
private readonly devMode: boolean;
@ -12,10 +11,10 @@ export default class Application {
private readonly mainWindow: MainWindow;
private tray?: Tray;
public constructor(devMode: boolean) {
constructor(devMode: boolean) {
this.devMode = devMode;
this.config = new Config();
this.updater = new Updater(this.config, this);
this.updater = new Updater(this.config);
this.mainWindow = new MainWindow(this);
}
@ -27,11 +26,9 @@ export default class Application {
this.setupElectronTweaks();
// Check for updates
if (os.platform() === 'win32') {
this.updater.checkAndPromptForUpdates(this.mainWindow.getWindow()).then(() => {
console.log('Update check successful.');
}).catch(console.error);
}
console.log('App started');
}
@ -52,48 +49,27 @@ export default class Application {
return this.devMode;
}
public async openExternalLink(url: string): Promise<void> {
if (url.startsWith('https://')) {
console.log('Opening link', url);
await shell.openExternal(url);
} else {
const {response} = await dialog.showMessageBox({
message: 'Are you sure you want to open this link?\n' + url,
type: 'question',
buttons: ['Cancel', 'Open link'],
});
if (response === 1) {
console.log('Opening link', url);
await shell.openExternal(url);
}
}
}
private setupElectronTweaks() {
// Open external links in default OS browser
app.on('web-contents-created', (e, contents) => {
if (contents.getType() === 'webview') {
console.log('Setting external links to open in default OS browser');
contents.setWindowOpenHandler(details => {
if (details.url.startsWith(details.referrer.url)) return {action: 'allow'};
const url = details.url;
this.openExternalLink(url)
.catch(console.error);
return {action: 'deny'};
contents.on('new-window', (e, url) => {
e.preventDefault();
if (url.startsWith('https://')) {
shell.openExternal(url).catch(console.error);
}
});
}
});
// Disable unused features
app.on('web-contents-created', (e, contents) => {
contents.on('will-attach-webview', (e, webPreferences) => {
contents.on('will-attach-webview', (e, webPreferences, params) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
// TODO: Here would be a good place to filter accessed urls (params.src).
// Also consider 'will-navigate' event on contents.
// TODO: Here would be a good place to filter accessed urls (params.src). Also consider 'will-navigate' event on contents.
});
});
}
@ -106,7 +82,7 @@ export default class Application {
{label: 'Tabs', enabled: false},
{label: 'Open Tabs', click: () => this.mainWindow.getWindow().show()},
{type: 'separator'},
{label: 'Quit', role: 'quit'},
{label: 'Quit', role: 'quit'}
]));
this.tray.on('click', () => this.mainWindow.toggle());
}

View File

@ -10,13 +10,7 @@ const configFile = path.resolve(configDir, 'config.json');
export default class Config {
public services: Service[] = [];
public updateCheckSkip?: string;
public startMinimized: boolean = false;
public bigNavBar: boolean = false;
public securityButton: boolean = true;
public homeButton: boolean = false;
public backButton: boolean = true;
@ -25,11 +19,9 @@ export default class Config {
private properties: string[] = [];
[p: string]: unknown;
public constructor() {
constructor() {
// Load data from config file
let data: Record<string, unknown> = {};
let data: any = {};
if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) {
if (fs.existsSync(configFile) && fs.statSync(configFile).isFile())
data = JSON.parse(fs.readFileSync(configFile, 'utf8'));
@ -38,7 +30,7 @@ export default class Config {
}
// Parse services
if (typeof data.services === 'object' && Array.isArray(data.services)) {
if (typeof data.services === 'object') {
let i = 0;
for (const service of data.services) {
this.services[i] = new Service(service);
@ -47,22 +39,11 @@ export default class Config {
}
if (this.services.length === 0) {
this.services.push(new Service(
'welcome',
'Welcome',
'rocket',
false,
'https://eternae.ink/ashpie/tabs',
false,
));
this.services.push(new Service('welcome', 'Welcome', 'rocket', false, 'https://github.com/ArisuOngaku/tabs', false));
}
this.defineProperty('updateCheckSkip', data);
this.defineProperty('startMinimized', data);
this.defineProperty('bigNavBar', data);
this.defineProperty('securityButton', data);
this.defineProperty('homeButton', data);
this.defineProperty('backButton', data);
@ -72,24 +53,25 @@ export default class Config {
this.save();
}
public save(): void {
save() {
console.log('Saving config');
this.services = this.services.filter(s => s !== null);
fs.writeFileSync(configFile, JSON.stringify(this, null, 4));
console.log('> Config saved to', configFile.toString());
}
public defineProperty(name: string, data: Record<string, unknown>): void {
defineProperty(name: string, data: any) {
if (data[name] !== undefined) {
this[name] = data[name];
(<any>this)[name] = data[name];
}
this.properties.push(name);
}
public update(data: Record<string, unknown>): void {
update(data: any) {
for (const prop of this.properties) {
if (data[prop] !== undefined) {
this[prop] = data[prop];
(<any>this)[prop] = data[prop];
}
}
}

View File

@ -13,7 +13,7 @@ export default class Meta {
public static readonly BRAND_ICONS = Meta.listIcons('brands');
public static readonly SOLID_ICONS = Meta.listIcons('solid');
public static readonly REGULAR_ICONS = Meta.listIcons('regular');
public static readonly ICON_SETS: IconSet[] = [
public static readonly ICON_SETS = [
Meta.BRAND_ICONS,
Meta.SOLID_ICONS,
Meta.REGULAR_ICONS,
@ -21,7 +21,7 @@ export default class Meta {
private static devMode?: boolean;
public static isDevMode(): boolean {
public static isDevMode() {
if (this.devMode === undefined) {
this.devMode = process.argv.length > 2 && process.argv[2] === '--dev';
console.debug('Dev mode:', this.devMode);
@ -30,7 +30,7 @@ export default class Meta {
return this.devMode;
}
public static getTitleForService(service: Service, viewTitle: string): string {
public static getTitleForService(service: Service, viewTitle: string) {
let suffix = '';
if (viewTitle.length > 0) {
suffix = ' - ' + viewTitle;
@ -38,7 +38,7 @@ export default class Meta {
return this.title + ' - ' + service.name + suffix;
}
private static listIcons(set: string): IconSet {
private static listIcons(set: string) {
console.log('Loading icon set', set);
const directory = path.resolve(Meta.RESOURCES_PATH, `images/icons/${set}`);
const icons: { name: string; faIcon: string; set: string; }[] = [];
@ -51,17 +51,3 @@ export default class Meta {
return icons;
}
}
export type SpecialPages = {
empty: string;
connectionError: string;
fileNotFound: string;
};
export type IconProperties = {
name: string;
faIcon: string;
set: string;
};
export type IconSet = IconProperties[];

View File

@ -1,77 +1,31 @@
export default class Service {
public name: string;
public partition: string;
public url: string;
public partition?: string;
public name?: string;
public icon?: string;
public isImage: boolean = false;
public useFavicon: boolean = true;
public isImage?: boolean = false;
public url?: string;
public useFavicon?: boolean = true;
public favicon?: string;
public autoLoad: boolean = false;
public autoLoad?: boolean = false;
public customCSS?: string;
public customUserAgent?: string;
public permissions: ServicePermissions = {};
public permissions?: {} = {};
[p: string]: unknown;
public constructor(
partition: string | Pick<Service, keyof Service>,
name?: string,
icon?: string,
isImage?: boolean,
url?: string,
useFavicon?: boolean,
) {
const data = arguments.length === 1 ? partition as Record<string, unknown> : {
name,
icon,
isImage,
url,
useFavicon,
};
if (typeof data.name === 'string') this.name = data.name;
else throw new Error('A service must have a name');
if (typeof data.partition === 'string') this.partition = data.partition;
else this.partition = this.name;
if (typeof data.url === 'string') this.url = data.url;
else throw new Error('A service must have a url.');
if (typeof data.icon === 'string') this.icon = data.icon;
if (typeof data.isImage === 'boolean') this.isImage = data.isImage;
if (typeof data.useFavicon === 'boolean') this.useFavicon = data.useFavicon;
if (typeof data.favicon === 'string') this.favicon = data.favicon;
if (typeof data.autoLoad === 'boolean') this.autoLoad = data.autoLoad;
if (typeof data.customCSS === 'string') this.customCSS = data.customCSS;
if (typeof data.customUserAgent === 'string') this.customUserAgent = data.customUserAgent;
if (typeof data.permissions === 'object' && data.permissions !== null) {
for (const domain of Object.keys(data.permissions)) {
this.permissions[domain] = [];
const permissions = (data.permissions as Record<string, unknown>)[domain];
if (Array.isArray(permissions)) {
for (const permission of permissions) {
if (typeof permission.name === 'string' &&
typeof permission.authorized === 'boolean') {
this.permissions[domain]?.push(permission);
constructor(partition: string, name?: string, icon?: string, isImage?: boolean, url?: string, useFavicon?: boolean) {
if (arguments.length === 1) {
const data = arguments[0];
for (const k in data) {
if (data.hasOwnProperty(k)) {
(<any>this)[k] = data[k];
}
}
} else {
this.partition = partition;
this.name = name;
this.icon = icon;
this.isImage = isImage;
this.url = url;
this.useFavicon = useFavicon;
}
}
}
}
}
}
}
export type ServicePermission = {
name: string;
authorized: boolean;
};
export type ServicePermissions = Record<string, ServicePermission[] | undefined>;

View File

@ -1,18 +1,14 @@
import {autoUpdater, UpdateInfo} from "electron-updater";
import {dialog} from "electron";
import {dialog, shell} from "electron";
import Config from "./Config";
import Application from "./Application";
import {SemVer} from "semver";
import BrowserWindow = Electron.BrowserWindow;
export default class Updater {
private readonly config: Config;
private readonly application: Application;
private updateInfo?: UpdateInfo;
public constructor(config: Config, application: Application) {
public constructor(config: Config) {
this.config = config;
this.application = application;
// Configure auto updater
autoUpdater.autoDownload = false;
@ -37,7 +33,7 @@ export default class Updater {
}
}
public getCurrentVersion(): SemVer {
public getCurrentVersion() {
return autoUpdater.currentVersion;
}
@ -46,16 +42,16 @@ export default class Updater {
if (updateInfo && updateInfo.version !== this.config.updateCheckSkip) {
const input = await dialog.showMessageBox(mainWindow, {
message: `Version ${updateInfo.version} of tabs is available. Do you wish to install this update?`,
message: `Version ${updateInfo.version} of tabs is available. Do you wish to download this update?`,
buttons: [
'Cancel',
'Install',
'Download',
],
checkboxChecked: false,
checkboxLabel: `Don't remind me for this version`,
cancelId: 0,
defaultId: 1,
type: 'question',
type: 'question'
});
if (input.checkboxChecked) {
@ -65,9 +61,7 @@ export default class Updater {
}
if (input.response === 1) {
await this.application.stop();
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
await shell.openExternal(`https://github.com/ArisuOngaku/tabs/releases/download/v${updateInfo.version}/${updateInfo.path}`);
}
}
}

View File

@ -3,9 +3,7 @@ import Application from "./Application";
import Config from "./Config";
export default abstract class Window {
private readonly listeners: {
[channel: string]: ((event: IpcMainEvent, ...args: unknown[]) => void)[] | undefined
} = {};
private readonly listeners: { [channel: string]: ((event: IpcMainEvent, ...args: any[]) => void)[] } = {};
private readonly onCloseListeners: (() => void)[] = [];
protected readonly application: Application;
@ -20,7 +18,7 @@ export default abstract class Window {
}
public setup(options: BrowserWindowConstructorOptions): void {
public setup(options: BrowserWindowConstructorOptions) {
console.log('Creating window', this.constructor.name);
if (this.parent) {
@ -32,22 +30,9 @@ export default abstract class Window {
this.teardown();
this.window = undefined;
});
this.onIpc('close-window', (
event,
constructorName: string,
) => {
if (constructorName === this.constructor.name) {
console.log('Closing', this.constructor.name);
const window = this.getWindow();
if (window.closable) {
window.close();
}
}
});
}
public teardown(): void {
public teardown() {
console.log('Tearing down window', this.constructor.name);
for (const listener of this.onCloseListeners) {
@ -55,10 +40,7 @@ export default abstract class Window {
}
for (const channel in this.listeners) {
const listeners = this.listeners[channel];
if (!listeners) continue;
for (const listener of listeners) {
for (const listener of this.listeners[channel]) {
ipcMain.removeListener(channel, listener);
}
}
@ -66,20 +48,18 @@ export default abstract class Window {
this.window = undefined;
}
// This is the spec of ipcMain.on()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected onIpc(channel: string, listener: (event: IpcMainEvent, ...args: any[]) => void): this {
ipcMain.on(channel, listener);
if (!this.listeners[channel]) this.listeners[channel] = [];
this.listeners[channel]?.push(listener);
this.listeners[channel].push(listener);
return this;
}
public onClose(listener: () => void): void {
public onClose(listener: () => void) {
this.onCloseListeners.push(listener);
}
public toggle(): void {
public toggle() {
if (this.window) {
if (!this.window.isFocused()) {
console.log('Showing window', this.constructor.name);
@ -93,7 +73,6 @@ export default abstract class Window {
public getWindow(): BrowserWindow {
if (!this.window) throw Error('Window not initialized.');
else if (this.window.isDestroyed()) throw Error('Window destroyed.');
return this.window;
}
}

View File

@ -4,9 +4,6 @@ import {app} from "electron";
import Meta from "./Meta";
import Application from "./Application";
// Fix for twitter (and others) in webviews - pending https://github.com/electron/electron/issues/25469
app.commandLine.appendSwitch('disable-features', 'CrossOriginOpenerPolicy');
const application = new Application(Meta.isDevMode());
// Check if application is already running

View File

@ -1,6 +1,6 @@
declare module "single-instance" {
export default class SingleInstance {
public constructor(lockName: string);
constructor(lockName: string);
public lock(): Promise<void>;
}

View File

@ -1,42 +1,39 @@
import path from "path";
import {clipboard, ContextMenuParams, dialog, ipcMain, Menu, MenuItem, session, webContents} from "electron";
import {ipcMain} from "electron";
import ServiceSettingsWindow from "./ServiceSettingsWindow";
import SettingsWindow from "./SettingsWindow";
import Application from "../Application";
import Meta, {SpecialPages} from "../Meta";
import Meta from "../Meta";
import Window from "../Window";
export default class MainWindow extends Window {
private activeServiceId: number = 0;
private activeService: number = 0;
private serviceSettingsWindow?: ServiceSettingsWindow;
private settingsWindow?: SettingsWindow;
public constructor(application: Application) {
constructor(application: Application) {
super(application);
}
public setup(): void {
public setup() {
super.setup({
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
webviewTag: true,
contextIsolation: false,
},
autoHideMenuBar: true,
icon: Meta.ICON_PATH,
title: Meta.title,
show: !this.application.getConfig().startMinimized,
});
const window = this.getWindow();
if (!this.application.getConfig().startMinimized) {
window.maximize();
}
if (this.application.isDevMode()) {
window.webContents.openDevTools({
mode: 'right',
mode: 'right'
});
}
@ -46,19 +43,19 @@ export default class MainWindow extends Window {
});
// Load active service
this.onIpc('setActiveService', (event, index: number) => {
this.onIpc('setActiveService', (event, index) => {
this.setActiveService(index);
});
// Set a service's favicon
this.onIpc('setServiceFavicon', (event, index: number, favicon?: string) => {
this.onIpc('setServiceFavicon', (event, index, favicon) => {
console.log('Setting service', index, 'favicon', favicon);
this.config.services[index].favicon = favicon;
this.config.save();
});
// Reorder services
this.onIpc('reorderService', (event, serviceId: number, targetId: number) => {
this.onIpc('reorderService', (event, serviceId, targetId) => {
console.log('Reordering services', serviceId, targetId);
const oldServices = this.config.services;
@ -80,14 +77,40 @@ export default class MainWindow extends Window {
this.config.save();
});
// Delete service
this.onIpc('deleteService', (e, id) => {
console.log('Deleting service', id);
delete this.config.services[id];
this.config.save();
window.webContents.send('deleteService', id);
});
// Update service permissions
ipcMain.on('updateServicePermissions', (e, serviceId, permissions) => {
this.config.services[serviceId].permissions = permissions;
this.config.save();
});
// Update window title
ipcMain.on('update-window-title', (event, serviceId: number | null, webContentsId?: number) => {
ipcMain.on('updateWindowTitle', (event, serviceId, viewTitle) => {
if (serviceId === null) {
window.setTitle(Meta.title);
} else if (webContentsId) {
} else {
const service = this.config.services[serviceId];
const serviceWebContents = webContents.fromId(webContentsId);
window.setTitle(Meta.getTitleForService(service, serviceWebContents.getTitle()));
window.setTitle(Meta.getTitleForService(service, viewTitle));
}
});
// Open service settings window
ipcMain.on('openServiceSettings', (e, serviceId) => {
if (!this.serviceSettingsWindow) {
console.log('Opening service settings', serviceId);
this.serviceSettingsWindow = new ServiceSettingsWindow(this.application, this, serviceId);
this.serviceSettingsWindow.setup();
this.serviceSettingsWindow.onClose(() => {
this.serviceSettingsWindow = undefined;
});
}
});
@ -103,409 +126,23 @@ export default class MainWindow extends Window {
}
});
// Context menus
ipcMain.on('open-service-navigation-context-menu', (
event,
serviceId: number,
ready: boolean,
notReady: boolean,
canResetZoom: boolean,
) => {
this.openServiceNavigationContextMenu(serviceId, ready, notReady, canResetZoom);
});
ipcMain.on('open-service-content-context-menu', (
event,
webContentsId: number,
) => {
this.openServiceContentContextMenu(webContentsId);
});
// User agent
ipcMain.on('set-web-contents-user-agent', (event, webContentsId: number, userAgent: string) => {
webContents.fromId(webContentsId).setUserAgent(userAgent);
});
// Permission management
ipcMain.on('set-partition-permissions', (
event,
serviceId: number,
partition: string,
) => {
this.setPartitionPermissions(serviceId, partition);
});
// Navigation
ipcMain.on('go-forward', (event, webContentsId: number) => {
webContents.fromId(webContentsId).goForward();
});
ipcMain.on('go-back', (event, webContentsId: number) => {
webContents.fromId(webContentsId).goBack();
});
// Create new service
ipcMain.on('create-new-service', () => {
this.openServiceSettings(null);
});
window.on('enter-full-screen', () => {
window.webContents.send('fullscreenchange', true);
});
window.on('leave-full-screen', () => {
window.webContents.send('fullscreenchange', false);
});
// Load navigation view
window.loadFile(path.resolve(Meta.RESOURCES_PATH, 'index.html'))
.catch(console.error);
}
public syncData(): void {
public syncData() {
this.getWindow().webContents.send('data',
Meta.title,
Meta.ICON_SETS,
this.activeServiceId,
<SpecialPages>{
empty: path.resolve(Meta.RESOURCES_PATH, 'empty.html'),
connectionError: path.resolve(Meta.RESOURCES_PATH, 'connection_error.html'),
fileNotFound: path.resolve(Meta.RESOURCES_PATH, 'file_not_found_error.html'),
},
this.config,
this.activeService,
path.resolve(Meta.RESOURCES_PATH, 'empty.html'),
this.config
);
}
private setActiveService(index: number) {
console.log('Set active service', index);
this.activeServiceId = index;
}
private openServiceSettings(serviceId: number | null): void {
console.log('o', serviceId, !!this.serviceSettingsWindow);
if (!this.serviceSettingsWindow) {
console.log('Opening service settings', serviceId);
this.serviceSettingsWindow = new ServiceSettingsWindow(this.application, this, serviceId);
this.serviceSettingsWindow.setup();
this.serviceSettingsWindow.onClose(() => {
this.serviceSettingsWindow = undefined;
});
}
}
private deleteService(serviceId: number): void {
console.log('Deleting service', serviceId);
this.config.services.splice(serviceId, 1);
this.config.save();
this.getWindow().webContents.send('deleteService', serviceId);
}
private openServiceNavigationContextMenu(
serviceId: number,
ready: boolean,
notReady: boolean,
canResetZoom: boolean,
): void {
const ipc = this.getWindow().webContents;
const permissions = this.config.services[serviceId].permissions;
const menu = new Menu();
menu.append(new MenuItem({
label: 'Home', click: () => {
ipc.send('load-service-home', serviceId);
},
enabled: ready,
}));
menu.append(new MenuItem({
label: ready ? 'Reload' : 'Load', click: () => {
ipc.send('reload-service', serviceId);
},
enabled: ready || notReady,
}));
menu.append(new MenuItem({
label: 'Close', click: () => {
ipc.send('unload-service', serviceId);
},
enabled: ready,
}));
menu.append(new MenuItem({type: "separator"}));
menu.append(new MenuItem({
label: 'Reset zoom level', click: () => {
ipc.send('reset-service-zoom-level', serviceId);
},
enabled: ready && canResetZoom,
}));
menu.append(new MenuItem({
label: 'Zoom in', click: () => {
ipc.send('zoom-in-service', serviceId);
},
enabled: ready,
}));
menu.append(new MenuItem({
label: 'Zoom out', click: () => {
ipc.send('zoom-out-service', serviceId);
},
enabled: ready,
}));
menu.append(new MenuItem({type: "separator"}));
const permissionsMenu = [];
if (ready) {
for (const domain of Object.keys(permissions)) {
const domainPermissionsMenu = [];
const domainPermissions = permissions[domain];
if (domainPermissions) {
for (const permission of domainPermissions) {
domainPermissionsMenu.push({
label: (permission.authorized ? '✓' : '❌') + ' ' + permission.name,
submenu: [{
label: 'Toggle',
click: () => {
permission.authorized = !permission.authorized;
this.config.save();
},
}, {
label: 'Forget',
click: () => {
permissions[domain] = domainPermissions.filter(p => p !== permission);
},
}],
});
}
}
if (domainPermissionsMenu.length > 0) {
permissionsMenu.push({
label: domain,
submenu: domainPermissionsMenu,
});
}
}
}
menu.append(new MenuItem({
label: 'Permissions',
enabled: ready,
submenu: permissionsMenu,
}));
menu.append(new MenuItem({type: "separator"}));
menu.append(new MenuItem({
label: 'Edit', click: () => {
this.openServiceSettings(serviceId);
},
}));
menu.append(new MenuItem({
label: 'Delete', click: () => {
dialog.showMessageBox(this.getWindow(), {
type: 'question',
title: 'Confirm',
message: 'Are you sure you want to delete this service?',
buttons: ['Cancel', 'Confirm'],
cancelId: 0,
}).then(result => {
if (result.response === 1) {
this.deleteService(serviceId);
}
}).catch(console.error);
},
}));
menu.popup({window: this.getWindow()});
}
private openServiceContentContextMenu(
webContentsId: number,
): void {
const serviceWebContents = webContents.fromId(webContentsId);
serviceWebContents.on('context-menu', (event, props: ContextMenuParams) => {
const menu = new Menu();
const {editFlags} = props;
// linkURL
if (props.linkURL.length > 0) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Copy link URL',
click: () => {
clipboard.writeText(props.linkURL);
},
}));
menu.append(new MenuItem({
label: 'Open URL in default browser',
click: () => {
this.application.openExternalLink(props.linkURL)
.catch(console.error);
},
}));
}
// Image
if (props.hasImageContents) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Copy image',
click: () => {
serviceWebContents.copyImageAt(props.x, props.y);
},
}));
menu.append(new MenuItem({
label: 'Save image as',
click: () => {
serviceWebContents.downloadURL(props.srcURL);
},
}));
}
// Text clipboard
if (editFlags.canUndo || editFlags.canRedo || editFlags.canCut || editFlags.canCopy || editFlags.canPaste ||
editFlags.canDelete) {
if (editFlags.canUndo || editFlags.canRedo) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
if (editFlags.canUndo) {
menu.append(new MenuItem({
label: 'Undo',
role: 'undo',
}));
}
if (editFlags.canRedo) {
menu.append(new MenuItem({
label: 'Redo',
role: 'redo',
}));
}
}
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Cut',
role: 'cut',
enabled: editFlags.canCut,
}));
menu.append(new MenuItem({
label: 'Copy',
role: 'copy',
enabled: editFlags.canCopy,
}));
menu.append(new MenuItem({
label: 'Paste',
role: 'paste',
enabled: editFlags.canPaste,
}));
menu.append(new MenuItem({
label: 'Delete',
role: 'delete',
enabled: editFlags.canDelete,
}));
}
if (editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Select all',
role: 'selectAll',
}));
}
// Inspect element
if (menu.items.length > 0) {
menu.append(new MenuItem({type: 'separator'}));
}
menu.append(new MenuItem({
label: 'Inspect element',
click: () => {
serviceWebContents.inspectElement(props.x, props.y);
},
}));
menu.popup({
window: this.getWindow(),
});
});
}
private setPartitionPermissions(
serviceId: number,
partition: string,
): void {
const service = this.config.services[serviceId];
function getUrlDomain(url: string | undefined) {
if (!url) return '';
const matches = url.match(/^https?:\/\/((.+?)\/|(.+))/i);
if (matches !== null) {
let domain = matches[1];
if (domain.endsWith('/')) domain = domain.substr(0, domain.length - 1);
return domain;
}
return '';
}
function getDomainPermissions(domain: string) {
let domainPermissions = service.permissions[domain];
if (!domainPermissions) domainPermissions = service.permissions[domain] = [];
return domainPermissions;
}
const serviceSession = session.fromPartition(partition);
serviceSession.setPermissionRequestHandler((webContents, permissionName, callback, details) => {
const domain = getUrlDomain(details.requestingUrl);
const domainPermissions = getDomainPermissions(domain);
const existingPermissions = domainPermissions.filter(p => p.name === permissionName);
if (existingPermissions.length > 0) {
callback(existingPermissions[0].authorized);
return;
}
dialog.showMessageBox(this.getWindow(), {
type: 'question',
title: 'Grant ' + permissionName + ' permission',
message: 'Do you wish to grant the ' + permissionName + ' permission to ' + domain + '?',
buttons: ['Deny', 'Authorize'],
cancelId: 0,
}).then(result => {
const authorized = result.response === 1;
domainPermissions.push({
name: permissionName,
authorized: authorized,
});
this.config.save();
console.log(authorized ? 'Granted' : 'Denied', permissionName, 'for domain', domain);
callback(authorized);
}).catch(console.error);
});
serviceSession.setPermissionCheckHandler((webContents1, permissionName, requestingOrigin, details) => {
console.log('Permission check', permissionName, requestingOrigin, details);
const domain = getUrlDomain(details.requestingUrl);
const domainPermissions = getDomainPermissions(domain);
const existingPermissions = domainPermissions.filter(p => p.name === permissionName);
return existingPermissions.length > 0 && existingPermissions[0].authorized;
});
this.activeService = index;
}
}

View File

@ -5,19 +5,19 @@ import Meta from "../Meta";
import Service from "../Service";
export default class ServiceSettingsWindow extends Window {
private readonly serviceId: number | null;
private readonly serviceId: number;
public constructor(application: Application, parent: Window, serviceId: number | null) {
constructor(application: Application, parent: Window, serviceId: number) {
super(application, parent);
this.serviceId = serviceId;
}
public setup(): void {
public setup() {
super.setup({
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
webviewTag: true,
contextIsolation: false,
},
modal: true,
autoHideMenuBar: true,
@ -28,20 +28,16 @@ export default class ServiceSettingsWindow extends Window {
if (this.application.isDevMode()) {
window.webContents.openDevTools({
mode: 'right',
mode: 'right'
});
}
this.onIpc('sync-settings', () => {
window.webContents.send('syncIcons', Meta.ICON_SETS);
window.webContents.send('loadService',
this.serviceId, typeof this.serviceId === 'number' ?
this.config.services[this.serviceId] :
undefined,
);
window.webContents.send('loadService', this.serviceId, this.config.services[this.serviceId]);
});
this.onIpc('saveService', (e, id: number | null, data: Service) => {
this.onIpc('saveService', (e, id, data) => {
console.log('Saving service', id, data);
const newService = new Service(data);
if (typeof id === 'number') {
@ -49,8 +45,6 @@ export default class ServiceSettingsWindow extends Window {
} else {
this.config.services.push(newService);
id = this.config.services.indexOf(newService);
if (id < 0) id = null;
}
this.config.save();

View File

@ -3,15 +3,14 @@ import path from "path";
import Window from "../Window";
import MainWindow from "./MainWindow";
import Meta from "../Meta";
import Config from "../Config";
export default class SettingsWindow extends Window {
public setup(): void {
public setup() {
super.setup({
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
webviewTag: true,
contextIsolation: false,
},
modal: true,
autoHideMenuBar: true,
@ -22,7 +21,7 @@ export default class SettingsWindow extends Window {
if (this.application.isDevMode()) {
window.webContents.openDevTools({
mode: 'right',
mode: 'right'
});
}
@ -37,7 +36,7 @@ export default class SettingsWindow extends Window {
}).catch(console.error);
});
this.onIpc('save-config', (e: Event, data: Config) => {
this.onIpc('save-config', (e: Event, data: any) => {
this.config.update(data);
this.config.save();
if (this.parent instanceof MainWindow) {

View File

@ -1,7 +1,5 @@
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin");
const {extendDefaultPlugins} = require("svgo");
const CopyWebpackPlugin = require('copy-webpack-plugin');
const dev = process.env.NODE_ENV === 'development';
@ -39,6 +37,9 @@ const config = {
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '/',
}
},
'css-loader',
'sass-loader',
@ -46,17 +47,25 @@ const config = {
},
{
test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource',
generator: {
filename: '../fonts/[name][ext]',
},
use: 'file-loader?name=../fonts/[name].[ext]',
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
type: 'asset/resource',
generator: {
filename: '../images/[name][ext]',
},
use: [
'file-loader?name=../images/[name].[ext]',
{
loader: 'img-loader',
options: {
enabled: !dev,
plugins: [
require('imagemin-gifsicle')({}),
require('imagemin-mozjpeg')({}),
require('imagemin-pngquant')({}),
require('imagemin-svgo')({}),
]
}
}
]
},
{
test: /\.ts$/i,
@ -66,14 +75,13 @@ const config = {
configFile: 'tsconfig.frontend.json',
}
},
exclude: '/node_modules/',
exclude: '/node_modules/'
},
{
test: /\.html$/i,
type: 'asset/resource',
generator: {
filename: '../[name][ext]',
},
use: [
'file-loader?name=../[name].[ext]',
]
}
],
},
@ -86,56 +94,6 @@ const config = {
{from: 'node_modules/@fortawesome/fontawesome-free/svgs', to: '../images/icons'}
]
}),
new ImageMinimizerPlugin({
minimizer: {
implementation: ImageMinimizerPlugin.imageminMinify,
options: {
// Lossless optimization with custom option
// Feel free to experiment with options for better result for you
plugins: [
["gifsicle", {}],
["mozjpeg", {}],
["pngquant", {}],
// Svgo configuration here https://github.com/svg/svgo#configuration
[
"svgo",
{
plugins: extendDefaultPlugins([
{
name: "removeViewBox",
active: false,
},
{
name: "addAttributesToSVGElement",
params: {
attributes: [{ xmlns: "http://www.w3.org/2000/svg" }],
},
},
]),
},
// todo: still not fixed
// {
// plugins: {
// name: 'preset-default',
// params: {
// overrides: {
// removeViewBox: {
// active: false,
// },
// addAttributesToSVGElement: {
// params: {
// attributes: [{xmlns: "http://www.w3.org/2000/svg"}],
// },
// },
// },
// },
// },
// },
],
],
},
},
}),
]
};

7877
yarn.lock

File diff suppressed because it is too large Load Diff