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, "env": {
"parser": "@typescript-eslint/parser", "browser": true,
"plugins": [ "commonjs": true,
"@typescript-eslint" "es6": true,
], "node": true
"parserOptions": { },
"project": [ "extends": "eslint:recommended",
"./tsconfig.backend.json", "globals": {
"./tsconfig.frontend.json" "Atomics": "readonly",
] "SharedArrayBuffer": "readonly"
}, },
"extends": [ "parserOptions": {
"eslint:recommended", "ecmaVersion": 2018
"plugin:@typescript-eslint/recommended" },
], "rules": {
"rules": { "indent": [
"indent": [ "error",
"error", "tab"
4, ],
{ "linebreak-style": [
"SwitchCase": 1 "error",
} "unix"
], ],
"no-trailing-spaces": "error", "quotes": [
"max-len": [ "error",
"error", "single"
{ ],
"code": 120, "semi": [
"ignoreStrings": true, "error",
"ignoreTemplateLiterals": true, "always"
"ignoreRegExpLiterals": true ]
} }
], }
"semi": "off",
"@typescript-eslint/semi": [
"error"
],
"no-extra-semi": "error",
"eol-last": "error",
"comma-dangle": "off",
"@typescript-eslint/comma-dangle": [
"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"
}
],
"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": [
"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 # 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. 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. 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 - 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) - 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 ## Contributing
Pull requests are welcome and appreciated! Pull requests are welcome and appreciated!
## License ## License
[GPLv3 - see LICENSE file](LICENSE) [MIT - see LICENSE file](LICENSE)

View File

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

View File

@ -3,7 +3,6 @@
"files": "ts/files.ts", "files": "ts/files.ts",
"layout": "sass/layout.scss", "layout": "sass/layout.scss",
"index": "sass/index.scss", "index": "sass/index.scss",
"error": "sass/error.scss",
"service-settings": "sass/service-settings.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"> <meta charset="UTF-8">
<title>Tabs</title> <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/layout.css">
<link rel="stylesheet" href="css/index.css"> <link rel="stylesheet" href="css/index.css">
@ -47,7 +50,7 @@
<div id="services"> <div id="services">
<div class="loader"></div> <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 id="empty-message">Load a service using the menu on the left.</div>
</div> </div>
</body> </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,356 +1,332 @@
:root {
--nav-width: 48px;
}
body { body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
}
#service-buttons {
flex-grow: 1;
overflow: hidden auto;
}
*:focus {
outline-color: rgb(118, 93, 176);
} }
#navigation { #navigation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: var(--nav-width); width: 48px;
}
body.fullscreen & { #navigation > :not(#service-buttons) {
display: none; flex-shrink: 0;
} }
> :not(#service-buttons) { #service-buttons {
flex-shrink: 0; flex-grow: 1;
}
button { overflow: hidden auto;
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 { #service-selector {
display: block; display: block;
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
}
&::-webkit-scrollbar { #service-selector::-webkit-scrollbar {
width: 6px; width: 6px;
} }
li { #service-selector [draggable] {
position: relative; user-select: none;
background-color: rgb(43, 43, 43);
}
button { #service-selector [draggable] img {
border-radius: 0; -webkit-user-drag: none;
} user-drag: none;
&.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] {
user-select: none;
background-color: rgb(43, 43, 43);
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-selector .drag-target button::after,
#service-last-drag-position.drag-target { #service-last-drag-position.drag-target {
content: ""; content: "";
height: 4px; height: 4px;
transform: translateY(-50%); transform: translateY(-50%);
border: 0; border: 0;
box-sizing: border-box; box-sizing: border-box;
background: #fff9; 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 { #history {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 2px; padding: 2px;
} }
#history button, #history button,
#history .status { #history .status {
display: inline; display: inline;
width: calc(var(--nav-width) / 2); width: 24px;
height: calc(var(--nav-width) / 2); height: 24px;
margin: 2px; margin: 2px;
padding: initial; padding: initial;
font-size: calc(var(--nav-width) / 4); font-size: 12px;
background: #fff1; background: #fff1;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
} }
#history button i { #history button i {
font-size: inherit; font-size: inherit;
} }
#history button.disabled { #history button.disabled {
color: #888; color: #888;
border: transparent; border: transparent;
background: transparent; background: transparent;
cursor: initial; cursor: initial;
} }
#history button:focus, #history button:focus,
#history button:hover { #history button:hover {
outline: none; outline: none;
border-color: #fff9; border-color: #fff9;
} }
#history button:hover:not(.disabled) { #history button:hover:not(.disabled) {
outline: none; outline: none;
background-color: #fff2; background-color: #fff2;
} }
#history button:active:not(.disabled) { #history button:active:not(.disabled) {
background-color: #fff4; background-color: #fff4;
} }
#history #status { #history #status {
background: transparent; background: transparent;
} }
#history #status .unknown, #history #status .unknown,
#history #status .file { #history #status .file {
color: #888; color: #888;
} }
#history #status .https { #history #status .https {
color: #009800; color: #009800;
} }
#history #status .http { #history #status .http {
color: #e20000; color: #e20000;
} }
#history #status > :not(.active) { #history #status > :not(.active) {
display: none; display: none;
} }
#history #status > * .tip { #history #status > * .tip {
position: absolute; position: absolute;
z-index: 30; z-index: 1;
left: 100%; left: 100%;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: none; display: none;
margin-left: 8px; margin-left: 8px;
background-color: #000; background-color: #000;
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
white-space: nowrap; white-space: nowrap;
color: #fff; color: #fff;
text-transform: none; text-transform: none;
font-family: sans-serif; font-family: sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 100; font-weight: 100;
} }
#history #status > * .tip::before { #history #status > * .tip::before {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
top: 50%; top: 50%;
transform: translate(-100%, -50%); transform: translate(-100%, -50%);
display: inline-block; display: inline-block;
width: 0; width: 0;
height: 0; height: 0;
border: 0 solid transparent; border: 0 solid transparent;
border-top-width: 4px; border-top-width: 4px;
border-bottom-width: 4px; border-bottom-width: 4px;
border-right: 7px solid black; border-right: 7px solid black;
} }
#history #status:focus > * .tip { #history #status:focus > * .tip {
display: block; display: block;
} }
#navigation #add-button:not(:hover) { #navigation #add-button:not(:hover) {
opacity: 0.75; opacity: 0.75;
} }
#services { #services {
position: relative; position: relative;
flex-grow: 1; flex-grow: 1;
} }
#services > *:not(.loader):not(#url-preview) { #services > *:not(.loader):not(#url-preview) {
height: 100%; height: 100%;
} }
#services > :not(.active):not(.loader):not(#url-preview):not(webview) { #services > :not(.active):not(.loader):not(#url-preview) {
display: none; 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 { #services > .loader {
width: 64px; width: 64px;
height: 64px; height: 64px;
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: -1; z-index: -1;
} }
#url-preview { #url-preview {
position: absolute; position: absolute;
z-index: 10000; bottom: 0;
bottom: 0; left: 0;
left: 0; display: block;
display: block; padding: 2px 5px 1px 5px;
padding: 2px 5px 1px 5px;
max-width: 48%; max-width: 48%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-family: monospace; font-family: monospace;
border-width: 1px 1px 0 0; border-width: 1px 1px 0 0;
border-color: #232323; border-color: #232323;
border-style: solid; border-style: solid;
border-radius: 0 5px 0 0; border-radius: 0 5px 0 0;
background: #2b2b2b; background: #2b2b2b;
opacity: 1; opacity: 1;
transition: 100ms ease opacity; transition: 100ms ease opacity;
transition-delay: 200ms; transition-delay: 200ms;
} }
#url-preview.right { #url-preview.right {
left: auto; left: auto;
right: 0; right: 0;
border-width: 1px 0 0 1px; border-width: 1px 0 0 1px;
border-radius: 5px 0 0 0; border-radius: 5px 0 0 0;
} }
#url-preview.invisible { #url-preview.hidden {
opacity: 0; opacity: 0;
} }
#services > #empty-message { #services > #empty-message {
display: flex !important; display: flex !important;
position: absolute; position: absolute;
z-index: -1; z-index: -1;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
#services > .loader:not(.hidden) ~ #empty-message { #services > .loader:not(.hidden) ~ #empty-message {
display: none !important; display: none !important;
} }

