diff --git a/README.md b/README.md
index 10b28eb..4f61e6e 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/electron/.gitignore b/electron/.gitignore
new file mode 100644
index 0000000..ed0e1c8
--- /dev/null
+++ b/electron/.gitignore
@@ -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
diff --git a/electron/README.md b/electron/README.md
new file mode 100644
index 0000000..65759b4
--- /dev/null
+++ b/electron/README.md
@@ -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.
+
+
+
+
+## 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```
+
+
diff --git a/electron/authentication/index.html b/electron/authentication/index.html
new file mode 100644
index 0000000..194af0a
--- /dev/null
+++ b/electron/authentication/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/electron/authentication/index.tsx b/electron/authentication/index.tsx
new file mode 100644
index 0000000..6790ec1
--- /dev/null
+++ b/electron/authentication/index.tsx
@@ -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 (
+
+ {
+ setAccount(e.target.value)
+ }}/>
+
+
+ {
+ setPassword(e.target.value)
+ }}/>
+
+
+ await onUpdate(credential, index, {account, password})}>
+
+
+ await onDelete(credential, index,)}>
+
+
+
+ );
+}
+
+
+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
+ }
+
+ return (
+
+
+
+
+ Host of the registry including username
+ Password
+
+ {
+ setCredentials([...credentials, null])
+ }} disabled={credentials.includes(null)}>
+
+
+
+
+
+
+ {credentials.map((credential, index) => )}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {error &&
{
+ setError(null)
+ }}>
+ Error
+ {error}
+ }
+
+
+
+
+
+ );
+}
+
+render(, document.getElementById("root"));
+
+// @ts-ignore
+if (module.hot) {
+ // @ts-ignore
+ module.hot.accept();
+}
+
diff --git a/electron/doc/assets/authentication.gif b/electron/doc/assets/authentication.gif
new file mode 100644
index 0000000..1e4e2bd
Binary files /dev/null and b/electron/doc/assets/authentication.gif differ
diff --git a/electron/index.js b/electron/index.js
new file mode 100644
index 0000000..d656869
--- /dev/null
+++ b/electron/index.js
@@ -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();
+});
diff --git a/electron/package.json b/electron/package.json
new file mode 100644
index 0000000..8564b03
--- /dev/null
+++ b/electron/package.json
@@ -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"
+}
diff --git a/package.json b/package.json
index 9bea700..c339131 100644
--- a/package.json
+++ b/package.json
@@ -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",