Add Electron-based Standalone Application (#129)

* add electron app
* add some readme
* add more documentation
* add a password fix for windows
* format code
* overwrite existing dists
* build app first before building electron app
* add authentication
* add build
* use material ui for credentials
* add application bar
* open dev tools only in dev mode
* cleanup code
* disable add button if a new item is added
* do not always create credentials helper - create it once
* improve add button
* do not make credential helper modal
* use dark mode if user prefers it
* disable menubar in credentials window
* clean up package json
* show windows first when all DOMs are loaded
* remove save button
* write documentation
* load credentials after credentials helper is closed
* execute npm install first
* add gif animation for the credential helper
This commit is contained in:
Manuel Leitold 2020-05-11 10:57:10 +02:00 committed by GitHub
parent f0a40d6087
commit da9591609e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 558 additions and 1 deletions

View File

@ -235,6 +235,10 @@ auth:
path: /etc/docker/registry/htpasswd
```
## Standalone Application
If you do not want to install the docker-registry-ui on your server, you may
check out the [Electron](electron/README.md) standalone application.
## All examples
- [Use docker-registry-ui as a proxy (use REGISTRY_URL)](https://github.com/Joxit/docker-registry-ui/tree/master/examples/ui-as-proxy)

8
electron/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
dist/
node_modules/
Registry*
.cache

57
electron/README.md Normal file
View File

@ -0,0 +1,57 @@
# Standalone Application
## Overview
This standalone application is based on Electron which encapsulates the whole
docker-registry-ui in a single executable, that can be run on your local
computer.
## Building
* Check out or download the repository, open a terminal at the checkout
directory, download the dependencies and build the web app:
```bash
npm install
npm run build
```
* After building the web application, navigate to the ```electron``` directory
and execute following commands to build the executable:
```bash
cd electron
npm install
npm run dist
```
If you encounter any issues, please check the troubleshooting below.
## Password Protected Registries
If you want to interact with password protected Docker Registries, this
application will use the keystore of your system to gather the credentials for
accessing the Registry.
This is accomplished with the [keytar](https://www.npmjs.com/package/keytar)
package. In concjunction with keytar, the integrated credential
helper supports you with managing the credentials to the Registries.
![alt Authentication on macOS](./doc/assets/authentication.gif)
## Troubleshooting
* Problem: The application does not start with ```npm start``` and exits with following message:
```
[7742:0509/001117.199224:FATAL:setuid_sandbox_host.cc(157)] The SUID sandbox helper binary was found, but is not configured correctly. Rather than run without sandboxing I'm aborting now. You need to make sure that ./node_modules/electron dist/chrome-sandbox is owned by root and has mode 4755.
```
Solution: Add proper rights to the chrome-sanbox
```bash
sudo chown root ./node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox
```
* Problem: I am on Linux and to not have any password wallet for keytar.
Solution: Install following dependencies according to the official [setup instructions](https://atom.github.io/node-keytar/) for keytar on Linux:
* Debian/Ubuntu: ```sudo apt-get install libsecret-1-dev```
* Red Hat-based: ```sudo yum install libsecret-devel```
* Arch Linux: ```sudo pacman -S libsecret```

View File

@ -0,0 +1,8 @@
<html>
<body>
<div id="root"></div>
<script src="index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,211 @@
import * as React from "react";
import {useEffect, useState} from "react";
import {render} from "react-dom";
import * as keytar from 'keytar';
import {ipcRenderer} from 'electron';
import {
Button,
createMuiTheme,
CssBaseline,
IconButton,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
ThemeProvider,
useMediaQuery
} from "@material-ui/core";
import {Alert, AlertTitle} from '@material-ui/lab';
import {blue} from "@material-ui/core/colors";
import {Add as AddIcon, Delete as DeleteIcon, Save as SaveIcon} from "@material-ui/icons";
const mainStyle = makeStyles((theme) => ({
root: {
padding: theme.spacing(2),
display: "flex",
flexDirection: 'column',
width: '100%',
height: '100%',
},
main: {
flexGrow: 1,
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
input: {
width: '100%',
},
}));
function CredentialRow({credential, index, onDelete, onUpdate}) {
const [account, setAccount] = useState(credential?.account || '');
const [password, setPassword] = useState(credential?.password || '');
const style = mainStyle();
return (<TableRow>
<TableCell>
<TextField
className={style.input}
type="text"
placeholder='https://user@someregistry:5000/'
value={account} variant="outlined"
onChange={(e) => {
setAccount(e.target.value)
}}/>
</TableCell>
<TableCell>
<TextField type="password"
className={style.input}
variant="outlined"
placeholder='Password'
value={password}
onChange={(e) => {
setPassword(e.target.value)
}}/>
</TableCell>
<TableCell align="right">
<IconButton onClick={async () => await onUpdate(credential, index, {account, password})}>
<SaveIcon/>
</IconButton>
<IconButton onClick={async () => await onDelete(credential, index,)}>
<DeleteIcon/>
</IconButton>
</TableCell>
</TableRow>);
}
function CredentialsTable({onError}) {
const [credentials, setCredentials] = useState(null);
async function loadItems() {
try {
const credentials = await keytar.findCredentials('docker-registry-ui');
for (const credential of credentials) {
// fix for windows
credential.password = credential.password.replace(/\000+/g, '');
}
setCredentials(credentials);
} catch (e) {
onError(e.toString());
}
}
async function handleDelete(item, index) {
// delete an item that has not been stored yet
if (!item) {
const newCredentials = [...credentials];
newCredentials.splice(index, 1);
setCredentials(newCredentials);
return;
}
try {
await keytar.deletePassword('docker-registry-ui', item.account);
await loadItems();
} catch (e) {
onError(e.toString());
}
}
async function handleUpdate(oldCredentials, index, newCredentials) {
try {
await handleDelete(oldCredentials, index);
await keytar.setPassword('docker-registry-ui', newCredentials.account, newCredentials.password);
await loadItems();
} catch (e) {
console.error("Error while updating key: ", e);
onError(e.toString());
}
}
useEffect(() => {
const load = async () => {
await loadItems();
};
load();
return;
}, []);
if (credentials === null) {
return <LinearProgress/>
}
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Host of the registry including username</TableCell>
<TableCell>Password</TableCell>
<TableCell align='right'>
<IconButton onClick={() => {
setCredentials([...credentials, null])
}} disabled={credentials.includes(null)}>
<AddIcon/>
</IconButton>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{credentials.map((credential, index) => <CredentialRow
onDelete={handleDelete}
onUpdate={handleUpdate}
index={index}
key={credential?.account || ''}
credential={credential}/>)}
</TableBody>
</Table>
</TableContainer>
);
}
function App() {
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [error, setError] = useState();
const classes = mainStyle();
const theme = React.useMemo(
() =>
createMuiTheme({
palette: {
type: prefersDarkMode ? 'dark' : 'light',
primary: blue,
},
}),
[prefersDarkMode],
);
return (
<ThemeProvider theme={theme}>
<CssBaseline/>
<div className={classes.root}>
{error && <Alert severity='error' onClose={() => {
setError(null)
}}>
<AlertTitle>Error</AlertTitle>
{error}
</Alert>}
<main className={classes.main}>
<CredentialsTable onError={setError}/>
</main>
</div>
</ThemeProvider>
);
}
render(<App/>, document.getElementById("root"));
// @ts-ignore
if (module.hot) {
// @ts-ignore
module.hot.accept();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

229
electron/index.js Normal file
View File

@ -0,0 +1,229 @@
const {app, BrowserWindow, globalShortcut, Menu} = require('electron');
const isDevMode = require('electron-is-dev');
const keytar = require('keytar');
const url = require('url');
const isMac = process.platform === 'darwin'
// Place holders for our windows so they don't get garbage collected.
let mainWindow = null;
// Credentials that are fetched from the Keychain
let credentials = [];
// Credentials helper window
let credentialsWindow;
const template = [
// { role: 'appMenu' }
...(isMac ? [{
label: app.name,
submenu: [
{role: 'about'},
{type: 'separator'},
{
label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => {
credentialsWindow.show();
}
},
{type: 'separator'},
{role: 'hide'},
{role: 'hideothers'},
{role: 'unhide'},
{type: 'separator'},
{role: 'quit'}
]
}] : []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
...(isMac ? [] : [{role: 'quit'}]),
{
label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => {
credentialsWindow.show();
}
},
]
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
...(isMac ? [
{role: 'pasteAndMatchStyle'},
{role: 'delete'},
{role: 'selectAll'},
{type: 'separator'},
{
label: 'Speech',
submenu: [
{role: 'startspeaking'},
{role: 'stopspeaking'}
]
}
] : [
{role: 'delete'},
{type: 'separator'},
{role: 'selectAll'}
])
]
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{role: 'reload'},
{role: 'forcereload'},
{role: 'toggledevtools'},
{type: 'separator'},
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'},
{type: 'separator'},
{
label: 'Credentials Helper', accelerator: 'CmdorCtrl+k', click: () => {
credentialsWindow.show();
}
},
]
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{role: 'minimize'},
{role: 'zoom'},
...(isMac ? [
{type: 'separator'},
{role: 'front'},
{type: 'separator'},
{role: 'window'}
] : [
{role: 'close'}
])
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
const {shell} = require('electron')
await shell.openExternal('https://joxit.dev/docker-registry-ui/')
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
if (isMac) {
Menu.setApplicationMenu(menu);
}
async function loadCredentials() {
try {
credentials = await keytar.findCredentials('docker-registry-ui');
for (const credential of credentials) {
// fix for windows
credential.password = credential.password.replace(/\000+/g, '');
}
} catch (e) {
console.log(e);
credentials = [];
}
}
async function createWindow() {
return new Promise((resolve, reject) => {
mainWindow = new BrowserWindow({
height: 920,
width: 1600,
show: false,
webPreferences: {
nodeIntegration: false,
}
});
if (isDevMode) {
mainWindow.webContents.openDevTools();
}
if (!isMac) {
mainWindow.setMenu(menu);
}
mainWindow.loadURL(`file://${__dirname}/dist/index.html`);
mainWindow.webContents.on('dom-ready', () => {
console.log("Main Window DOM ready");
resolve();
});
});
}
async function createCredentialsWindow() {
return new Promise((resolve) => {
credentialsWindow = new BrowserWindow({
width: 1000,
height: 400,
show: false,
title: 'Credential Manager',
parent: mainWindow,
webPreferences: {
nodeIntegration: true,
}
});
if (isDevMode) {
credentialsWindow.openDevTools();
}
if (!isMac) {
credentialsWindow.setMenu(null);
}
credentialsWindow.loadURL(`file://${__dirname}/dist/authentication/index.html`);
credentialsWindow.webContents.on('dom-ready', () => {
console.log('Credentials Window DOM is ready');
resolve();
});
credentialsWindow.on('close', async (e) => {
console.log("Closed credential window");
credentialsWindow.hide();
e.preventDefault();
await loadCredentials();
mainWindow.reload();
});
});
}
app.on('ready', async () => {
await Promise.all([
loadCredentials(),
createWindow(),
createCredentialsWindow(),
]);
mainWindow.show();
});
app.on("login", (event, contents, authencation, info, callback) => {
for (const credential of credentials) {
const parsedUrl = url.parse(credential.account);
if (parsedUrl.hostname === info.host) {
return callback(parsedUrl.auth, credential.password);
}
}
callback();
});