View File

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

View File

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

View File

@ -4,7 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Service settings</title> <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/layout.css">
<link rel="stylesheet" href="css/service-settings.css"> <link rel="stylesheet" href="css/service-settings.css">
@ -13,7 +16,7 @@
</head> </head>
<body> <body>
<form> <form action="javascript: save();">
<div class="form-header"> <div class="form-header">
<h1>Loading...</h1> <h1>Loading...</h1>
</div> </div>
@ -39,18 +42,11 @@
<textarea name="customCSS" id="custom-css" rows="3"></textarea> <textarea name="customCSS" id="custom-css" rows="3"></textarea>
</div> </div>
<fieldset> <div class="form-group">
<legend>Disguise</legend> <label for="custom-user-agent">Custom UserAgent (i.e. google services)</label>
<div class="form-group"> <input type="text" name="customUserAgent" id="custom-user-agent">
<label for="custom-user-agent">Custom UserAgent (i.e. google services)</label> <button type="button" id="userAgentAutoFill">Auto-fill</button>
<input type="text" name="customUserAgent" id="custom-user-agent"> </div>
</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 id="icon-choice">
<div class="form-group-header"> <div class="form-group-header">
@ -82,7 +78,7 @@
<div class="form-footer"> <div class="form-footer">
<div class="form-group" id="buttons"> <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> <button type="submit">Save</button>
</div> </div>
</div> </div>

View File

@ -5,7 +5,10 @@
<title>Service settings</title> <title>Service settings</title>
<meta http-equiv="Content-Security-Policy" <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/layout.css">
<link rel="stylesheet" href="css/service-settings.css"> <link rel="stylesheet" href="css/service-settings.css">
@ -14,7 +17,7 @@
</head> </head>
<body> <body>
<form> <form action="javascript: save();">
<div class="form-header"> <div class="form-header">
<h1>Settings</h1> <h1>Settings</h1>
</div> </div>
@ -26,32 +29,18 @@
<button id="download-button" type="button" class="hidden">Download</button> <button id="download-button" type="button" class="hidden">Download</button>
</div> </div>
<section> <h2 class="form-header">History navigation buttons</h2>
<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> <label class="form-group">
</section> <input type="checkbox" name="security-button" id="security-button"> Security info (recommended)</label>
<label class="form-group">
<section> <input type="checkbox" name="home-button" id="home-button"> Home button</label>
<h2 class="form-header">Appearance</h2> <label class="form-group">
<input type="checkbox" name="back-button" id="back-button"> Go back button</label>
<label class="form-group"><input type="checkbox" name="big-nav-bar" id="big-nav-bar"> Increase the size of the navigation bar</label> <label class="form-group">
</section> <input type="checkbox" name="forward-button" id="forward-button"> Go forward button</label>
<label class="form-group">
<section> <input type="checkbox" name="refresh-button" id="refresh-button"> Refresh page button</label>
<h2 class="form-header">History navigation buttons</h2>
<label class="form-group">
<input type="checkbox" name="security-button" id="security-button"> Security info (recommended)</label>
<label class="form-group">
<input type="checkbox" name="home-button" id="home-button"> Home button</label>
<label class="form-group">
<input type="checkbox" name="back-button" id="back-button"> Go back button</label>
<label class="form-group">
<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>
<div class="form-footer"> <div class="form-footer">

View File

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

View File

