├── requirements.txt
├── .gitignore
├── server-log-preload.js
├── did-fail-load.html
├── download-python.sh
├── scripts
└── notarize.js
├── test
└── spec.mjs
├── server-log.html
├── preload.js
├── .github
└── workflows
│ ├── release.yml
│ └── test.yml
├── package.json
├── README.md
├── progress.html
├── loading.html
└── main.js
/requirements.txt:
--------------------------------------------------------------------------------
1 | datasette
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | *.tgz
4 | dist
5 | *.egg-info
6 | *.tar.gz
7 | python
8 | test-videos
--------------------------------------------------------------------------------
/server-log-preload.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer, contextBridge } = require("electron");
2 |
3 | contextBridge.exposeInMainWorld("onServerLog", (callback) => {
4 | ipcRenderer.on("serverLog", callback);
5 | });
6 |
--------------------------------------------------------------------------------
/did-fail-load.html:
--------------------------------------------------------------------------------
1 |
2 |
Load Failed
3 |
17 |
18 |
19 |
20 |
Failed to load page
21 |
Quitting and restarting Datasette might fix this
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/download-python.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | release_date="20210724"
3 | filename="cpython-3.9.6-x86_64-apple-darwin-install_only-20210724T1424.tar.gz"
4 |
5 | standalone_python="python/"
6 |
7 | if [ ! -d "$standalone_python" ]; then
8 | wget https://github.com/indygreg/python-build-standalone/releases/download/${release_date}/${filename}
9 | tar -xzvf ${filename}
10 | rm -rf ${filename}
11 | # Now delete the test/ folder, saving about 23MB of disk space
12 | rm -rf python/lib/python3.9/test
13 | fi
14 |
--------------------------------------------------------------------------------
/scripts/notarize.js:
--------------------------------------------------------------------------------
1 | /* Based on https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/ */
2 |
3 | const { notarize } = require("electron-notarize");
4 |
5 | exports.default = async function notarizing(context) {
6 | const { electronPlatformName, appOutDir } = context;
7 | if (electronPlatformName !== "darwin") {
8 | return;
9 | }
10 |
11 | const appName = context.packager.appInfo.productFilename;
12 |
13 | return await notarize({
14 | appBundleId: "io.datasette.app",
15 | appPath: `${appOutDir}/${appName}.app`,
16 | appleId: process.env.APPLEID,
17 | appleIdPassword: process.env.APPLEIDPASS,
18 | });
19 | };
20 |
--------------------------------------------------------------------------------
/test/spec.mjs:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import { _electron } from 'playwright';
3 |
4 | async function sleep(ms) {
5 | return new Promise(resolve => setTimeout(resolve, ms));
6 | }
7 |
8 | test('App launches and quits', async () => {
9 | test.setTimeout(0);
10 | const app = await _electron.launch({
11 | args: ['main.js'],
12 | recordVideo: {dir: 'test-videos'}
13 | });
14 | const window = await app.firstWindow();
15 | await expect(await window.title()).toContain('Loading');
16 | await window.waitForSelector('#run-sql-link', {
17 | timeout: 90000
18 | });
19 | await sleep(1000);
20 | await app.close();
21 | });
22 |
--------------------------------------------------------------------------------
/server-log.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | datasette server log
5 |
6 |
7 |
8 | datasette server log
9 |
10 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/preload.js:
--------------------------------------------------------------------------------
1 | const { ipcRenderer, contextBridge } = require("electron");
2 | const path = require("path");
3 | contextBridge.exposeInMainWorld("datasetteApp", {
4 | importCsv: (database) => {
5 | ipcRenderer.send("import-csv", database);
6 | },
7 | importCsvFromUrl: (url, link) => {
8 | var tableName = link ? link.dataset.tablename : "";
9 | ipcRenderer.send("import-csv-from-url", { url, tableName });
10 | if (link) {
11 | link.style.opacity = 0.5;
12 | link.innerHTML = `Importing ${link.dataset.name}…`;
13 | }
14 | },
15 | installPlugin: (plugin, link) => {
16 | ipcRenderer.send("install-plugin", plugin);
17 | if (link) {
18 | link.style.opacity = 0.5;
19 | link.setAttribute("href", "#");
20 | link.innerHTML = `Installing ${plugin}…`;
21 | }
22 | },
23 | uninstallPlugin: (plugin, link) => {
24 | ipcRenderer.send("uninstall-plugin", plugin);
25 | if (link) {
26 | link.style.opacity = 0.5;
27 | link.setAttribute("href", "#");
28 | link.innerHTML = `Uninstalling ${plugin}…`;
29 | }
30 | },
31 | onServerLog: (callback) => {
32 | ipcRenderer.on("serverLog", callback);
33 | },
34 | onProcessLog: (callback) => {
35 | ipcRenderer.on("processLog", callback);
36 | },
37 | venvPath: path.join(process.env.HOME, ".datasette-app", "venv"),
38 | });
39 | ipcRenderer.on("csv-imported", () => {
40 | location.reload();
41 | });
42 | ipcRenderer.on("plugin-installed", () => {
43 | location.reload();
44 | });
45 | ipcRenderer.on("plugin-uninstalled", () => {
46 | location.reload();
47 | });
48 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | release:
9 | runs-on: macos-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Configure Node caching
13 | uses: actions/cache@v2
14 | env:
15 | cache-name: cache-node-modules
16 | with:
17 | path: ~/.npm
18 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
19 | restore-keys: |
20 | ${{ runner.os }}-build-${{ env.cache-name }}-
21 | ${{ runner.os }}-build-
22 | ${{ runner.os }}-
23 | - uses: actions/cache@v2
24 | name: Configure pip caching
25 | with:
26 | path: ~/.cache/pip
27 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
28 | restore-keys: |
29 | ${{ runner.os }}-pip-
30 | - name: Install Node dependencies
31 | run: npm install
32 | - name: Download standalone Python
33 | run: |
34 | ./download-python.sh
35 | - name: Run tests
36 | run: npm test
37 | timeout-minutes: 5
38 | - name: Build distribution
39 | env:
40 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
41 | CSC_LINK: ${{ secrets.CSC_LINK }}
42 | APPLEID: ${{ secrets.APPLEID }}
43 | APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
44 | run: npm run dist
45 | - name: Create zip file
46 | run: |
47 | cd dist/mac
48 | ditto -c -k --keepParent Datasette.app Datasette-mac.app.zip
49 | - name: Upload release attachment
50 | uses: actions/github-script@v4
51 | with:
52 | script: |
53 | const fs = require('fs');
54 | const tag = context.ref.replace("refs/tags/", "");
55 | console.log("tag = ", tag);
56 | // Get release for this tag
57 | const release = await github.repos.getReleaseByTag({
58 | owner: context.repo.owner,
59 | repo: context.repo.repo,
60 | tag
61 | });
62 | // Upload the release asset
63 | await github.repos.uploadReleaseAsset({
64 | owner: context.repo.owner,
65 | repo: context.repo.repo,
66 | release_id: release.data.id,
67 | name: "Datasette.app.zip",
68 | data: fs.readFileSync("dist/mac/Datasette-mac.app.zip")
69 | });
70 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on: push
4 |
5 | jobs:
6 | test:
7 | runs-on: macos-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Configure Node caching
11 | uses: actions/cache@v4
12 | env:
13 | cache-name: cache-node-modules
14 | with:
15 | path: ~/.npm
16 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
17 | restore-keys: |
18 | ${{ runner.os }}-build-${{ env.cache-name }}-
19 | ${{ runner.os }}-build-
20 | ${{ runner.os }}-
21 | - uses: actions/cache@v4
22 | name: Configure pip caching
23 | with:
24 | path: ~/.cache/pip
25 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
26 | restore-keys: |
27 | ${{ runner.os }}-pip-
28 | - name: Install Node dependencies
29 | run: npm install
30 | - name: Download standalone Python
31 | run: |
32 | ./download-python.sh
33 | - name: Run tests
34 | id: firstTest
35 | run: npm test
36 | timeout-minutes: 5
37 | continue-on-error: true
38 | - name: Retry tests once if they fail
39 | if: steps.firstTest.outcome == 'failure'
40 | run: npm test
41 | timeout-minutes: 5
42 | - name: Upload test videos
43 | uses: actions/upload-artifact@v3
44 | with:
45 | name: test-videos
46 | path: test-videos/
47 | - name: Build distribution
48 | if: github.ref == 'refs/heads/main'
49 | env:
50 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
51 | CSC_LINK: ${{ secrets.CSC_LINK }}
52 | APPLEID: ${{ secrets.APPLEID }}
53 | APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
54 | run: npm run dist
55 | - name: Create zip file
56 | if: github.ref == 'refs/heads/main'
57 | run: |
58 | cd dist/mac
59 | ditto -c -k --keepParent Datasette.app Datasette.app.zip
60 | - name: And a README (to work around GitHub double-zips)
61 | if: github.ref == 'refs/heads/main'
62 | run: |
63 | echo "More information: https://datasette.io" > dist/mac/README.txt
64 | - name: Upload artifact
65 | if: github.ref == 'refs/heads/main'
66 | uses: actions/upload-artifact@v4
67 | with:
68 | name: Datasette-macOS
69 | path: |
70 | dist/mac/Datasette.app.zip
71 | dist/mac/README.txt
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "datasette-app",
3 | "version": "0.2.3",
4 | "productName": "Datasette",
5 | "description": "An Electron app that wraps Datasette",
6 | "main": "main.js",
7 | "scripts": {
8 | "start": "DEBUGMENU=1 electron --trace-warnings --inspect=5858 .",
9 | "test": "playwright test",
10 | "pack": "electron-builder --dir",
11 | "dist": "electron-builder --publish never"
12 | },
13 | "build": {
14 | "appId": "io.datasette.app",
15 | "files": [
16 | "*.js",
17 | "*.html",
18 | "preload.js",
19 | "**/*",
20 | "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
21 | "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
22 | "!**/node_modules/*.d.ts",
23 | "!**/node_modules/.bin",
24 | "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}",
25 | "!.editorconfig",
26 | "!**/._*",
27 | "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}",
28 | "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}",
29 | "!**/{appveyor.yml,.travis.yml,circle.yml}",
30 | "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}"
31 | ],
32 | "mac": {
33 | "category": "public.app-category.developer-tools",
34 | "extendInfo": {
35 | "CFBundleDocumentTypes": [
36 | {
37 | "CFBundleTypeExtensions": [
38 | "csv",
39 | "tsv",
40 | "db"
41 | ],
42 | "LSHandlerRank": "Alternate"
43 | }
44 | ]
45 | },
46 | "hardenedRuntime": true,
47 | "gatekeeperAssess": false,
48 | "entitlements": "build/entitlements.mac.plist",
49 | "entitlementsInherit": "build/entitlements.mac.plist",
50 | "binaries": [
51 | "./dist/mac/Datasette.app/Contents/Resources/python/bin/python3.9",
52 | "./dist/mac/Datasette.app/Contents/Resources/python/lib/python3.9/lib-dynload/xxlimited.cpython-39-darwin.so",
53 | "./dist/mac/Datasette.app/Contents/Resources/python/lib/python3.9/lib-dynload/_testcapi.cpython-39-darwin.so"
54 | ]
55 | },
56 | "afterSign": "scripts/notarize.js",
57 | "extraResources": [
58 | {
59 | "from": "python",
60 | "to": "python",
61 | "filter": [
62 | "**/*"
63 | ]
64 | }
65 | ]
66 | },
67 | "repository": "https://github.com/simonw/datasette-app",
68 | "keywords": [
69 | "Electron"
70 | ],
71 | "author": "Simon Willison",
72 | "license": "Apache-2.0",
73 | "devDependencies": {
74 | "@playwright/test": "^1.25.2",
75 | "electron": "^20.1.3",
76 | "electron-builder": "^23.3.3",
77 | "electron-notarize": "^1.2.1",
78 | "playwright": "^1.25.2"
79 | },
80 | "dependencies": {
81 | "electron-prompt": "^1.7.0",
82 | "electron-request": "^1.8.2",
83 | "portfinder": "^1.0.32",
84 | "update-electron-app": "^2.0.1"
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Datasette Desktop
2 |
3 | A macOS desktop application that wraps [Datasette](https://datasette.io/). See [Building a desktop application for Datasette](https://simonwillison.net/2021/Aug/30/datasette-app/) for background on this project.
4 |
5 | ## Installation
6 |
7 | Grab the latest release from [the releases page](https://github.com/simonw/datasette-app/releases). Download `Datasette.app.zip`, uncompress it and drag `Datasette.app` to your `/Applications` folder - then double-click the icon.
8 |
9 | The first time you launch the app it will install the latest version of Datasette, which could take a little while. Subsequent application launches will be a lot quicker.
10 |
11 | ## Application features
12 |
13 | - Includes a full copy of Python which stays separate from any other Python versions you may have installed
14 | - Installs the latest Datasette release the first time it runs
15 | - The application can open existing SQLite database files or read CSV files into an in-memory database
16 | - It can also create a new, empty SQLite database file and create tables in that database by importing CSV data
17 | - By default the server only accepts connections from your computer, but you can use "File -> Access Control -> Anyone on my networks" to make it visible to other computers on your network (or devices on your [Tailscale](https://tailscale.com/) network).
18 | - Datasette plugins can be installed using the "Install Plugin" menu item
19 |
20 | ## How it works
21 |
22 | The app consists of two parts: the Electron app, and a custom Datasette plugin called [datasette-app-support](https://github.com/simonw/datasette-app-support).
23 |
24 | You can install a development version of the app like so:
25 |
26 | # Clone the repo
27 | git clone https://github.com/simonw/datasette-app
28 | cd datasette-app
29 |
30 | # Download standalone Python
31 | ./download-python.sh
32 |
33 | # Install Electron dependencies and start it running:
34 | npm install
35 | npm start
36 |
37 | When the app first starts up it will create a Python virtual environment in `~/.datasette-app/venv` and install both Datasette and the `datasette-app-support` plugin into that environment.
38 |
39 | To run the Electron tests:
40 |
41 | npm test
42 |
43 | The Electron tests may leave a `datasette` process running. You can find the process ID for this using:
44 |
45 | ps aux | grep xyz
46 |
47 | Then use `kill PROCESS_ID` to terminate it.
48 |
49 | 
50 |
51 | ## How to develop plugins
52 |
53 | You can develop new Datasette plugins directly against your installation of Datasette Desktop. The [Writing Plugins](https://docs.datasette.io/en/stable/writing_plugins.html) documentation mostly applies as-is, but the one extra thing you will need to do is to install an editable version of your plugin directly into the virtual environment used by Datasette Desktop.
54 |
55 | To do this, first create a new plugin in a folder called `datasette-your-new-plugin` with a `setup.py`, as described in the plugin documentation. The easiest way to do that is using the [datasette-plugin cookiecutter template](https://github.com/simonw/datasette-plugin).
56 |
57 | Then `cd` into that directory and run the following:
58 |
59 | ~/.datasette-app/venv/bin/pip install -e .
60 |
61 | This will install the plugin into your Datasette Desktop environment, such that any edits you make to the files in that directory will be picked up the next time the embedded Datasette server is restarted.
62 |
63 | You can restart the server either by quitting and restarting the Datasette Desktop application, or by enabling the Debug menu ("Datasette -> About Datasette -> Enable Debug Menu") and then using "Debug -> Restart Server".
64 |
65 | ## Release process
66 |
67 | To ship a new release, increment the version number in `package.json` and then [create a new release](https://github.com/simonw/datasette-app/releases/new) with a matching tag.
68 |
69 | Then [run a deploy](https://github.com/simonw/datasette.io/actions/workflows/deploy.yml) of [datasette.io](https://datasette.io/) to update the latest release link that is displayed on the [datasette.io/desktop](https://datasette.io/desktop) page.
70 |
--------------------------------------------------------------------------------
/progress.html:
--------------------------------------------------------------------------------
1 |
2 | Progress
3 |
34 |
35 |
36 |
37 | Reticulating splines...
38 |
41 |
42 |
43 |
95 |
96 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------
/loading.html:
--------------------------------------------------------------------------------
1 |
2 | Loading...
3 |
26 |
27 |
28 |
29 |
81 |
82 |
83 |
84 |
85 |
86 |
116 |
117 |
118 |
119 |
--------------------------------------------------------------------------------
/main.js:
--------------------------------------------------------------------------------
1 | const {
2 | app,
3 | clipboard,
4 | Menu,
5 | BrowserWindow,
6 | dialog,
7 | shell,
8 | ipcMain,
9 | } = require("electron");
10 | const EventEmitter = require("events");
11 | const crypto = require("crypto");
12 | const request = require("electron-request");
13 | const path = require("path");
14 | const os = require("os");
15 | const cp = require("child_process");
16 | const portfinder = require("portfinder");
17 | const prompt = require("electron-prompt");
18 | const fs = require("fs");
19 | const { unlink } = require("fs/promises");
20 | const util = require("util");
21 |
22 | const execFile = util.promisify(cp.execFile);
23 | const mkdir = util.promisify(fs.mkdir);
24 |
25 | require("update-electron-app")({
26 | updateInterval: "1 hour",
27 | });
28 |
29 | const RANDOM_SECRET = crypto.randomBytes(32).toString("hex");
30 |
31 | // 'SQLite format 3\0':
32 | const SQLITE_HEADER = Buffer.from("53514c69746520666f726d6174203300", "hex");
33 |
34 | const minPackageVersions = {
35 | datasette: "0.59",
36 | "datasette-app-support": "0.11.8",
37 | "datasette-vega": "0.6.2",
38 | "datasette-cluster-map": "0.17.1",
39 | "datasette-pretty-json": "0.2.1",
40 | "datasette-edit-schema": "0.4",
41 | "datasette-configure-fts": "1.1",
42 | "datasette-leaflet": "0.2.2",
43 | };
44 |
45 | let enableDebugMenu = !!process.env.DEBUGMENU;
46 |
47 | function configureWindow(window) {
48 | window.webContents.on("will-navigate", function (event, reqUrl) {
49 | // Links to external sites should open in system browser
50 | let requestedHost = new URL(reqUrl).host;
51 | let currentHost = new URL(window.webContents.getURL()).host;
52 | if (requestedHost && requestedHost != currentHost) {
53 | event.preventDefault();
54 | shell.openExternal(reqUrl);
55 | }
56 | });
57 | window.webContents.on("did-fail-load", (event) => {
58 | window.loadFile("did-fail-load.html");
59 | });
60 | window.webContents.on("did-navigate", (event, reqUrl) => {
61 | // Update back/forward button enable status
62 | let menu = Menu.getApplicationMenu();
63 | if (!menu) {
64 | return;
65 | }
66 | let backItem = menu.getMenuItemById("back-item");
67 | let forwardItem = menu.getMenuItemById("forward-item");
68 | if (backItem) {
69 | backItem.enabled = window.webContents.canGoBack();
70 | }
71 | if (forwardItem) {
72 | forwardItem.enabled = window.webContents.canGoForward();
73 | }
74 | });
75 | }
76 |
77 | class DatasetteServer {
78 | constructor(app, port) {
79 | this.app = app;
80 | this.port = port;
81 | this.process = null;
82 | this.apiToken = crypto.randomBytes(32).toString("hex");
83 | this.logEmitter = new EventEmitter();
84 | this.cappedServerLog = [];
85 | this.cappedProcessLog = [];
86 | this.accessControl = "localhost";
87 | this.cap = 1000;
88 | }
89 | on(event, listener) {
90 | this.logEmitter.on(event, listener);
91 | }
92 | async openFile(filepath) {
93 | const first16 = await firstBytes(filepath, 16);
94 | let endpoint;
95 | let errorMessage;
96 | if (first16.equals(SQLITE_HEADER)) {
97 | endpoint = "/-/open-database-file";
98 | errorMessage = "Error opening database file";
99 | } else {
100 | endpoint = "/-/open-csv-file";
101 | errorMessage = "Error opening CSV file";
102 | }
103 | const response = await this.apiRequest(endpoint, {
104 | path: filepath,
105 | });
106 | const responseJson = await response.json();
107 | if (!responseJson.ok) {
108 | console.log(responseJson);
109 | dialog.showMessageBox({
110 | type: "error",
111 | message: errorMessage,
112 | detail: responseJson.error,
113 | });
114 | } else {
115 | setTimeout(() => {
116 | this.openPath(responseJson.path);
117 | });
118 | }
119 | }
120 | async about() {
121 | const response = await request(
122 | `http://localhost:${this.port}/-/versions.json`
123 | );
124 | const data = await response.json();
125 | return [
126 | "An open source multi-tool for exploring and publishing data",
127 | "",
128 | `Datasette: ${data.datasette.version}`,
129 | `Python: ${data.python.version}`,
130 | `SQLite: ${data.sqlite.version}`,
131 | ].join("\n");
132 | }
133 | async setAccessControl(accessControl) {
134 | if (accessControl == this.accessControl) {
135 | return;
136 | }
137 | this.accessControl = accessControl;
138 | await this.startOrRestart();
139 | }
140 | serverLog(message, type) {
141 | if (!message) {
142 | return;
143 | }
144 | type ||= "stdout";
145 | const item = {
146 | message: message.replace("INFO: ", ""),
147 | type,
148 | ts: new Date(),
149 | };
150 | this.cappedServerLog.push(item);
151 | this.logEmitter.emit("serverLog", item);
152 | this.cappedServerLog = this.cappedServerLog.slice(-this.cap);
153 | }
154 | processLog(item) {
155 | this.cappedProcessLog.push(item);
156 | this.logEmitter.emit("processLog", item);
157 | this.cappedProcessLog = this.cappedProcessLog.slice(-this.cap);
158 | }
159 | serverArgs() {
160 | const args = [
161 | "--port",
162 | this.port,
163 | "--version-note",
164 | "xyz-for-datasette-app",
165 | "--setting",
166 | "sql_time_limit_ms",
167 | "10000",
168 | "--setting",
169 | "max_returned_rows",
170 | "2000",
171 | "--setting",
172 | "facet_time_limit_ms",
173 | "3000",
174 | "--setting",
175 | "max_csv_mb",
176 | "0",
177 | ];
178 | if (this.accessControl == "network") {
179 | args.push("--host", "0.0.0.0");
180 | }
181 | return args;
182 | }
183 | serverEnv() {
184 | return {
185 | DATASETTE_API_TOKEN: this.apiToken,
186 | DATASETTE_SECRET: RANDOM_SECRET,
187 | DATASETTE_DEFAULT_PLUGINS: Object.keys(minPackageVersions).join(" "),
188 | };
189 | }
190 | async startOrRestart() {
191 | const venv_dir = await this.ensureVenv();
192 | await this.ensurePackagesInstalled();
193 | const datasette_bin = path.join(venv_dir, "bin", "datasette");
194 | let backupPath = null;
195 | if (this.process) {
196 | // Dump temporary to restore later
197 | backupPath = path.join(
198 | app.getPath("temp"),
199 | `backup-${crypto.randomBytes(8).toString("hex")}.db`
200 | );
201 | await this.apiRequest("/-/dump-temporary-to-file", { path: backupPath });
202 | this.process.kill();
203 | }
204 | return new Promise((resolve, reject) => {
205 | let process;
206 | try {
207 | process = cp.spawn(datasette_bin, this.serverArgs(), {
208 | env: this.serverEnv(),
209 | });
210 | } catch (e) {
211 | reject(e);
212 | }
213 | this.process = process;
214 | process.stderr.on("data", async (data) => {
215 | if (/Uvicorn running/.test(data)) {
216 | serverHasStarted = true;
217 | if (backupPath) {
218 | await this.apiRequest("/-/restore-temporary-from-file", {
219 | path: backupPath,
220 | });
221 | await unlink(backupPath);
222 | }
223 | resolve(`http://localhost:${this.port}/`);
224 | }
225 | for (const line of data.toString().split("\n")) {
226 | this.serverLog(line, "stderr");
227 | }
228 | });
229 | process.stdout.on("data", (data) => {
230 | for (const line of data.toString().split("\n")) {
231 | this.serverLog(line);
232 | }
233 | });
234 | process.on("error", (err) => {
235 | console.error("Failed to start datasette", err);
236 | this.app.quit();
237 | reject();
238 | });
239 | });
240 | }
241 |
242 | shutdown() {
243 | this.process.kill();
244 | }
245 |
246 | async apiRequest(path, body) {
247 | return await request(`http://localhost:${this.port}${path}`, {
248 | method: "POST",
249 | body: JSON.stringify(body),
250 | headers: {
251 | Authorization: `Bearer ${this.apiToken}`,
252 | },
253 | });
254 | }
255 |
256 | async execCommand(command, args) {
257 | return new Promise((resolve, reject) => {
258 | // Use spawn() not execFile() so we can tail stdout/stderr
259 | console.log(command, args);
260 | // I tried process.hrtime() here but consistently got a
261 | // "Cannot access 'process' before initialization" error
262 | const start = new Date().valueOf(); // millisecond timestamp
263 | const process = cp.spawn(command, args);
264 | const collectedErr = [];
265 | this.processLog({
266 | type: "start",
267 | command,
268 | args,
269 | });
270 | process.stderr.on("data", async (data) => {
271 | for (const line of data.toString().split("\n")) {
272 | this.processLog({
273 | type: "stderr",
274 | command,
275 | args,
276 | stderr: line.trim(),
277 | });
278 | collectedErr.push(line.trim());
279 | }
280 | });
281 | process.stdout.on("data", (data) => {
282 | for (const line of data.toString().split("\n")) {
283 | this.processLog({
284 | type: "stdout",
285 | command,
286 | args,
287 | stdout: line.trim(),
288 | });
289 | }
290 | });
291 | process.on("error", (err) => {
292 | this.processLog({
293 | type: "error",
294 | command,
295 | args,
296 | error: err.toString(),
297 | });
298 | reject(err);
299 | });
300 | process.on("exit", (err) => {
301 | let duration_ms = new Date().valueOf() - start;
302 | this.processLog({
303 | type: "end",
304 | command,
305 | args,
306 | duration: duration_ms,
307 | });
308 | if (process.exitCode == 0) {
309 | resolve(process);
310 | } else {
311 | reject(collectedErr.join("\n"));
312 | }
313 | });
314 | });
315 | }
316 |
317 | async installPlugin(plugin) {
318 | const pip_binary = path.join(
319 | process.env.HOME,
320 | ".datasette-app",
321 | "venv",
322 | "bin",
323 | "pip"
324 | );
325 | await this.execCommand(pip_binary, [
326 | "install",
327 | plugin,
328 | "--disable-pip-version-check",
329 | ]);
330 | }
331 |
332 | async uninstallPlugin(plugin) {
333 | const pip_binary = path.join(
334 | process.env.HOME,
335 | ".datasette-app",
336 | "venv",
337 | "bin",
338 | "pip"
339 | );
340 | await this.execCommand(pip_binary, [
341 | "uninstall",
342 | plugin,
343 | "--disable-pip-version-check",
344 | "-y",
345 | ]);
346 | }
347 |
348 | async packageVersions() {
349 | const venv_dir = await this.ensureVenv();
350 | const pip_path = path.join(venv_dir, "bin", "pip");
351 | const versionsProcess = await execFile(pip_path, [
352 | "list",
353 | "--format",
354 | "json",
355 | ]);
356 | const versions = {};
357 | for (const item of JSON.parse(versionsProcess.stdout)) {
358 | versions[item.name] = item.version;
359 | }
360 | return versions;
361 | }
362 |
363 | async ensureVenv() {
364 | const datasette_app_dir = path.join(process.env.HOME, ".datasette-app");
365 | const venv_dir = path.join(datasette_app_dir, "venv");
366 | if (!fs.existsSync(datasette_app_dir)) {
367 | await mkdir(datasette_app_dir);
368 | }
369 | let shouldCreateVenv = true;
370 | if (fs.existsSync(venv_dir)) {
371 | // Check Python interpreter still works, using
372 | // ~/.datasette-app/venv/bin/python3.9 --version
373 | // See https://github.com/simonw/datasette-app/issues/89
374 | const venv_python = path.join(venv_dir, "bin", "python3.9");
375 | try {
376 | await this.execCommand(venv_python, ["--version"]);
377 | shouldCreateVenv = false;
378 | } catch (e) {
379 | fs.rmdirSync(venv_dir, { recursive: true });
380 | }
381 | }
382 | if (shouldCreateVenv) {
383 | await this.execCommand(findPython(), ["-m", "venv", venv_dir]);
384 | }
385 | return venv_dir;
386 | }
387 |
388 | async ensurePackagesInstalled() {
389 | const venv_dir = await this.ensureVenv();
390 | // Anything need installing or upgrading?
391 | const needsInstall = [];
392 | for (const [name, requiredVersion] of Object.entries(minPackageVersions)) {
393 | needsInstall.push(`${name}>=${requiredVersion}`);
394 | }
395 | const pip_path = path.join(venv_dir, "bin", "pip");
396 | try {
397 | await this.execCommand(
398 | pip_path,
399 | ["install"].concat(needsInstall).concat(["--disable-pip-version-check"])
400 | );
401 | } catch (e) {
402 | dialog.showMessageBox({
403 | type: "error",
404 | message: "Error running pip",
405 | detail: e.toString(),
406 | });
407 | }
408 | await new Promise((resolve) => setTimeout(resolve, 500));
409 | }
410 |
411 | openPath(path, opts) {
412 | path = path || "/";
413 | opts = opts || {};
414 | const loadUrlOpts = {
415 | extraHeaders: `authorization: Bearer ${this.apiToken}`,
416 | method: "POST",
417 | postData: [
418 | {
419 | type: "rawData",
420 | bytes: Buffer.from(JSON.stringify({ redirect: path })),
421 | },
422 | ],
423 | };
424 | if (
425 | BrowserWindow.getAllWindows().length == 1 &&
426 | (opts.forceMainWindow ||
427 | new URL(BrowserWindow.getAllWindows()[0].webContents.getURL())
428 | .pathname == "/")
429 | ) {
430 | // Re-use the single existing window
431 | BrowserWindow.getAllWindows()[0].webContents.loadURL(
432 | `http://localhost:${this.port}/-/auth-app-user`,
433 | loadUrlOpts
434 | );
435 | } else {
436 | // Open a new window
437 | let newWindow = new BrowserWindow({
438 | ...windowOpts(),
439 | ...{ show: false },
440 | });
441 | newWindow.loadURL(
442 | `http://localhost:${this.port}/-/auth-app-user`,
443 | loadUrlOpts
444 | );
445 | newWindow.once("ready-to-show", () => {
446 | newWindow.show();
447 | });
448 | configureWindow(newWindow);
449 | }
450 | }
451 | }
452 |
453 | function findPython() {
454 | const possibilities = [
455 | // In packaged app
456 | path.join(process.resourcesPath, "python", "bin", "python3.9"),
457 | // In development
458 | path.join(__dirname, "python", "bin", "python3.9"),
459 | ];
460 | for (const path of possibilities) {
461 | if (fs.existsSync(path)) {
462 | return path;
463 | }
464 | }
465 | console.log("Could not find python3, checked", possibilities);
466 | app.quit();
467 | }
468 |
469 | function windowOpts(extraOpts) {
470 | extraOpts = extraOpts || {};
471 | let opts = {
472 | width: 800,
473 | height: 600,
474 | webPreferences: {
475 | preload: path.join(__dirname, extraOpts.preload || "preload.js"),
476 | sandbox: false,
477 | },
478 | };
479 | if (BrowserWindow.getFocusedWindow()) {
480 | const pos = BrowserWindow.getFocusedWindow().getPosition();
481 | opts.x = pos[0] + 22;
482 | opts.y = pos[1] + 22;
483 | }
484 | return opts;
485 | }
486 |
487 | let datasette = null;
488 |
489 | function createLoadingWindow() {
490 | let mainWindow = new BrowserWindow({
491 | show: false,
492 | ...windowOpts(),
493 | });
494 | mainWindow.loadFile("loading.html");
495 | mainWindow.once("ready-to-show", () => {
496 | mainWindow.show();
497 | for (const item of datasette.cappedProcessLog) {
498 | mainWindow.webContents.send("processLog", item);
499 | }
500 | datasette.on("processLog", (item) => {
501 | !mainWindow.isDestroyed() &&
502 | mainWindow.webContents.send("processLog", item);
503 | });
504 | });
505 | configureWindow(mainWindow);
506 | return mainWindow;
507 | }
508 |
509 | async function importCsvFromUrl(url, tableName) {
510 | const response = await datasette.apiRequest("/-/open-csv-from-url", {
511 | url: url,
512 | table_name: tableName,
513 | });
514 | const responseJson = await response.json();
515 | if (!responseJson.ok) {
516 | console.log(responseJson);
517 | dialog.showMessageBox({
518 | type: "error",
519 | message: "Error loading CSV file",
520 | detail: responseJson.error,
521 | });
522 | } else {
523 | setTimeout(() => {
524 | datasette.openPath(responseJson.path);
525 | }, 500);
526 | }
527 | }
528 |
529 | async function initializeApp() {
530 | /* We don't use openPath here because we want to control the transition from the
531 | loading.html page to the index page once the server has started up */
532 | createLoadingWindow();
533 | let freePort = null;
534 | try {
535 | freePort = await portfinder.getPortPromise({ port: 8001 });
536 | } catch (err) {
537 | console.error("Failed to obtain a port", err);
538 | app.quit();
539 | }
540 | // Start Python Datasette process (if one is not yet running)
541 | if (!datasette) {
542 | datasette = new DatasetteServer(app, freePort);
543 | }
544 | datasette.on("serverLog", (item) => {
545 | console.log(item);
546 | });
547 | await datasette.startOrRestart();
548 | datasette.openPath("/", {
549 | forceMainWindow: true,
550 | });
551 | if (filepathOnOpen) {
552 | await datasette.openFile(filepathOnOpen);
553 | filepathOnOpen = null;
554 | }
555 | app.on("will-quit", () => {
556 | datasette.shutdown();
557 | });
558 |
559 | ipcMain.on("install-plugin", async (event, pluginName) => {
560 | try {
561 | await datasette.installPlugin(pluginName);
562 | await datasette.startOrRestart();
563 | dialog.showMessageBoxSync({
564 | type: "info",
565 | message: "Plugin installed",
566 | detail: `${pluginName} is now ready to use`,
567 | });
568 | event.reply("plugin-installed", pluginName);
569 | } catch (error) {
570 | dialog.showMessageBoxSync({
571 | type: "error",
572 | message: "Plugin installation error",
573 | detail: error.toString(),
574 | });
575 | }
576 | });
577 |
578 | ipcMain.on("uninstall-plugin", async (event, pluginName) => {
579 | try {
580 | await datasette.uninstallPlugin(pluginName);
581 | await datasette.startOrRestart();
582 | dialog.showMessageBoxSync({
583 | type: "info",
584 | message: "Plugin uninstalled",
585 | detail: `${pluginName} has been removed`,
586 | });
587 | event.reply("plugin-uninstalled", pluginName);
588 | } catch (error) {
589 | dialog.showMessageBoxSync({
590 | type: "error",
591 | message: "Error uninstalling plugin",
592 | detail: error.toString(),
593 | });
594 | }
595 | });
596 |
597 | ipcMain.on("import-csv-from-url", async (event, info) => {
598 | await importCsvFromUrl(info.url, info.tableName);
599 | });
600 |
601 | ipcMain.on("import-csv", async (event, database) => {
602 | let selectedFiles = dialog.showOpenDialogSync({
603 | properties: ["openFile"],
604 | });
605 | if (!selectedFiles) {
606 | return;
607 | }
608 | let pathToOpen = null;
609 | const response = await datasette.apiRequest("/-/import-csv-file", {
610 | path: selectedFiles[0],
611 | database: database,
612 | });
613 | const responseJson = await response.json();
614 | if (!responseJson.ok) {
615 | console.log(responseJson);
616 | dialog.showMessageBox({
617 | type: "error",
618 | message: "Error importing CSV file",
619 | detail: responseJson.error,
620 | });
621 | } else {
622 | pathToOpen = responseJson.path;
623 | }
624 | setTimeout(() => {
625 | datasette.openPath(pathToOpen);
626 | }, 500);
627 | event.reply("csv-imported", database);
628 | });
629 | let menuTemplate = buildMenu();
630 | var menu = Menu.buildFromTemplate(menuTemplate);
631 | Menu.setApplicationMenu(menu);
632 | }
633 |
634 | function buildMenu() {
635 | const homeItem = {
636 | label: "Home",
637 | click() {
638 | let window = BrowserWindow.getFocusedWindow();
639 | if (window) {
640 | window.webContents.loadURL(`http://localhost:${datasette.port}/`);
641 | }
642 | },
643 | };
644 | const backItem = {
645 | label: "Back",
646 | id: "back-item",
647 | accelerator: "CommandOrControl+[",
648 | click() {
649 | let window = BrowserWindow.getFocusedWindow();
650 | if (window) {
651 | window.webContents.goBack();
652 | }
653 | },
654 | enabled: false,
655 | };
656 | const forwardItem = {
657 | label: "Forward",
658 | id: "forward-item",
659 | accelerator: "CommandOrControl+]",
660 | click() {
661 | let window = BrowserWindow.getFocusedWindow();
662 | if (window) {
663 | window.webContents.goForward();
664 | }
665 | },
666 | enabled: false,
667 | };
668 |
669 | app.on("browser-window-focus", (event, window) => {
670 | forwardItem.enabled = window.webContents.canGoForward();
671 | backItem.enabled = window.webContents.canGoBack();
672 | });
673 |
674 | function buildNetworkChanged(setting) {
675 | return async function () {
676 | await datasette.setAccessControl(setting);
677 | Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenu()));
678 | };
679 | }
680 |
681 | const onlyMyComputer = {
682 | label: "Only my computer",
683 | type: "radio",
684 | checked: datasette.accessControl == "localhost",
685 | click: buildNetworkChanged("localhost"),
686 | };
687 | const anyoneOnNetwork = {
688 | label: "Anyone on my networks",
689 | type: "radio",
690 | checked: datasette.accessControl == "network",
691 | click: buildNetworkChanged("network"),
692 | };
693 |
694 | // Gather IPv4 addresses
695 | const ips = new Set();
696 | for (const [key, networkIps] of Object.entries(os.networkInterfaces())) {
697 | networkIps.forEach((details) => {
698 | const ip = details.address;
699 | if (details.family == "IPv4" && ip != "127.0.0.1") {
700 | ips.add(ip);
701 | }
702 | });
703 | }
704 |
705 | const accessControlItems = [
706 | onlyMyComputer,
707 | anyoneOnNetwork,
708 | { type: "separator" },
709 | {
710 | label: "Open in Browser",
711 | click() {
712 | shell.openExternal(`http://localhost:${datasette.port}/`);
713 | },
714 | },
715 | ];
716 | if (datasette.accessControl == "network") {
717 | for (let ip of ips) {
718 | accessControlItems.push({
719 | label: `Copy http://${ip}:${datasette.port}/`,
720 | click() {
721 | clipboard.writeText(`http://${ip}:${datasette.port}/`);
722 | },
723 | });
724 | }
725 | }
726 |
727 | const menuTemplate = [
728 | {
729 | label: "Menu",
730 | submenu: [
731 | {
732 | label: "About Datasette",
733 | async click() {
734 | let buttons = ["Visit datasette.io", "OK"];
735 | if (!enableDebugMenu) {
736 | buttons.push("Enable Debug Menu");
737 | }
738 | dialog
739 | .showMessageBox({
740 | type: "info",
741 | message: `Datasette Desktop ${app.getVersion()}`,
742 | detail: await datasette.about(),
743 | buttons: buttons,
744 | })
745 | .then((click) => {
746 | console.log(click);
747 | if (click.response == 0) {
748 | shell.openExternal("https://datasette.io/");
749 | }
750 | if (click.response == 2) {
751 | enableDebugMenu = true;
752 | Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenu()));
753 | }
754 | });
755 | },
756 | },
757 | { type: "separator" },
758 | {
759 | role: "quit",
760 | },
761 | ],
762 | },
763 | {
764 | label: "File",
765 | submenu: [
766 | {
767 | label: "New Window",
768 | accelerator: "CommandOrControl+N",
769 | click() {
770 | let newWindow = new BrowserWindow({
771 | ...windowOpts(),
772 | ...{ show: false },
773 | });
774 | newWindow.loadURL(`http://localhost:${datasette.port}`);
775 | newWindow.once("ready-to-show", () => {
776 | newWindow.show();
777 | });
778 | configureWindow(newWindow);
779 | },
780 | },
781 | { type: "separator" },
782 | {
783 | label: "Open Recent",
784 | role: "recentdocuments",
785 | submenu: [
786 | { label: "Clear Recent Items", role: "clearrecentdocuments" },
787 | ],
788 | },
789 | { type: "separator" },
790 | {
791 | label: "Open CSV…",
792 | accelerator: "CommandOrControl+O",
793 | click: async () => {
794 | let selectedFiles = dialog.showOpenDialogSync({
795 | properties: ["openFile", "multiSelections"],
796 | });
797 | if (!selectedFiles) {
798 | return;
799 | }
800 | let pathToOpen = null;
801 | for (const filepath of selectedFiles) {
802 | app.addRecentDocument(filepath);
803 | const response = await datasette.apiRequest("/-/open-csv-file", {
804 | path: filepath,
805 | });
806 | const responseJson = await response.json();
807 | if (!responseJson.ok) {
808 | console.log(responseJson);
809 | dialog.showMessageBox({
810 | type: "error",
811 | message: "Error opening CSV file",
812 | detail: responseJson.error,
813 | });
814 | } else {
815 | pathToOpen = responseJson.path;
816 | }
817 | }
818 | setTimeout(() => {
819 | datasette.openPath(pathToOpen);
820 | }, 500);
821 | },
822 | },
823 | {
824 | label: "Open CSV from URL…",
825 | click: async () => {
826 | prompt({
827 | title: "Open CSV from URL",
828 | label: "URL:",
829 | type: "input",
830 | alwaysOnTop: true,
831 | })
832 | .then(async (url) => {
833 | if (url !== null) {
834 | await importCsvFromUrl(url);
835 | }
836 | })
837 | .catch(console.error);
838 | },
839 | },
840 | { type: "separator" },
841 | {
842 | label: "Open Database…",
843 | accelerator: "CommandOrControl+D",
844 | click: async () => {
845 | let selectedFiles = dialog.showOpenDialogSync({
846 | properties: ["openFile", "multiSelections"],
847 | });
848 | if (!selectedFiles) {
849 | return;
850 | }
851 | let pathToOpen = null;
852 | for (const filepath of selectedFiles) {
853 | const response = await datasette.apiRequest(
854 | "/-/open-database-file",
855 | { path: filepath }
856 | );
857 | const responseJson = await response.json();
858 | if (!responseJson.ok) {
859 | console.log(responseJson);
860 | dialog.showMessageBox({
861 | type: "error",
862 | message: "Error opening database file",
863 | detail: responseJson.error,
864 | });
865 | } else {
866 | app.addRecentDocument(filepath);
867 | pathToOpen = responseJson.path;
868 | }
869 | }
870 | setTimeout(() => {
871 | datasette.openPath(pathToOpen);
872 | }, 500);
873 | },
874 | },
875 | {
876 | label: "New Empty Database…",
877 | accelerator: "CommandOrControl+Shift+N",
878 | click: async () => {
879 | const filepath = dialog.showSaveDialogSync({
880 | defaultPath: "database.db",
881 | title: "Create Empty Database",
882 | });
883 | if (!filepath) {
884 | return;
885 | }
886 | const response = await datasette.apiRequest(
887 | "/-/new-empty-database-file",
888 | { path: filepath }
889 | );
890 | const responseJson = await response.json();
891 | if (!responseJson.ok) {
892 | console.log(responseJson);
893 | dialog.showMessageBox({
894 | type: "error",
895 | title: "Datasette",
896 | message: responseJson.error,
897 | });
898 | } else {
899 | datasette.openPath(responseJson.path);
900 | }
901 | },
902 | },
903 | { type: "separator" },
904 | {
905 | label: "Access Control",
906 | submenu: accessControlItems,
907 | },
908 | { type: "separator" },
909 | {
910 | role: "close",
911 | },
912 | ],
913 | },
914 | { role: "editMenu" },
915 | {
916 | label: "Navigate",
917 | submenu: [
918 | homeItem,
919 | backItem,
920 | forwardItem,
921 | {
922 | label: "Reload Current Page",
923 | accelerator: "CommandOrControl+R",
924 | click() {
925 | let window = BrowserWindow.getFocusedWindow();
926 | if (window) {
927 | window.webContents.reload();
928 | }
929 | },
930 | },
931 | ],
932 | },
933 | {
934 | label: "Plugins",
935 | submenu: [
936 | {
937 | label: "Install and Manage Plugins…",
938 | click() {
939 | datasette.openPath(
940 | "/plugin_directory/plugins?_sort_desc=stargazers_count&_facet=installed&_facet=upgrade"
941 | );
942 | },
943 | },
944 | {
945 | label: "About Datasette Plugins",
946 | click() {
947 | shell.openExternal("https://datasette.io/plugins");
948 | },
949 | },
950 | ],
951 | },
952 | {
953 | role: "help",
954 | submenu: [
955 | {
956 | label: "Learn More",
957 | click: async () => {
958 | await shell.openExternal("https://datasette.io/");
959 | },
960 | },
961 | ],
962 | },
963 | ];
964 | if (enableDebugMenu) {
965 | const enableChromiumDevTools = !!BrowserWindow.getAllWindows().length;
966 | menuTemplate.push({
967 | label: "Debug",
968 | submenu: [
969 | {
970 | label: "Open Chromium DevTools",
971 | enabled: enableChromiumDevTools,
972 | click() {
973 | (
974 | BrowserWindow.getFocusedWindow() ||
975 | BrowserWindow.getAllWindows()[0]
976 | ).webContents.openDevTools();
977 | },
978 | },
979 | { type: "separator" },
980 | {
981 | label: "Show Server Log",
982 | click() {
983 | /* Is it open already? */
984 | let browserWindow = null;
985 | let existing = BrowserWindow.getAllWindows().filter((bw) =>
986 | bw.webContents.getURL().endsWith("/server-log.html")
987 | );
988 | if (existing.length) {
989 | browserWindow = existing[0];
990 | browserWindow.focus();
991 | } else {
992 | browserWindow = new BrowserWindow(
993 | windowOpts({
994 | preload: "server-log-preload.js",
995 | })
996 | );
997 | browserWindow.loadFile("server-log.html");
998 | datasette.on("serverLog", (item) => {
999 | !browserWindow.isDestroyed() &&
1000 | browserWindow.webContents.send("serverLog", item);
1001 | });
1002 | for (const item of datasette.cappedServerLog) {
1003 | browserWindow.webContents.send("serverLog", item);
1004 | }
1005 | }
1006 | },
1007 | },
1008 | { type: "separator" },
1009 | {
1010 | label: "Progress Bar Demo",
1011 | click() {
1012 | let newWindow = new BrowserWindow({
1013 | width: 600,
1014 | height: 200,
1015 | });
1016 | newWindow.loadFile("progress.html");
1017 | },
1018 | },
1019 | { type: "separator" },
1020 | {
1021 | label: "Restart Server",
1022 | async click() {
1023 | await datasette.startOrRestart();
1024 | },
1025 | },
1026 | {
1027 | label: "Run Server Manually",
1028 | click() {
1029 | let command = [];
1030 | for (const [key, value] of Object.entries(datasette.serverEnv())) {
1031 | command.push(`${key}="${value}"`);
1032 | }
1033 | command.push(
1034 | path.join(
1035 | process.env.HOME,
1036 | ".datasette-app",
1037 | "venv",
1038 | "bin",
1039 | "datasette"
1040 | )
1041 | );
1042 | command.push(datasette.serverArgs().join(" "));
1043 | dialog
1044 | .showMessageBox({
1045 | type: "warning",
1046 | message: "Run server manually?",
1047 | detail:
1048 | "Clicking OK will terminate the Datasette server used by this app\n\n" +
1049 | "Copy this command to a terminal to manually run a replacement:\n\n" +
1050 | command.join(" "),
1051 | buttons: ["OK", "Cancel"],
1052 | })
1053 | .then(async (click) => {
1054 | if (click.response == 0) {
1055 | datasette.process.kill();
1056 | }
1057 | });
1058 | },
1059 | },
1060 | { type: "separator" },
1061 | {
1062 | label: "Show Package Versions",
1063 | async click() {
1064 | dialog.showMessageBox({
1065 | type: "info",
1066 | message: "Package Versions",
1067 | detail: JSON.stringify(
1068 | await datasette.packageVersions(),
1069 | null,
1070 | 2
1071 | ),
1072 | });
1073 | },
1074 | },
1075 | {
1076 | label: "Reinstall Datasette",
1077 | click() {
1078 | dialog
1079 | .showMessageBox({
1080 | type: "warning",
1081 | message: "Delete and reinstall Datasette?",
1082 | detail:
1083 | "This will upgrade Datasette to the latest version, and remove all currently installed additional plugins.",
1084 | buttons: ["OK", "Cancel"],
1085 | })
1086 | .then(async (click) => {
1087 | if (click.response == 0) {
1088 | // Clicked OK
1089 | BrowserWindow.getAllWindows().forEach((window) =>
1090 | window.close()
1091 | );
1092 | fs.rmdirSync(
1093 | path.join(process.env.HOME, ".datasette-app", "venv"),
1094 | { recursive: true }
1095 | );
1096 | createLoadingWindow();
1097 | await datasette.startOrRestart();
1098 | datasette.openPath("/", {
1099 | forceMainWindow: true,
1100 | });
1101 | }
1102 | });
1103 | },
1104 | },
1105 | ],
1106 | });
1107 | }
1108 | return menuTemplate;
1109 | }
1110 |
1111 | app.whenReady().then(async () => {
1112 | await initializeApp();
1113 | app.on("activate", () => {
1114 | // On macOS it's common to re-create a window in the app when the
1115 | // dock icon is clicked and there are no other windows open.
1116 | if (BrowserWindow.getAllWindows().length === 0) {
1117 | datasette.openPath("/");
1118 | }
1119 | });
1120 | });
1121 |
1122 | // Quit when all windows are closed, except on macOS. There, it's common
1123 | // for applications and their menu bar to stay active until the user quits
1124 | // explicitly with Cmd + Q.
1125 | app.on("window-all-closed", function () {
1126 | if (process.platform == "darwin") {
1127 | Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenu()));
1128 | } else {
1129 | app.quit();
1130 | }
1131 | });
1132 |
1133 | app.on("browser-window-created", function () {
1134 | // To re-enable DevTools menu item when a window opens
1135 | // Needs a slight delay so that the code can tell that a
1136 | // window has been opened an the DevTools menu item should
1137 | // be enabled.
1138 | if (datasette) {
1139 | setTimeout(() => {
1140 | Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenu()));
1141 | }, 300);
1142 | }
1143 | });
1144 |
1145 | let serverHasStarted = false;
1146 | let filepathOnOpen = null;
1147 |
1148 | app.on("open-file", async (event, filepath) => {
1149 | if (serverHasStarted) {
1150 | await datasette.openFile(filepath);
1151 | } else {
1152 | filepathOnOpen = filepath;
1153 | }
1154 | });
1155 |
1156 | function firstBytes(filepath, bytesToRead) {
1157 | return new Promise((resolve, reject) => {
1158 | fs.open(filepath, "r", function (errOpen, fd) {
1159 | if (errOpen) {
1160 | reject(errOpen);
1161 | } else {
1162 | fs.read(
1163 | fd,
1164 | Buffer.alloc(bytesToRead),
1165 | 0,
1166 | bytesToRead,
1167 | 0,
1168 | function (errRead, bytesRead, buffer) {
1169 | if (errRead) {
1170 | reject(errRead);
1171 | } else {
1172 | resolve(buffer);
1173 | }
1174 | }
1175 | );
1176 | }
1177 | });
1178 | });
1179 | }
1180 |
--------------------------------------------------------------------------------