├── .gitignore ├── Makefile ├── README.md ├── _config.yml ├── entitlements.mac.plist ├── logo.png ├── package-lock.json ├── package.json ├── public └── index.html ├── scripts └── notarize.js ├── src ├── index.tsx ├── main │ ├── actionTypes.ts │ ├── components │ │ ├── CustomMenu.ts │ │ ├── Window.ts │ │ └── index.ts │ ├── electron.ts │ ├── preload.js │ └── utilities.ts ├── react-app-env.d.ts └── renderer │ ├── App.tsx │ ├── serviceWorker.ts │ └── static │ └── logoBase64.ts ├── tsconfig.json └── types └── electron-reload.d.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | public 4 | dist 5 | build 6 | 7 | # dependencies 8 | /node_modules 9 | /.pnp 10 | .pnp.js 11 | 12 | # testing 13 | /coverage 14 | 15 | # production 16 | /build 17 | /dist 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | *.p12 31 | *.plist 32 | .env 33 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += -j2 2 | 3 | install: 4 | npm install 5 | 6 | link: 7 | npm link @alephdata/react-ftm 8 | 9 | clean: 10 | rm -rf node_modules dist build 11 | 12 | dev: 13 | npm run start-renderer-dev & npm run start-app-dev 14 | 15 | dev-browser-only: 16 | npm run start-renderer-dev-browser-only 17 | 18 | build: 19 | npm run build 20 | 21 | release-patch: 22 | npm version patch 23 | npm run release 24 | 25 | release-minor: 26 | npm version minor 27 | npm run release 28 | 29 | release-major: 30 | npm version minor 31 | npm run release 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aleph Data Desktop 2 | 3 | *** 4 | 5 | **September 2022: Aleph Data Desktop has been deprecated and will not receive any further updates. All of Data Desktop’s features are also available within Aleph. Please refer to the Aleph documentation to find out [how to create network diagrams in Aleph](https://docs.alephdata.org/guide/building-out-your-investigation/network-diagrams) and how to [import diagrams created in Data Desktop](https://docs.alephdata.org/guide/building-out-your-investigation/network-diagrams).** 6 | 7 | *** 8 | 9 | **Data Desktop** is a tool for visualizing and exploring complex networks. 10 | 11 | It is built with investigative reporting as its primary use case and utilizes the [Follow the Money](https://github.com/alephdata/followthemoney) data model, which seeks to provide a common language to describe the entities most commonly used in investigative reporting. 12 | 13 | **To get started, download the latest release for [Mac](https://github.com/alephdata/datadesktop/releases/latest/download/Aleph-Data-Desktop.dmg), [Windows](https://github.com/alephdata/datadesktop/releases/latest/download/Aleph-Data-Desktop.exe), and [Linux](https://github.com/alephdata/datadesktop/releases/latest/download/Aleph.Data.Desktop.deb).** 14 | 15 | For more information, read the [Data Desktop documentation](https://docs.alephdata.org/guide/aleph-data-desktop). 16 | 17 | Feel free to reach out with any feedback by emailing data@occrp.org or by posting in the [Aleph Slack](https://alephdata.slack.com). 18 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | title: Aleph Data Desktop 2 | description: A tool for visualizing and exploring complex networks 3 | theme: jekyll-theme-minimal 4 | -------------------------------------------------------------------------------- /entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-jit 6 | 7 | com.apple.security.cs.allow-unsigned-executable-memory 8 | 9 | com.apple.security.cs.allow-dyld-environment-variables 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alephdata/datadesktop/ea79a51d3134b6c285df708ac741c5aac72d0104/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@alephdata/datadesktop", 3 | "version": "2.7.0", 4 | "description": "Desktop graph visualization application", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/alephdata/datadesktop.git" 8 | }, 9 | "author": "OCCRP Data ", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/alephdata/datadesktop/issues" 13 | }, 14 | "homepage": "./", 15 | "main": "build/electron.js", 16 | "build": { 17 | "appId": "com.electron.visdesktop", 18 | "productName": "Aleph Data Desktop", 19 | "publish": "github", 20 | "afterSign": "scripts/notarize.js", 21 | "artifactName": "${productName}.${ext}", 22 | "fileAssociations": [ 23 | { 24 | "ext": "ftm", 25 | "name": "FTM" 26 | }, 27 | { 28 | "ext": "vis", 29 | "name": "VIS" 30 | } 31 | ], 32 | "files": [ 33 | "src/main/preload.js", 34 | "build/**/*", 35 | "node_modules/**/*", 36 | "icon.*" 37 | ], 38 | "mac": { 39 | "icon": "./logo.png", 40 | "hardenedRuntime": true, 41 | "gatekeeperAssess": false, 42 | "entitlements": "./entitlements.mac.plist", 43 | "entitlementsInherit": "./entitlements.mac.plist", 44 | "target": [ 45 | { 46 | "target": "dmg", 47 | "arch": ["universal"] 48 | } 49 | ] 50 | }, 51 | "linux": { 52 | "target": "deb", 53 | "executableName": "AlephDataDesktop", 54 | "icon": "./logo.png" 55 | }, 56 | "win": { 57 | "target": "portable", 58 | "icon": "./logo.png" 59 | } 60 | }, 61 | "dependencies": { 62 | "@babel/runtime": "^7.16.7", 63 | "electron-is-dev": "^2.0.0", 64 | "electron-log": "^4.4.5", 65 | "electron-updater": "^4.6.1", 66 | "react": "^16.11.0", 67 | "react-dom": "^16.11.0", 68 | "react-scripts": "^4.0.3", 69 | "typescript": "^4.5.5" 70 | }, 71 | "scripts": { 72 | "react-start": "react-scripts start", 73 | "react-build": "react-scripts build", 74 | "react-test": "react-scripts test --env=jsdom", 75 | "react-eject": "react-scripts eject", 76 | "start-renderer-dev": "cross-env BROWSER=none npm run react-start", 77 | "start-renderer-dev-browser-only": "REACT_APP_ENV=browser_only npm run react-start", 78 | "start-app-dev": "tsc-watch ./src/main/electron.ts --outDir ./build --onSuccess \"electron ./build/electron.js\" --onFailure \"echo Compilation Failed\" --compiler typescript/bin/tsc", 79 | "electron-build": "tsc ./src/main/electron.ts --outDir ./build && electron-builder", 80 | "electron-dist": "tsc ./src/main/electron.ts --outDir ./build && electron-builder -mwl -p always", 81 | "build": "npm run react-build && npm run electron-build", 82 | "release": "npm run react-build && npm run electron-dist" 83 | }, 84 | "eslintConfig": { 85 | "extends": "react-app" 86 | }, 87 | "browserslist": { 88 | "production": [ 89 | ">0.2%", 90 | "not dead", 91 | "not op_mini all" 92 | ], 93 | "development": [ 94 | "last 1 chrome version", 95 | "last 1 firefox version", 96 | "last 1 safari version" 97 | ] 98 | }, 99 | "devDependencies": { 100 | "@alephdata/react-ftm": "^2.6.6", 101 | "@blueprintjs/core": "3.52.0", 102 | "@blueprintjs/select": "3.18.11", 103 | "@types/node": "^17.0.13", 104 | "@types/react": "^17.0.2", 105 | "@types/react-dom": "^17.0.1", 106 | "cross-env": "^7.0.3", 107 | "electron": "^16.0.8", 108 | "electron-builder": "^23.1.0", 109 | "electron-notarize": "^1.1.1", 110 | "electron-reload": "^2.0.0-alpha.1", 111 | "tsc-watch": "^4.6.0" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 21 | VIS 22 | 23 | 24 | 25 |
26 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /scripts/notarize.js: -------------------------------------------------------------------------------- 1 | // See: https://medium.com/@TwitterArchiveEraser/notarize-electron-apps-7a5f988406db 2 | 3 | require('dotenv').config(); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | var electron_notarize = require('electron-notarize'); 7 | 8 | module.exports = async function (params) { 9 | console.log(params.electronPlatformName, process.platform); 10 | // Only notarize the app on Mac OS only. 11 | if (params.electronPlatformName !== 'mac' && params.electronPlatformName !== 'darwin') { 12 | return; 13 | } 14 | console.log('afterSign hook triggered', params); 15 | 16 | // Same appId in electron-builder. 17 | let appId = 'com.electron.visdesktop' 18 | 19 | let appPath = path.join(params.appOutDir, `${params.packager.appInfo.productFilename}.app`); 20 | if (!fs.existsSync(appPath)) { 21 | throw new Error(`Cannot find application at: ${appPath}`); 22 | } 23 | 24 | console.log(`Notarizing ${appId} found at ${appPath}`); 25 | 26 | try { 27 | await electron_notarize.notarize({ 28 | appBundleId: appId, 29 | appPath: appPath, 30 | appleId: process.env.APPLE_ID, 31 | appleIdPassword: process.env.APPLE_APP_PASSWORD, 32 | }); 33 | } catch (error) { 34 | console.error(error); 35 | } 36 | 37 | console.log(`Done notarizing ${appId}`); 38 | }; 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './renderer/App'; 4 | 5 | import * as serviceWorker from './renderer/serviceWorker'; 6 | declare global { 7 | interface Window { 8 | electron: any; 9 | } 10 | } 11 | 12 | let ipcRenderer; 13 | 14 | if (process.env.REACT_APP_ENV !== 'browser_only') { 15 | ipcRenderer = window.electron.ipcRenderer; 16 | } 17 | 18 | ReactDOM.render(, document.getElementById('root')); 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: https://bit.ly/CRA-PWA 23 | serviceWorker.unregister(); 24 | -------------------------------------------------------------------------------- /src/main/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const SAVE_FILE = 'SAVE_FILE'; 2 | export const SAVE_FILE_SUCCESS = 'SAVE_FILE_SUCCESS' 3 | export const OPEN_FILE = 'OPEN_FILE' 4 | export const OPEN_FILE_SUCCESS = 'OPEN_FILE_SUCCESS' 5 | export const NEW_FILE = 'NEW_FILE' 6 | -------------------------------------------------------------------------------- /src/main/components/CustomMenu.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, Menu } from 'electron' 2 | 3 | export const CustomMenu = (sendSaveFile: any, newFile: any, openFileDialog: any, sendExportSvg: any) => { 4 | return Menu.buildFromTemplate([ 5 | { 6 | label: 'Aleph Data Desktop', 7 | submenu: [ 8 | { role: 'about' }, 9 | { type: 'separator' }, 10 | { role: 'services' }, 11 | { type: 'separator' }, 12 | { role: 'hide' }, 13 | { role: 'unhide' }, 14 | { role: 'quit' } 15 | ] 16 | }, 17 | { 18 | label: 'File', 19 | submenu: [ 20 | { 21 | label: 'New', 22 | accelerator: 'cmd+N', 23 | click: () => { 24 | newFile() 25 | } 26 | }, 27 | { 28 | label: 'Save', 29 | accelerator: 'cmd+S', 30 | click: () => { 31 | sendSaveFile(false) 32 | } 33 | }, 34 | { 35 | label: 'Save as...', 36 | accelerator: 'Shift+cmd+S', 37 | click: () => { 38 | sendSaveFile(true) 39 | } 40 | }, 41 | { 42 | label: 'Open', 43 | accelerator: 'cmd+O', 44 | click: () => { 45 | openFileDialog() 46 | } 47 | }, 48 | { 49 | label: 'Export as SVG', 50 | click: () => { 51 | sendExportSvg() 52 | } 53 | } 54 | ] 55 | }, 56 | { 57 | label: 'Edit', 58 | submenu: [ 59 | // {label: 'Undo', role: 'undo' }, 60 | // {label: 'Redo', role: 'redo' }, 61 | {label: 'Cut', accelerator: "CmdOrCtrl+X", role: 'cut' }, 62 | {label: 'Copy', accelerator: "CmdOrCtrl+C", role: 'copy' }, 63 | {label: 'Paste', accelerator: "CmdOrCtrl+V", role:'paste' }, 64 | // { role: 'delete' }, 65 | { type: 'separator' }, 66 | { role: 'selectAll', accelerator: "CmdOrCtrl+A", } 67 | ] 68 | }, 69 | { 70 | label: 'Window', 71 | submenu: [ 72 | { role: 'minimize' }, 73 | { role: 'zoom' }, 74 | { type: 'separator' }, 75 | { role: 'front' }, 76 | { type: 'separator' }, 77 | { role: 'window' } 78 | ] 79 | }, 80 | ]) 81 | } 82 | -------------------------------------------------------------------------------- /src/main/components/Window.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, dialog, SaveDialogOptions } from 'electron' 2 | import * as path from 'path' 3 | import * as isDev from "electron-is-dev" ; 4 | import { addFileExtension } from '../utilities' 5 | import * as fs from 'fs' 6 | 7 | 8 | import { CustomMenu } from './CustomMenu' 9 | 10 | export class Window { 11 | id: number 12 | win: BrowserWindow 13 | filePath: string | undefined 14 | hasUnsavedChanges: boolean 15 | onFocus: any 16 | 17 | constructor(props: any) { 18 | const {id, onFocus} = props 19 | const win = new BrowserWindow({ 20 | width: 1000, 21 | height: 800, 22 | show: false, 23 | webPreferences: { preload: path.join(__dirname, "../../src/main/preload.js") }, 24 | }); 25 | 26 | win.loadURL( 27 | isDev 28 | ? "http://localhost:3000" : 29 | `file://${path.join(__dirname, "../../build/index.html")}` 30 | ); 31 | 32 | if (isDev) { 33 | win.webContents.openDevTools() 34 | } 35 | 36 | this.win = win 37 | this.id = id 38 | this.hasUnsavedChanges = false 39 | this.onFocus = onFocus 40 | this.attachListeners() 41 | } 42 | 43 | attachListeners() { 44 | this.win.on('closed', () => this.win.destroy()) 45 | this.win.on('focus', () => this.onFocus(this.id)) 46 | this.win.once('ready-to-show', () => this.onReady()) 47 | } 48 | 49 | sendSaveFile(saveAs?:boolean) { 50 | this.sendMessage('SAVE_FILE', saveAs) 51 | } 52 | 53 | receiveSaveFile(props: any) { 54 | const {graphData, saveAs} = props 55 | if (!saveAs && this.filePath) { 56 | this.writeFile(this.filePath, graphData) 57 | } else { 58 | dialog.showSaveDialog((this.win, {defaultPath: ''}) as SaveDialogOptions).then(({filePath, canceled}) => { 59 | if (!canceled && filePath) { 60 | const withExtension: string = addFileExtension(filePath, '.ftm') 61 | this.writeFile(withExtension, graphData) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | writeFile(filePath: string, contents: any) { 68 | fs.writeFile(filePath, contents, (err) => { 69 | if (err) { 70 | console.log(err); 71 | return; 72 | } 73 | this.filePath = filePath 74 | this.hasUnsavedChanges = false 75 | this.setTitle() 76 | }); 77 | } 78 | 79 | sendOpenFile(filePath: string, data: any) { 80 | this.win.webContents.on('did-finish-load', () => { 81 | this.filePath = filePath 82 | this.hasUnsavedChanges = false 83 | this.setTitle() 84 | this.sendMessage('OPEN_FILE', data) 85 | }) 86 | } 87 | 88 | sendExportSvg() { 89 | this.sendMessage('EXPORT_SVG'); 90 | } 91 | 92 | receiveExportSvg(data: any) { 93 | dialog.showSaveDialog((this.win, {defaultPath: ''}) as SaveDialogOptions).then(({filePath, canceled}) => { 94 | if (!canceled && filePath) { 95 | const withExtension: string = addFileExtension(filePath, '.svg') 96 | fs.writeFile(withExtension, data, (err) => { 97 | if (err) { 98 | console.log(err); 99 | return; 100 | } 101 | }); 102 | } 103 | }) 104 | } 105 | 106 | setTitle() { 107 | const base = this.filePath ? path.basename(this.filePath) : 'Untitled' 108 | const title = this.hasUnsavedChanges ? `${base}*` : base 109 | this.win.setTitle(title) 110 | } 111 | 112 | sendMessage(type: string, ...contents:any[]) { 113 | this.win.webContents.send(type, ...contents); 114 | } 115 | 116 | onGraphChanged() { 117 | this.hasUnsavedChanges = true 118 | this.setTitle() 119 | } 120 | 121 | onReady() { 122 | this.setTitle() 123 | let locale = app.getLocale(); 124 | locale = (locale === "en_GB" || locale === "en_US") ? "en" : locale; 125 | this.sendMessage('SET_LOCALE', locale); 126 | this.win.show() 127 | } 128 | 129 | destroy() { 130 | this.win.destroy() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CustomMenu' 2 | export * from './Window' 3 | -------------------------------------------------------------------------------- /src/main/electron.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow , app , ipcMain, IpcMessageEvent, Menu, dialog } from 'electron' ; 2 | import * as isDev from "electron-is-dev" ; 3 | import { autoUpdater } from 'electron-updater' 4 | import { Window, CustomMenu } from './components' 5 | import * as fs from 'fs' 6 | 7 | let appInstance: any; 8 | 9 | app.on('ready', function() { 10 | appInstance = new App({}) 11 | }); 12 | 13 | app.on("open-file", function(event, path) { 14 | event.preventDefault() 15 | let fileToOpen = path 16 | 17 | if (process.platform == 'win32' && process.argv.length >= 2) { 18 | fileToOpen = process.argv[1]; 19 | } 20 | if (appInstance && app.isReady()) { 21 | appInstance.openFile(fileToOpen) 22 | } else { 23 | app.on('ready', function() { 24 | appInstance.openFile(fileToOpen) 25 | }); 26 | } 27 | }); 28 | 29 | app.on('window-all-closed', () => { 30 | if (process.platform !== "darwin") { 31 | app.quit(); 32 | } 33 | }); 34 | 35 | class App { 36 | private windows: Window[] 37 | private activeWindow: number 38 | 39 | constructor({ fileToOpen }: any) { 40 | this.onWindowFocus = this.onWindowFocus.bind(this) 41 | const mainWindow = new Window({id: 0, onFocus: this.onWindowFocus}) 42 | if (fileToOpen) { 43 | this.openFile(fileToOpen) 44 | } 45 | const log = require("electron-log") 46 | log.transports.file.level = "debug" 47 | autoUpdater.logger = log 48 | autoUpdater.checkForUpdatesAndNotify(); 49 | 50 | Menu.setApplicationMenu(CustomMenu( 51 | this.sendSaveFile.bind(this), 52 | this.newFile.bind(this), 53 | this.openFileDialog.bind(this), 54 | this.exportSvg.bind(this) 55 | )) 56 | 57 | this.windows = [mainWindow] 58 | this.activeWindow = 0 59 | 60 | this.attachListeners() 61 | } 62 | 63 | attachListeners() { 64 | ipcMain.on('SAVE_FILE_SUCCESS', this.receiveSaveFile.bind(this)) 65 | ipcMain.on('GRAPH_CHANGED', this.onGraphChanged.bind(this)) 66 | ipcMain.on('RECEIVE_EXPORT_SVG', this.receiveExportSvg.bind(this)) 67 | } 68 | 69 | onWindowFocus(id: number) { 70 | this.activeWindow = id 71 | } 72 | 73 | sendSaveFile(saveAs?:boolean) { 74 | this.windows[this.activeWindow].sendSaveFile(saveAs) 75 | } 76 | 77 | receiveSaveFile(event: any, contents: any) { 78 | this.windows[this.activeWindow].receiveSaveFile(contents) 79 | } 80 | 81 | onGraphChanged() { 82 | this.windows[this.activeWindow].onGraphChanged() 83 | } 84 | 85 | exportSvg() { 86 | this.windows[this.activeWindow].sendExportSvg() 87 | } 88 | 89 | receiveExportSvg(event: any, data: any) { 90 | this.windows[this.activeWindow].receiveExportSvg(data) 91 | } 92 | 93 | newFile() { 94 | const newId = this.windows.length 95 | const newWindow = new Window({id: newId, onFocus:this.onWindowFocus}) 96 | this.windows.push(newWindow) 97 | this.activeWindow = newId 98 | return newWindow 99 | } 100 | 101 | openFileDialog() { 102 | dialog.showOpenDialog({filters:[{name: '*',extensions:['ftm','vis']}]}).then(({filePaths}) => { 103 | if (filePaths && filePaths.length > 0) { 104 | filePaths.forEach(filePath => { 105 | this.openFile(filePath) 106 | }) 107 | } 108 | }); 109 | } 110 | 111 | openFile(filePath: string) { 112 | fs.readFile(filePath, 'utf-8', (err, data) => { 113 | if (err){ 114 | console.log("An error ocurred reading the file :" + err.message); 115 | return; 116 | } 117 | const newWindow = this.newFile() 118 | newWindow.sendOpenFile(filePath, data) 119 | }); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron') 2 | 3 | contextBridge.exposeInMainWorld("electron", { 4 | ipcRenderer: { 5 | sendSaveFileSuccess: (payload) => ipcRenderer.send('SAVE_FILE_SUCCESS', payload), 6 | sendReceiveExportSvg: (payload) => ipcRenderer.send('RECEIVE_EXPORT_SVG', payload), 7 | sendGraphChanged: () => ipcRenderer.send('GRAPH_CHANGED'), 8 | onSaveFile: (callback) => ipcRenderer.on('SAVE_FILE', callback), 9 | onOpenFile: (callback) => ipcRenderer.on('OPEN_FILE', callback), 10 | onExportSvg: (callback) => ipcRenderer.on('EXPORT_SVG', callback), 11 | onSetLocale: (callback) => ipcRenderer.on('SET_LOCALE', callback), 12 | }, 13 | }); -------------------------------------------------------------------------------- /src/main/utilities.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | export const addFileExtension = (filePath: string, extension: string) => { 4 | const currExtension = path.extname(filePath) 5 | if (currExtension === '') { 6 | return filePath + extension 7 | } else { 8 | return currExtension === extension ? filePath : filePath.replace(currExtension, extension) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/renderer/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { EntityManager, exportSvg, GraphConfig, GraphLayout, GraphLogo, Viewport, NetworkDiagram } from '@alephdata/react-ftm'; 3 | import logoBase64 from './static/logoBase64'; 4 | 5 | const logo = new GraphLogo({ 6 | text: "Data Desktop", 7 | image: logoBase64, 8 | }); 9 | 10 | const config = new GraphConfig({ editorTheme: "dark", toolbarPosition: 'top', logo }); 11 | 12 | interface IAppProps { 13 | ipcRenderer: any 14 | } 15 | 16 | interface IAppState { 17 | layout: GraphLayout, 18 | locale?: string, 19 | viewport: Viewport 20 | } 21 | 22 | export default class App extends React.Component { 23 | entityManager: EntityManager 24 | saveTimeout: any 25 | svgRef: React.RefObject 26 | 27 | constructor(props: any) { 28 | super(props) 29 | this.attachListeners() 30 | 31 | const storedGraphData = localStorage.getItem('storedGraphData') 32 | 33 | if (storedGraphData && !props.ipcRenderer) { 34 | const parsed = JSON.parse(storedGraphData) 35 | this.state = { 36 | // @ts-ignore 37 | layout: GraphLayout.fromJSON(config, parsed.layout), 38 | viewport: Viewport.fromJSON(config, parsed.viewport), 39 | } 40 | this.entityManager = EntityManager.fromJSON({}, parsed.entities); 41 | } else { 42 | this.state = { 43 | // @ts-ignore 44 | layout: new GraphLayout(config), 45 | viewport: new Viewport(config) 46 | } 47 | this.entityManager = new EntityManager(); 48 | } 49 | 50 | this.updateLayout = this.updateLayout.bind(this); 51 | this.updateViewport = this.updateViewport.bind(this); 52 | this.saveFile = this.saveFile.bind(this); 53 | this.openFile = this.openFile.bind(this); 54 | this.exportSvg = this.exportSvg.bind(this); 55 | 56 | this.svgRef = React.createRef(); 57 | } 58 | 59 | attachListeners() { 60 | const { ipcRenderer } = this.props; 61 | 62 | if (ipcRenderer) { 63 | ipcRenderer.onSaveFile((event: any, saveAs: boolean) => this.saveFile(saveAs)); 64 | ipcRenderer.onOpenFile((event: any, data: any) => this.openFile(data)); 65 | ipcRenderer.onExportSvg((event: any) => this.exportSvg()); 66 | ipcRenderer.onSetLocale((event: any, locale: any) => this.setLocale(locale)); 67 | } 68 | } 69 | 70 | saveFile(saveAs: boolean) { 71 | const { ipcRenderer } = this.props; 72 | const { layout, viewport } = this.state; 73 | const graphData = JSON.stringify({ 74 | entities: this.entityManager.toJSON(), 75 | layout: layout.toJSON(), 76 | viewport: viewport.toJSON() 77 | }) 78 | 79 | ipcRenderer.sendSaveFileSuccess({ graphData, saveAs }); 80 | } 81 | 82 | openFile(data: any) { 83 | const parsed = JSON.parse(data); 84 | // supports legacy .vis files with layout.entities 85 | const { entities, ...layout } = parsed.layout; 86 | this.entityManager = EntityManager.fromJSON({}, entities || parsed.entities); 87 | this.setState({ 88 | // @ts-ignore 89 | layout: GraphLayout.fromJSON(config, layout), 90 | viewport: Viewport.fromJSON(config, parsed.viewport), 91 | }) 92 | } 93 | 94 | setLocale(locale: string) { 95 | this.setState({ locale }); 96 | } 97 | 98 | exportSvg() { 99 | const { ipcRenderer } = this.props; 100 | const { layout, viewport } = this.state; 101 | 102 | const data = exportSvg(layout, viewport, this.svgRef.current); 103 | ipcRenderer.sendReceiveExportSvg(data); 104 | } 105 | 106 | updateLayout(layout: GraphLayout, historyModified: boolean = false) { 107 | const { ipcRenderer } = this.props; 108 | 109 | this.setState({'layout': layout}) 110 | 111 | if (historyModified) { 112 | if (ipcRenderer) { 113 | ipcRenderer.sendGraphChanged(); 114 | } else { 115 | this.saveToLocalStorage({ layout }); 116 | } 117 | } 118 | } 119 | 120 | updateViewport(viewport: Viewport) { 121 | const { ipcRenderer } = this.props; 122 | this.setState({'viewport': viewport}) 123 | if (!ipcRenderer) { 124 | this.saveToLocalStorage({ viewport }); 125 | } 126 | } 127 | 128 | saveToLocalStorage({ layout, viewport }: { layout?: GraphLayout, viewport?: Viewport }) { 129 | const graphData = JSON.stringify({ 130 | entities: this.entityManager.toJSON(), 131 | layout: layout ? layout.toJSON() : this.state.layout.toJSON(), 132 | viewport: viewport ? viewport.toJSON() : this.state.viewport.toJSON() 133 | }) 134 | localStorage.setItem('storedGraphData', graphData) 135 | } 136 | 137 | render() { 138 | const { layout, locale, viewport } = this.state; 139 | 140 | return ( 141 | 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/renderer/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then(response => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | navigator.serviceWorker.ready.then(registration => { 121 | registration.unregister().then(() => { 122 | window.location.reload(); 123 | }); 124 | }); 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config); 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.' 133 | ); 134 | }); 135 | } 136 | 137 | export function unregister() { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready.then(registration => { 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/renderer/static/logoBase64.ts: -------------------------------------------------------------------------------- 1 | const logo = "data:image/png;base64,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"; 2 | 3 | export default logo; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /types/electron-reload.d.ts: -------------------------------------------------------------------------------- 1 | export = main; 2 | declare const main: any; 3 | --------------------------------------------------------------------------------