@ -1,50 +1,143 @@
import {DidFailLoadEvent, ipcRenderer, PageFaviconUpdatedEvent, UpdateTargetUrlEvent} from "electron"; import {
import Service from "../../src/Service"; clipboard,
import {IconProperties, IconSet, SpecialPages} from "../../src/Meta"; ipcRenderer,
import Config from "../../src/Config"; PageFaviconUpdatedEvent,
remote,
shell,
UpdateTargetUrlEvent,
WebContents
} from "electron";
const appInfo: { const {
title?: string; Menu,
} = {}; MenuItem,
let icons: IconProperties[] = []; dialog,
session,
} = remote;
let services: (FrontService | undefined)[] = []; const appInfo: any = {};
let selectedServiceId: number | null = null; let icons: any[] = [];
let securityButton: HTMLElement | null = null,
homeButton: HTMLElement | null = null, let services: any[] = [];
forwardButton: HTMLElement | null = null, let selectedService: any = null;
backButton: HTMLElement | null = null, let securityButton: HTMLElement | null,
refreshButton: HTMLElement | null = null; homeButton: HTMLElement | null,
forwardButton: HTMLElement | null,
backButton: HTMLElement | null,
refreshButton: HTMLElement | null;
let addButton, settingsButton; let addButton, settingsButton;
let specialPages: SpecialPages | null = null; let emptyPage: string;
let urlPreview: HTMLElement | null = null; let urlPreview: HTMLElement | null;
let serviceSelector: HTMLElement | null = null; let serviceSelector: HTMLElement | null;
// Service reordering // Service reordering
let lastDragPosition: HTMLElement | null = null; let lastDragPosition: HTMLElement | null, oldActiveService: number;
let oldActiveService: number | null = null;
// 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', ( ipcRenderer.on('data', (event, appData, iconSets, actualSelectedService, emptyUrl, config) => {
event,
appTitle: string,
iconSets: IconSet[],
activeServiceId: number,
_specialPages: SpecialPages,
config: Config,
) => {
// App info // App info
appInfo.title = appTitle; appInfo.title = appData.title;
// Icons // Icons
icons = []; icons = [];
for (const set of iconSets) { for (const set of iconSets) {
icons.push(...set); icons = icons.concat(set);
} }
// Special pages
specialPages = _specialPages;
console.log('Updating services ...'); console.log('Updating services ...');
services = config.services; services = config.services;
@ -61,7 +154,7 @@ ipcRenderer.on('data', (
} }
for (let i = 0; i < services.length; i++) { for (let i = 0; i < services.length; i++) {
createServiceNavigationElement(i); createService(i);
} }
// Init drag last position // Init drag last position
@ -71,7 +164,7 @@ ipcRenderer.on('data', (
const index = services.length; const index = services.length;
if (draggedId !== index && draggedId !== index - 1) { if (draggedId !== index && draggedId !== index - 1) {
resetDrag(); resetDrag();
lastDragTarget = index; lastDragTarget = dragTargetId = index;
lastDragPosition?.classList.remove('hidden'); lastDragPosition?.classList.remove('hidden');
lastDragPosition?.classList.add('drag-target'); lastDragPosition?.classList.add('drag-target');
} }
@ -79,20 +172,22 @@ ipcRenderer.on('data', (
} }
// Set active service // Set active service
if (activeServiceId < 0 || activeServiceId >= services.length) { if (actualSelectedService < 0 || actualSelectedService >= services.length) {
activeServiceId = 0; actualSelectedService = 0;
} }
setActiveService(activeServiceId); setActiveService(actualSelectedService);
// Empty
emptyPage = emptyUrl;
// Url preview element // Url preview element
urlPreview = document.getElementById("url-preview"); urlPreview = document.getElementById("url-preview");
if (urlPreview) { if (urlPreview) {
const _urlPreview = urlPreview;
urlPreview.addEventListener('mouseover', () => { urlPreview.addEventListener('mouseover', () => {
if (_urlPreview.classList.contains('right')) { if (urlPreview!.classList.contains('right')) {
_urlPreview.classList.remove('right'); urlPreview!.classList.remove('right');
} else { } else {
_urlPreview.classList.add('right'); urlPreview!.classList.add('right');
} }
}); });
} }
@ -112,91 +207,44 @@ ipcRenderer.on('data', (
// Other elements // Other elements
serviceSelector = document.getElementById('service-selector'); 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) => { function removeServiceFeatures(id: number): HTMLElement | null {
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 {
// Remove nav // Remove nav
const nav = document.querySelector('#service-selector'); const nav = document.querySelector('#service-selector');
let oldNavButton: HTMLElement | null = null; let oldNavButton: HTMLElement | null = null;
let nextSibling: Element | null = null;
if (nav) { if (nav) {
oldNavButton = nav.querySelector('li:nth-of-type(' + (id + 1) + ')'); oldNavButton = nav.querySelector('li:nth-of-type(' + (id + 1) + ')');
if (oldNavButton) { if (oldNavButton) {
nextSibling = oldNavButton.nextElementSibling;
nav.removeChild(oldNavButton); nav.removeChild(oldNavButton);
} }
} }
// Remove webview // Remove webview
const service = services[id]; if (services[id] && services[id].view) {
if (service) { document.querySelector('#services')?.removeChild(services[id].view);
const view = service.view;
if (view) document.querySelector('#services')?.removeChild(view);
} }
return nextSibling; return oldNavButton;
} }
ipcRenderer.on('updateService', (e, id: number | null, data: Service) => { ipcRenderer.on('updateService', (e, id, data) => {
if (id === null) { if (id === null) {
console.log('Adding new service');
services.push(data); services.push(data);
createServiceNavigationElement(services.length - 1); createService(services.length - 1);
} else { } else {
console.log('Updating existing service', id); const oldNavButton = removeServiceFeatures(id);
const nextSibling = removeServiceFeatures(id);
// Create new service // Create new service
services[id] = data; services[id] = data;
createServiceNavigationElement(id, nextSibling); createService(id, oldNavButton ? oldNavButton.nextElementSibling : null);
if (selectedServiceId === id) { if (parseInt(selectedService) === id) {
setActiveService(id); setActiveService(id);
} }
} }
}); });
ipcRenderer.on('reorderService', (e, serviceId: number, targetId: number) => { ipcRenderer.on('reorderService', (e, serviceId, targetId) => {
const oldServices = services; const oldServices = services;
services = []; services = [];
@ -220,69 +268,48 @@ ipcRenderer.on('reorderService', (e, serviceId: number, targetId: number) => {
serviceSelector.innerHTML = ''; serviceSelector.innerHTML = '';
} }
for (let i = 0; i < services.length; i++) { for (let i = 0; i < services.length; i++) {
const service = services[i]; services[i].li = undefined;
if (service) service.li = undefined; createService(i);
createServiceNavigationElement(i);
} }
setActiveService(newId); setActiveService(newId);
}); });
ipcRenderer.on('deleteService', (e, id: number) => { ipcRenderer.on('deleteService', (e, id) => {
removeServiceFeatures(id); removeServiceFeatures(id);
if (selectedServiceId === id) { if (parseInt(selectedService) === id) {
setActiveService(0); setActiveService(0);
} }
services.splice(id, 1); delete services[id];
services = services.filter(s => s !== null);
}); });
function createServiceNavigationElement(index: number, nextNavButton?: Element | null) { function createService(index: number, nextNavButton?: Element | null) {
const service = services[index]; let service = services[index];
if (!service) throw new Error('Service doesn\'t exist.'); let li = <any>document.createElement('li');
const li = document.createElement('li') as NavigationElement;
service.li = li; service.li = li;
const button = document.createElement('button'); let button = document.createElement('button');
button.dataset.serviceId = '' + index; button.dataset.serviceId = '' + index;
button.dataset.tooltip = service.name; button.dataset.tooltip = service.name;
button.addEventListener('click', () => { button.addEventListener('click', () => {
const rawId = button.dataset.serviceId; if (button.dataset.serviceId) {
if (rawId) { setActiveService(parseInt(button.dataset.serviceId));
const id = parseInt(rawId);
setActiveService(id);
ipcRenderer.send('setActiveService', id);
} }
ipcRenderer.send('setActiveService', button.dataset.serviceId);
}); });
button.addEventListener('contextmenu', e => { button.addEventListener('contextmenu', e => openServiceContextMenu(e, index));
e.preventDefault();
const service = services[index]; let icon: any;
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;
if (service.useFavicon && service.favicon != null) { if (service.useFavicon && service.favicon != null) {
icon = document.createElement('img'); icon = document.createElement('img');
if (icon instanceof HTMLImageElement) { icon.src = service.favicon;
icon.src = service.favicon; icon.alt = service.name;
icon.alt = service.name; } else if (service.isImage) {
}
} else if (service.isImage && service.icon) {
icon = document.createElement('img'); icon = document.createElement('img');
if (icon instanceof HTMLImageElement) { icon.src = service.icon;
icon.src = service.icon; icon.alt = service.name;
icon.alt = service.name;
}
} else { } else {
icon = document.createElement('i'); icon = document.createElement('i');
let iconProperties = icons.find(i => `${i.set}/${i.name}` === service.icon); 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) => { iconProperties.faIcon.split(' ').forEach((cl: string) => {
icon.classList.add(cl); 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 draggedId: number;
let lastDragTarget = -1; 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.serviceId = index;
li.draggable = true; li.draggable = true;
li.addEventListener('dragstart', (event: DragEvent) => { li.addEventListener('dragstart', (event: DragEvent) => {
@ -334,7 +361,7 @@ function initDrag(index: number, li: NavigationElement) {
}); });
li.addEventListener('dragover', (e: DragEvent) => { li.addEventListener('dragover', (e: DragEvent) => {
let realIndex = index; let realIndex = index;
const rect = li.getBoundingClientRect(); let rect = li.getBoundingClientRect();
if ((e.clientY - rect.y) / rect.height >= 0.5) { if ((e.clientY - rect.y) / rect.height >= 0.5) {
realIndex++; realIndex++;
@ -345,18 +372,12 @@ function initDrag(index: number, li: NavigationElement) {
} }
resetDrag(); resetDrag();
const el = realIndex === services.length ? let el = realIndex === services.length ? lastDragPosition : services[realIndex].li;
lastDragPosition : lastDragTarget = dragTargetId = realIndex;
services[realIndex]?.li;
lastDragTarget = realIndex;
lastDragPosition?.classList.remove('hidden'); lastDragPosition?.classList.remove('hidden');
el.classList.add('drag-target');
if (el) { if (draggedId === realIndex || draggedId === realIndex - 1) el.classList.add('drag-target-self');
el.classList.add('drag-target');
if (draggedId === realIndex || draggedId === realIndex - 1)
el.classList.add('drag-target-self');
}
}); });
li.addEventListener('dragend', () => { li.addEventListener('dragend', () => {
reorderService(draggedId, lastDragTarget); reorderService(draggedId, lastDragTarget);
@ -366,6 +387,8 @@ function initDrag(index: number, li: NavigationElement) {
function resetDrag() { function resetDrag() {
lastDragTarget = -1; lastDragTarget = -1;
dragTargetId = -1;
dragTargetCount = 0;
serviceSelector?.querySelectorAll('li').forEach(li => { serviceSelector?.querySelectorAll('li').forEach(li => {
li.classList.remove('drag-target'); li.classList.remove('drag-target');
li.classList.remove('drag-target-self'); li.classList.remove('drag-target-self');
@ -378,7 +401,7 @@ function resetDrag() {
function reorderService(serviceId: number, targetId: number) { function reorderService(serviceId: number, targetId: number) {
console.log('Reordering service', serviceId, targetId); console.log('Reordering service', serviceId, targetId);
if (targetId >= 0) { if (targetId >= 0) {
oldActiveService = selectedServiceId; oldActiveService = selectedService;
setActiveService(-1); setActiveService(-1);
ipcRenderer.send('reorderService', serviceId, targetId); ipcRenderer.send('reorderService', serviceId, targetId);
} }
@ -400,7 +423,7 @@ document.addEventListener('DOMContentLoaded', () => {
refreshButton?.addEventListener('click', () => reload()); refreshButton?.addEventListener('click', () => reload());
addButton = document.getElementById('add-button'); 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 = document.getElementById('settings-button');
settingsButton?.addEventListener('click', () => ipcRenderer.send('openSettings', null)); settingsButton?.addEventListener('click', () => ipcRenderer.send('openSettings', null));
@ -414,91 +437,135 @@ function setActiveService(serviceId: number) {
} }
// Hide previous service // Hide previous service
if (typeof selectedServiceId === 'number') { if (services[selectedService] && services[selectedService].view) {
const selectedService = services[selectedServiceId]; services[selectedService].view.classList.remove('active');
if (selectedService && selectedService.view) {
selectedService.view.classList.remove('active');
}
} }
// Show service // Show service
currentService?.view?.classList.add('active'); if (currentService) {
currentService.view.classList.add('active');
}
// Save active service ID // Save active service ID
selectedServiceId = serviceId; selectedService = serviceId;
// Refresh navigation // Refresh navigation
updateNavigation(); updateNavigation();
}); });
} }
function loadService(serviceId: number, service: FrontService) { function loadService(serviceId: number, service: any) {
// Load service if not loaded yet // Load service if not loaded yet
if (!service.view && !service.viewReady) { if (!service.view && !service.viewReady) {
console.log('Loading service', serviceId); console.log('Loading service', serviceId);
document.querySelector('#services > .loader')?.classList.remove('hidden'); document.querySelector('#services > .loader')?.classList.remove('hidden');
const view = service.view = document.createElement('webview'); service.view = document.createElement('webview');
updateNavigation(); // Start loading animation service.view.setAttribute('enableRemoteModule', 'false');
view.setAttribute('enableRemoteModule', 'false'); service.view.setAttribute('partition', 'persist:service_' + service.partition);
view.setAttribute('partition', 'persist:service_' + service.partition); service.view.setAttribute('autosize', 'true');
view.setAttribute('autosize', 'true'); service.view.setAttribute('src', emptyPage);
view.setAttribute('allowpopups', 'true');
if (specialPages) view.setAttribute('src', specialPages.empty);
// Error handling // Enable context isolation. This is currently not used as there is no preload script; however it could prevent
view.addEventListener('did-fail-load', (e: DidFailLoadEvent) => { // eventual future human mistakes.
if (e.errorCode <= -100 && e.errorCode > -200) { service.view.setAttribute('webpreferences', 'contextIsolation=yes');
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);
}
});
// Append element to DOM // Append element to DOM
document.querySelector('#services')?.appendChild(view); document.querySelector('#services')?.appendChild(service.view);
// Load chain // Load chain
let listener: () => void; let listener: Function;
view.addEventListener('dom-ready', listener = () => { service.view.addEventListener('dom-ready', listener = () => {
view.removeEventListener('dom-ready', listener); service.view.removeEventListener('dom-ready', listener);
view.addEventListener('dom-ready', listener = () => { service.view.addEventListener('dom-ready', listener = () => {
if (service.customCSS) { if (service.customCSS) {
view.insertCSS(service.customCSS) service.view.insertCSS(service.customCSS);
.catch(console.error);
} }
document.querySelector('#services > .loader')?.classList.add('hidden'); document.querySelector('#services > .loader')?.classList.add('hidden');
service.li?.classList.add('loaded'); service.li.classList.add('loaded');
service.viewReady = true; service.viewReady = true;
updateNavigation(); updateNavigation();
if (selectedServiceId === null) { if (selectedService === null) {
setActiveService(serviceId); setActiveService(serviceId);
} }
}); });
const webContents = remote.webContents.fromId(service.view.getWebContentsId());
// Set custom user agent // Set custom user agent
if (typeof service.customUserAgent === 'string') { if (typeof service.customUserAgent === 'string') {
ipcRenderer.send('set-web-contents-user-agent', view.getWebContentsId(), service.customUserAgent); webContents.setUserAgent(service.customUserAgent);
} }
// Set context menu // Set context menu
ipcRenderer.send('open-service-content-context-menu', view.getWebContentsId()); setContextMenu(webContents);
// Set permission request handler // 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 // 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); console.debug('Loaded favicons for', service.name, event.favicons);
if (event.favicons.length > 0 && service.favicon !== event.favicons[0]) { if (event.favicons.length > 0 && service.favicon !== event.favicons[0]) {
ipcRenderer.send('setServiceFavicon', serviceId, 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.src = event.favicons[0];
img.alt = service.name; img.alt = service.name;
img.onload = () => { img.onload = () => {
if (service.li) { service.li.button.innerHTML = '';
service.li.button.innerHTML = ''; service.li.button.appendChild(img);
service.li.button.appendChild(img);
}
}; };
} }
} }
}); });
// Display target urls // Display target urls
view.addEventListener('update-target-url', (event: UpdateTargetUrlEvent) => { service.view.addEventListener('update-target-url', (event: UpdateTargetUrlEvent) => {
if (event.url.length === 0) { if (event.url.length === 0) {
urlPreview?.classList.add('invisible'); urlPreview?.classList.add('hidden');
} else { } else {
urlPreview?.classList.remove('invisible'); urlPreview?.classList.remove('hidden');
if (urlPreview) { if (urlPreview) {
urlPreview.innerHTML = event.url; urlPreview.innerHTML = event.url;
} }
@ -532,28 +597,21 @@ function loadService(serviceId: number, service: FrontService) {
function unloadService(serviceId: number) { function unloadService(serviceId: number) {
const service = services[serviceId]; const service = services[serviceId];
if (!service) throw new Error('Service doesn\'t exist.');
if (service.view && service.viewReady) { if (service.view && service.viewReady) {
service.view.remove(); service.view.remove();
service.view = undefined; service.view = null;
service.li?.classList.remove('loaded'); service.li.classList.remove('loaded');
service.viewReady = false; service.viewReady = false;
if (selectedServiceId === serviceId) { if (parseInt(selectedService) === serviceId) {
selectedServiceId = null; selectedService = null;
for (let i = 0; i < services.length; i++) { for (let i = 0; i < services.length; i++) {
const otherService = services[i]; if (services[i].view && services[i].viewReady) {
if (otherService && otherService.view && otherService.viewReady) {
setActiveService(i); setActiveService(i);
break; break;
} }
} }
if (selectedService === null) {
// false positive:
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selectedServiceId === null) {
updateNavigation(); updateNavigation();
} }
} }
@ -562,8 +620,6 @@ function unloadService(serviceId: number) {
function reloadService(serviceId: number) { function reloadService(serviceId: number) {
const service = services[serviceId]; const service = services[serviceId];
if (!service) throw new Error('Service doesn\'t exist.');
if (service.view && service.viewReady) { if (service.view && service.viewReady) {
console.log('Reloading service', serviceId); console.log('Reloading service', serviceId);
document.querySelector('#services > .loader')?.classList.remove('hidden'); 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() { function updateNavigation() {
console.debug('Updating navigation'); console.debug('Updating navigation');
// Update active list element // Update active list element
for (let i = 0; i < services.length; i++) { for (let i = 0; i < services.length; i++) {
const service = services[i]; const service = services[i];
if (!service) continue;
if (!service.li) continue;
// Active? // Active?
if (selectedServiceId === i) service.li.classList.add('active'); if (parseInt(selectedService) === i) service.li.classList.add('active');
else service.li.classList.remove('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? // Loaded?
if (service.viewReady) service.li.classList.add('loaded'); if (service.viewReady) service.li.classList.add('loaded');
else service.li.classList.remove('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'); console.debug('Updating navigation buttons because view is ready');
// Update history navigation // Update history navigation
const view = services[selectedServiceId]?.view; let view = services[selectedService].view;
homeButton?.classList.remove('disabled'); homeButton?.classList.remove('disabled');
@ -617,70 +671,164 @@ function updateNavigation() {
} }
function updateStatusButton() { function updateStatusButton() {
if (typeof selectedServiceId !== 'number') return; let protocol = services[selectedService].view.getURL().split('://')[0];
if (!protocol) protocol = 'unknown';
const protocol = services[selectedServiceId]?.view?.getURL().split('://')[0] || 'unknown'; for (const c of <any>securityButton?.children) {
securityButton?.childNodes.forEach(el => { if (c.classList.contains(protocol)) c.classList.add('active');
if (el instanceof HTMLElement) { else c.classList.remove('active');
if (el.classList.contains(protocol)) el.classList.add('active'); }
else el.classList.remove('active');
}
});
} }
function updateWindowTitle() { function updateWindowTitle() {
if (selectedServiceId === null) { if (selectedService === null) {
ipcRenderer.send('update-window-title', null); ipcRenderer.send('updateWindowTitle', null);
} else { } else if (services[selectedService].viewReady) {
const service = services[selectedServiceId]; ipcRenderer.send('updateWindowTitle', selectedService, remote.webContents.fromId(services[selectedService].view.getWebContentsId()).getTitle());
if (service?.viewReady && service.view) {
ipcRenderer.send('update-window-title', selectedServiceId, service.view.getWebContentsId());
}
} }
} }
function goHome() { function goHome() {
if (selectedServiceId === null) return; let service = services[selectedService];
service.view.loadURL(service.url)
const service = services[selectedServiceId];
if (!service) throw new Error('Service doesn\'t exist.');
service.view?.loadURL(service.url)
.catch(console.error); .catch(console.error);
} }
function goForward() { function goForward() {
if (selectedServiceId === null) return; let view = services[selectedService].view;
if (view) remote.webContents.fromId(view.getWebContentsId()).goForward();
const view = services[selectedServiceId]?.view;
if (view) ipcRenderer.send('go-forward', view.getWebContentsId());
} }
function goBack() { function goBack() {
if (selectedServiceId === null) return; let view = services[selectedService].view;
if (view) remote.webContents.fromId(view.getWebContentsId()).goBack();
const view = services[selectedServiceId]?.view;
if (view) ipcRenderer.send('go-back', view.getWebContentsId());
} }
function reload() { function reload() {
if (selectedServiceId === null) return; reloadService(selectedService);
reloadService(selectedServiceId);
} }
ipcRenderer.on('fullscreenchange', (e, fullscreen: boolean) => { function setContextMenu(webContents: WebContents) {
if (fullscreen) document.body.classList.add('fullscreen'); webContents.on('context-menu', (event, props) => {
else document.body.classList.remove('fullscreen'); const menu = new Menu();
}); const {editFlags} = props;
type FrontService = Service & { // linkURL
view?: Electron.WebviewTag; if (props.linkURL.length > 0) {
viewReady?: boolean; if (menu.items.length > 0) {
li?: NavigationElement; menu.append(new MenuItem({type: 'separator'}));
}; }
type NavigationElement = HTMLLIElement & { menu.append(new MenuItem({
serviceId: number; label: 'Copy link URL',
button: HTMLButtonElement; 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(),
});
});
}

View File

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

View File

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

View File

@ -1,24 +1,22 @@
{ {
"name": "tabs", "name": "tabs",
"version": "1.3.1", "version": "1.0.0",
"description": "Persistent and separate browser tabs in one window.", "description": "Persistent and separate browser tabs in one window.",
"author": { "author": {
"name": "Alice Gaudon", "name": "Alice Gaudon",
"email": "alice@gaudon.pro" "email": "alice@gaudon.pro"
}, },
"homepage": "https://eternae.ink/ashpie/tabs", "homepage": "https://gitlab.com/ArisuOngaku/tabs",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "build/main.js", "main": "build/main.js",
"scripts": { "scripts": {
"clean": "(! test -d build || rm -r build) && (! test -d resources || rm -r resources)", "clean": "(! test -d build || rm -r build) && (! test -d resources || rm -r resources)",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx", "compile": "yarn clean && tsc -p tsconfig.backend.json && tsc -p tsconfig.frontend.json && webpack --mode production",
"compile": "yarn compile-common && webpack --mode production", "compile-dev": "yarn clean && tsc -p tsconfig.backend.json && tsc -p tsconfig.frontend.json && webpack --mode development",
"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",
"start": "yarn compile && electron .", "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\"", "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", "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": { "dependencies": {
"appdata-path": "^1.0.0", "appdata-path": "^1.0.0",
@ -30,31 +28,28 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.6", "@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6", "@babel/preset-env": "^7.9.6",
"@fortawesome/fontawesome-free": "^6.1.0", "@fortawesome/fontawesome-free": "^5.13.0",
"@types/node": "^14.6.2", "@types/node": "^12.12.41",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"concurrently": "^7.0.0", "concurrently": "^5.2.0",
"copy-webpack-plugin": "^10.2.4", "copy-webpack-plugin": "^6.0.1",
"css-loader": "^6.3.0", "css-loader": "^3.5.3",
"electron": "^17.1.2", "electron": "^9.0.0",
"electron-builder": "^22.11.5", "electron-builder": "^22.4.0",
"eslint": "^8.11.0", "file-loader": "^6.0.0",
"image-minimizer-webpack-plugin": "^3.2.3", "imagemin": "^7.0.1",
"imagemin": "^8.0.1",
"imagemin-gifsicle": "^7.0.0", "imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^10.0.0", "imagemin-mozjpeg": "^8.0.0",
"imagemin-pngquant": "^9.0.2", "imagemin-pngquant": "^8.0.0",
"imagemin-svgo": "^10.0.1", "imagemin-svgo": "^8.0.0",
"mini-css-extract-plugin": "^2.1.0", "img-loader": "^3.0.1",
"sass": "^1.32.12", "mini-css-extract-plugin": "^0.9.0",
"sass-loader": "^12.1.0", "node-sass": "^4.14.1",
"svgo": "^2.3.1", "sass-loader": "^8.0.2",
"ts-loader": "^9.1.2", "ts-loader": "^7.0.4",
"typescript": "^4.0.2", "typescript": "^3.9.3",
"webpack": "^5.2.0", "webpack": "^4.43.0",
"webpack-cli": "^4.1.0" "webpack-cli": "^3.3.11"
}, },
"build": { "build": {
"appId": "tabs-app", "appId": "tabs-app",
@ -62,33 +57,48 @@
"resources/**/*", "resources/**/*",
"build/**/*" "build/**/*"
], ],
"publish": [
{
"provider": "generic",
"url": "https://update.eternae.ink/ashpie/tabs"
}
],
"linux": { "linux": {
"target": "AppImage", "target": "AppImage",
"icon": "frontend/images/logo.png", "icon": "resources/logo.png",
"category": "Utility", "category": "Utility",
"executableName": "tabs", "executableName": "tabs",
"desktop": { "desktop": {
"StartupWMClass": "Tabs", "StartupWMClass": "Tabs",
"MimeType": "x-scheme-handler/tabs" "MimeType": "x-scheme-handler/tabs"
} },
"publish": [
{
"provider": "github",
"owner": "ArisuOngaku",
"repo": "tabs"
}
]
}, },
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "frontend/images/logo.png", "icon": "resources/logo.png",
"publisherName": "Alice Gaudon", "publisherName": "Alice Gaudon",
"verifyUpdateCodeSignature": "false" "verifyUpdateCodeSignature": "true",
"publish": [
{
"provider": "github",
"owner": "ArisuOngaku",
"repo": "tabs"
}
]
}, },
"mac": { "mac": {
"target": "default", "target": "default",
"icon": "frontend/images/logo.png", "icon": "resources/logo.png",
"category": "public.app-category.utilities" "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 Meta from "./Meta";
import Config from "./Config"; import Config from "./Config";
import Updater from "./Updater"; import Updater from "./Updater";
import MainWindow from "./windows/MainWindow"; import MainWindow from "./windows/MainWindow";
import * as os from "os";
export default class Application { export default class Application {
private readonly devMode: boolean; private readonly devMode: boolean;
@ -12,10 +11,10 @@ export default class Application {
private readonly mainWindow: MainWindow; private readonly mainWindow: MainWindow;
private tray?: Tray; private tray?: Tray;
public constructor(devMode: boolean) { constructor(devMode: boolean) {
this.devMode = devMode; this.devMode = devMode;
this.config = new Config(); this.config = new Config();
this.updater = new Updater(this.config, this); this.updater = new Updater(this.config);
this.mainWindow = new MainWindow(this); this.mainWindow = new MainWindow(this);
} }
@ -27,11 +26,9 @@ export default class Application {
this.setupElectronTweaks(); this.setupElectronTweaks();
// Check for updates // Check for updates
if (os.platform() === 'win32') { this.updater.checkAndPromptForUpdates(this.mainWindow.getWindow()).then(() => {
this.updater.checkAndPromptForUpdates(this.mainWindow.getWindow()).then(() => { console.log('Update check successful.');
console.log('Update check successful.'); }).catch(console.error);
}).catch(console.error);
}
console.log('App started'); console.log('App started');
} }
@ -52,48 +49,27 @@ export default class Application {
return this.devMode; 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() { private setupElectronTweaks() {
// Open external links in default OS browser // Open external links in default OS browser
app.on('web-contents-created', (e, contents) => { app.on('web-contents-created', (e, contents) => {
if (contents.getType() === 'webview') { if (contents.getType() === 'webview') {
console.log('Setting external links to open in default OS browser'); console.log('Setting external links to open in default OS browser');
contents.setWindowOpenHandler(details => { contents.on('new-window', (e, url) => {
if (details.url.startsWith(details.referrer.url)) return {action: 'allow'}; e.preventDefault();
if (url.startsWith('https://')) {
const url = details.url; shell.openExternal(url).catch(console.error);
this.openExternalLink(url) }
.catch(console.error);
return {action: 'deny'};
}); });
} }
}); });
// Disable unused features // Disable unused features
app.on('web-contents-created', (e, contents) => { 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; delete webPreferences.preload;
webPreferences.nodeIntegration = false; webPreferences.nodeIntegration = false;
// TODO: Here would be a good place to filter accessed urls (params.src). // TODO: Here would be a good place to filter accessed urls (params.src). Also consider 'will-navigate' event on contents.
// Also consider 'will-navigate' event on contents.
}); });
}); });
} }
@ -106,8 +82,8 @@ export default class Application {
{label: 'Tabs', enabled: false}, {label: 'Tabs', enabled: false},
{label: 'Open Tabs', click: () => this.mainWindow.getWindow().show()}, {label: 'Open Tabs', click: () => this.mainWindow.getWindow().show()},
{type: 'separator'}, {type: 'separator'},
{label: 'Quit', role: 'quit'}, {label: 'Quit', role: 'quit'}
])); ]));
this.tray.on('click', () => this.mainWindow.toggle()); this.tray.on('click', () => this.mainWindow.toggle());
} }
} }

View File

@ -10,13 +10,7 @@ const configFile = path.resolve(configDir, 'config.json');
export default class Config { export default class Config {
public services: Service[] = []; public services: Service[] = [];
public updateCheckSkip?: string; public updateCheckSkip?: string;
public startMinimized: boolean = false;
public bigNavBar: boolean = false;
public securityButton: boolean = true; public securityButton: boolean = true;
public homeButton: boolean = false; public homeButton: boolean = false;
public backButton: boolean = true; public backButton: boolean = true;
@ -25,11 +19,9 @@ export default class Config {
private properties: string[] = []; private properties: string[] = [];
[p: string]: unknown; constructor() {
public constructor() {
// Load data from config file // Load data from config file
let data: Record<string, unknown> = {}; let data: any = {};
if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) { if (fs.existsSync(configDir) && fs.statSync(configDir).isDirectory()) {
if (fs.existsSync(configFile) && fs.statSync(configFile).isFile()) if (fs.existsSync(configFile) && fs.statSync(configFile).isFile())
data = JSON.parse(fs.readFileSync(configFile, 'utf8')); data = JSON.parse(fs.readFileSync(configFile, 'utf8'));
@ -38,7 +30,7 @@ export default class Config {
} }
// Parse services // Parse services
if (typeof data.services === 'object' && Array.isArray(data.services)) { if (typeof data.services === 'object') {
let i = 0; let i = 0;
for (const service of data.services) { for (const service of data.services) {
this.services[i] = new Service(service); this.services[i] = new Service(service);
@ -47,22 +39,11 @@ export default class Config {
} }
if (this.services.length === 0) { if (this.services.length === 0) {
this.services.push(new Service( this.services.push(new Service('welcome', 'Welcome', 'rocket', false, 'https://github.com/ArisuOngaku/tabs', false));
'welcome',
'Welcome',
'rocket',
false,
'https://eternae.ink/ashpie/tabs',
false,
));
} }
this.defineProperty('updateCheckSkip', data); this.defineProperty('updateCheckSkip', data);
this.defineProperty('startMinimized', data);
this.defineProperty('bigNavBar', data);
this.defineProperty('securityButton', data); this.defineProperty('securityButton', data);
this.defineProperty('homeButton', data); this.defineProperty('homeButton', data);
this.defineProperty('backButton', data); this.defineProperty('backButton', data);
@ -72,25 +53,26 @@ export default class Config {
this.save(); this.save();
} }
public save(): void { save() {
console.log('Saving config'); console.log('Saving config');
this.services = this.services.filter(s => s !== null);
fs.writeFileSync(configFile, JSON.stringify(this, null, 4)); fs.writeFileSync(configFile, JSON.stringify(this, null, 4));
console.log('> Config saved to', configFile.toString()); 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) { if (data[name] !== undefined) {
this[name] = data[name]; (<any>this)[name] = data[name];
} }
this.properties.push(name); this.properties.push(name);
} }
public update(data: Record<string, unknown>): void { update(data: any) {
for (const prop of this.properties) { for (const prop of this.properties) {
if (data[prop] !== undefined) { 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 BRAND_ICONS = Meta.listIcons('brands');
public static readonly SOLID_ICONS = Meta.listIcons('solid'); public static readonly SOLID_ICONS = Meta.listIcons('solid');
public static readonly REGULAR_ICONS = Meta.listIcons('regular'); public static readonly REGULAR_ICONS = Meta.listIcons('regular');
public static readonly ICON_SETS: IconSet[] = [ public static readonly ICON_SETS = [
Meta.BRAND_ICONS, Meta.BRAND_ICONS,
Meta.SOLID_ICONS, Meta.SOLID_ICONS,
Meta.REGULAR_ICONS, Meta.REGULAR_ICONS,
@ -21,7 +21,7 @@ export default class Meta {
private static devMode?: boolean; private static devMode?: boolean;
public static isDevMode(): boolean { public static isDevMode() {
if (this.devMode === undefined) { if (this.devMode === undefined) {
this.devMode = process.argv.length > 2 && process.argv[2] === '--dev'; this.devMode = process.argv.length > 2 && process.argv[2] === '--dev';
console.debug('Dev mode:', this.devMode); console.debug('Dev mode:', this.devMode);
@ -30,7 +30,7 @@ export default class Meta {
return this.devMode; return this.devMode;
} }
public static getTitleForService(service: Service, viewTitle: string): string { public static getTitleForService(service: Service, viewTitle: string) {
let suffix = ''; let suffix = '';
if (viewTitle.length > 0) { if (viewTitle.length > 0) {
suffix = ' - ' + viewTitle; suffix = ' - ' + viewTitle;
@ -38,7 +38,7 @@ export default class Meta {
return this.title + ' - ' + service.name + suffix; return this.title + ' - ' + service.name + suffix;
} }
private static listIcons(set: string): IconSet { private static listIcons(set: string) {
console.log('Loading icon set', set); console.log('Loading icon set', set);
const directory = path.resolve(Meta.RESOURCES_PATH, `images/icons/${set}`); const directory = path.resolve(Meta.RESOURCES_PATH, `images/icons/${set}`);
const icons: { name: string; faIcon: string; set: string; }[] = []; const icons: { name: string; faIcon: string; set: string; }[] = [];
@ -51,17 +51,3 @@ export default class Meta {
return icons; 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 { export default class Service {
public name: string; public partition?: string;
public partition: string; public name?: string;
public url: string;
public icon?: string; public icon?: string;
public isImage: boolean = false; public isImage?: boolean = false;
public url?: string;
public useFavicon: boolean = true; public useFavicon?: boolean = true;
public favicon?: string; public favicon?: string;
public autoLoad?: boolean = false;
public autoLoad: boolean = false;
public customCSS?: string; public customCSS?: string;
public customUserAgent?: string; public customUserAgent?: string;
public permissions: ServicePermissions = {}; public permissions?: {} = {};
[p: string]: unknown; constructor(partition: string, name?: string, icon?: string, isImage?: boolean, url?: string, useFavicon?: boolean) {
if (arguments.length === 1) {
public constructor( const data = arguments[0];
partition: string | Pick<Service, keyof Service>, for (const k in data) {
name?: string, if (data.hasOwnProperty(k)) {
icon?: string, (<any>this)[k] = data[k];
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);
}
}
} }
} }
} 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 {autoUpdater, UpdateInfo} from "electron-updater";
import {dialog} from "electron"; import {dialog, shell} from "electron";
import Config from "./Config"; import Config from "./Config";
import Application from "./Application";
import {SemVer} from "semver";
import BrowserWindow = Electron.BrowserWindow; import BrowserWindow = Electron.BrowserWindow;
export default class Updater { export default class Updater {
private readonly config: Config; private readonly config: Config;
private readonly application: Application;
private updateInfo?: UpdateInfo; private updateInfo?: UpdateInfo;
public constructor(config: Config, application: Application) { public constructor(config: Config) {
this.config = config; this.config = config;
this.application = application;
// Configure auto updater // Configure auto updater
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
@ -37,7 +33,7 @@ export default class Updater {
} }
} }
public getCurrentVersion(): SemVer { public getCurrentVersion() {
return autoUpdater.currentVersion; return autoUpdater.currentVersion;
} }
@ -46,16 +42,16 @@ export default class Updater {
if (updateInfo && updateInfo.version !== this.config.updateCheckSkip) { if (updateInfo && updateInfo.version !== this.config.updateCheckSkip) {
const input = await dialog.showMessageBox(mainWindow, { 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: [ buttons: [
'Cancel', 'Cancel',
'Install', 'Download',
], ],
checkboxChecked: false, checkboxChecked: false,
checkboxLabel: `Don't remind me for this version`, checkboxLabel: `Don't remind me for this version`,
cancelId: 0, cancelId: 0,
defaultId: 1, defaultId: 1,
type: 'question', type: 'question'
}); });
if (input.checkboxChecked) { if (input.checkboxChecked) {
@ -65,9 +61,7 @@ export default class Updater {
} }
if (input.response === 1) { if (input.response === 1) {
await this.application.stop(); await shell.openExternal(`https://github.com/ArisuOngaku/tabs/releases/download/v${updateInfo.version}/${updateInfo.path}`);
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall();
} }
} }
} }

View File

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

View File

@ -4,9 +4,6 @@ import {app} from "electron";
import Meta from "./Meta"; import Meta from "./Meta";
import Application from "./Application"; 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()); const application = new Application(Meta.isDevMode());
// Check if application is already running // Check if application is already running

View File

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

View File

@ -1,42 +1,39 @@
import path from "path"; import path from "path";
import {clipboard, ContextMenuParams, dialog, ipcMain, Menu, MenuItem, session, webContents} from "electron"; import {ipcMain} from "electron";
import ServiceSettingsWindow from "./ServiceSettingsWindow"; import ServiceSettingsWindow from "./ServiceSettingsWindow";
import SettingsWindow from "./SettingsWindow"; import SettingsWindow from "./SettingsWindow";
import Application from "../Application"; import Application from "../Application";
import Meta, {SpecialPages} from "../Meta"; import Meta from "../Meta";
import Window from "../Window"; import Window from "../Window";
export default class MainWindow extends Window { export default class MainWindow extends Window {
private activeServiceId: number = 0; private activeService: number = 0;
private serviceSettingsWindow?: ServiceSettingsWindow; private serviceSettingsWindow?: ServiceSettingsWindow;
private settingsWindow?: SettingsWindow; private settingsWindow?: SettingsWindow;
public constructor(application: Application) { constructor(application: Application) {
super(application); super(application);
} }
public setup(): void { public setup() {
super.setup({ super.setup({
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
enableRemoteModule: true,
webviewTag: true, webviewTag: true,
contextIsolation: false,
}, },
autoHideMenuBar: true, autoHideMenuBar: true,
icon: Meta.ICON_PATH, icon: Meta.ICON_PATH,
title: Meta.title, title: Meta.title,
show: !this.application.getConfig().startMinimized,
}); });
const window = this.getWindow(); const window = this.getWindow();
if (!this.application.getConfig().startMinimized) { window.maximize();
window.maximize();
}
if (this.application.isDevMode()) { if (this.application.isDevMode()) {
window.webContents.openDevTools({ window.webContents.openDevTools({
mode: 'right', mode: 'right'
}); });
} }
@ -46,19 +43,19 @@ export default class MainWindow extends Window {
}); });
// Load active service // Load active service
this.onIpc('setActiveService', (event, index: number) => { this.onIpc('setActiveService', (event, index) => {
this.setActiveService(index); this.setActiveService(index);
}); });
// Set a service's favicon // 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); console.log('Setting service', index, 'favicon', favicon);
this.config.services[index].favicon = favicon; this.config.services[index].favicon = favicon;
this.config.save(); this.config.save();
}); });
// Reorder services // Reorder services
this.onIpc('reorderService', (event, serviceId: number, targetId: number) => { this.onIpc('reorderService', (event, serviceId, targetId) => {
console.log('Reordering services', serviceId, targetId); console.log('Reordering services', serviceId, targetId);
const oldServices = this.config.services; const oldServices = this.config.services;
@ -80,14 +77,40 @@ export default class MainWindow extends Window {
this.config.save(); 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 // Update window title
ipcMain.on('update-window-title', (event, serviceId: number | null, webContentsId?: number) => { ipcMain.on('updateWindowTitle', (event, serviceId, viewTitle) => {
if (serviceId === null) { if (serviceId === null) {
window.setTitle(Meta.title); window.setTitle(Meta.title);
} else if (webContentsId) { } else {
const service = this.config.services[serviceId]; const service = this.config.services[serviceId];
const serviceWebContents = webContents.fromId(webContentsId); window.setTitle(Meta.getTitleForService(service, viewTitle));
window.setTitle(Meta.getTitleForService(service, serviceWebContents.getTitle())); }
});
// 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 // Load navigation view
window.loadFile(path.resolve(Meta.RESOURCES_PATH, 'index.html')) window.loadFile(path.resolve(Meta.RESOURCES_PATH, 'index.html'))
.catch(console.error); .catch(console.error);
} }
public syncData(): void { public syncData() {
this.getWindow().webContents.send('data', this.getWindow().webContents.send('data',
Meta.title, Meta.title,
Meta.ICON_SETS, Meta.ICON_SETS,
this.activeServiceId, this.activeService,
<SpecialPages>{ path.resolve(Meta.RESOURCES_PATH, 'empty.html'),
empty: path.resolve(Meta.RESOURCES_PATH, 'empty.html'), this.config
connectionError: path.resolve(Meta.RESOURCES_PATH, 'connection_error.html'),
fileNotFound: path.resolve(Meta.RESOURCES_PATH, 'file_not_found_error.html'),
},
this.config,
); );
} }
private setActiveService(index: number) { private setActiveService(index: number) {
console.log('Set active service', index); console.log('Set active service', index);
this.activeServiceId = index; this.activeService = 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;
});
}
}

View File

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

View File

@ -3,15 +3,14 @@ import path from "path";
import Window from "../Window"; import Window from "../Window";
import MainWindow from "./MainWindow"; import MainWindow from "./MainWindow";
import Meta from "../Meta"; import Meta from "../Meta";
import Config from "../Config";
export default class SettingsWindow extends Window { export default class SettingsWindow extends Window {
public setup(): void { public setup() {
super.setup({ super.setup({
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
enableRemoteModule: true,
webviewTag: true, webviewTag: true,
contextIsolation: false,
}, },
modal: true, modal: true,
autoHideMenuBar: true, autoHideMenuBar: true,
@ -22,7 +21,7 @@ export default class SettingsWindow extends Window {
if (this.application.isDevMode()) { if (this.application.isDevMode()) {
window.webContents.openDevTools({ window.webContents.openDevTools({
mode: 'right', mode: 'right'
}); });
} }
@ -37,7 +36,7 @@ export default class SettingsWindow extends Window {
}).catch(console.error); }).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.update(data);
this.config.save(); this.config.save();
if (this.parent instanceof MainWindow) { if (this.parent instanceof MainWindow) {
@ -48,4 +47,4 @@ export default class SettingsWindow extends Window {
window.loadFile(path.resolve(Meta.RESOURCES_PATH, 'settings.html')) window.loadFile(path.resolve(Meta.RESOURCES_PATH, 'settings.html'))
.catch(console.error); .catch(console.error);
} }
} }

View File

@ -1,7 +1,5 @@
const path = require('path'); const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 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 CopyWebpackPlugin = require('copy-webpack-plugin');
const dev = process.env.NODE_ENV === 'development'; const dev = process.env.NODE_ENV === 'development';
@ -39,6 +37,9 @@ const config = {
use: [ use: [
{ {
loader: MiniCssExtractPlugin.loader, loader: MiniCssExtractPlugin.loader,
options: {
publicPath: '/',
}
}, },
'css-loader', 'css-loader',
'sass-loader', 'sass-loader',
@ -46,17 +47,25 @@ const config = {
}, },
{ {
test: /\.(woff2?|eot|ttf|otf)$/i, test: /\.(woff2?|eot|ttf|otf)$/i,
type: 'asset/resource', use: 'file-loader?name=../fonts/[name].[ext]',
generator: {
filename: '../fonts/[name][ext]',
},
}, },
{ {
test: /\.(png|jpe?g|gif|svg)$/i, test: /\.(png|jpe?g|gif|svg)$/i,
type: 'asset/resource', use: [
generator: { 'file-loader?name=../images/[name].[ext]',
filename: '../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, test: /\.ts$/i,
@ -66,14 +75,13 @@ const config = {
configFile: 'tsconfig.frontend.json', configFile: 'tsconfig.frontend.json',
} }
}, },
exclude: '/node_modules/', exclude: '/node_modules/'
}, },
{ {
test: /\.html$/i, test: /\.html$/i,
type: 'asset/resource', use: [
generator: { 'file-loader?name=../[name].[ext]',
filename: '../[name][ext]', ]
},
} }
], ],
}, },
@ -86,57 +94,7 @@ const config = {
{from: 'node_modules/@fortawesome/fontawesome-free/svgs', to: '../images/icons'} {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"}],
// },
// },
// },
// },
// },
// },
],
],
},
},
}),
] ]
}; };
module.exports = config; module.exports = config;

7877
yarn.lock

File diff suppressed because it is too large Load Diff