39
electron/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "docker-registry-ui",
"version": "1.4.8",
"productName": "Registry UI",
"description": "Electron Application for Docker Registry UI",
"main": "index.js",
"scripts": {
"start": "electron ./",
"start:dev": "parcel serve -d dist/authentication -t electron --public-url ./ authentication/index.html",
"build": "parcel build -d dist/authentication -t electron --public-url ./ authentication/index.html",
"rebuild": "electron-rebuild -f -w keytar",
"package": "electron-packager --overwrite .",
"sync": "copyfiles ../dist/* ../dist/**/* out",
"dist": "npm run rebuild && npm run sync && npm run build && npm run package"
},
"dependencies": {
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.52",
"electron-is-dev": "^1.1.0",
"keytar": "^5.6.0",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"devDependencies": {
"copyfiles": "^2.2.0",
"electron": "^8.0.0",
"electron-builder": "^22.6.0",
"electron-packager": "^14.2.1",
"electron-rebuild": "^1.10.1",
"parcel-bundler": "^1.12.4",
"typescript": "^3.8.3"
},
"keywords": [
"electron"
],
"author": "",
"license": "AGPL-3.0"
}

View File

@ -2,7 +2,8 @@
"name": "docker-registry-ui",
"version": "1.4.8",
"scripts": {
"build": "./node_modules/gulp/bin/gulp.js build"
"build": "./node_modules/gulp/bin/gulp.js build",
"build:electron": "npm run build && cd electron && npm install && npm run dist"
},
"repository": {
"type": "git",