├── .prettierignore ├── src ├── base │ ├── components │ │ ├── splash.html │ │ ├── alert.html │ │ ├── controls.html │ │ ├── settings.html │ │ ├── tabs.html │ │ └── instanceCreator.js │ ├── assets │ │ ├── penpot-logo │ │ │ ├── Windows │ │ │ │ └── icon.ico │ │ │ ├── App Icon.png │ │ │ ├── Linux │ │ │ │ ├── icon.png │ │ │ │ └── icon.svg │ │ │ ├── logo-black.png │ │ │ ├── logo-white.png │ │ │ ├── macOS │ │ │ │ ├── icon.icns │ │ │ │ └── macOS App Icon.icns │ │ │ ├── App Icon Rounded.png │ │ │ ├── logo-full-black.png │ │ │ ├── logo-full-white.png │ │ │ └── macOS App Icon.png │ │ ├── fonts │ │ │ ├── WorkSans-VariableFont_wght.ttf │ │ │ └── WorkSans-Italic-VariableFont_wght.ttf │ │ └── icons │ │ │ └── lucide │ │ │ ├── chevron-left.svg │ │ │ ├── circle-check.svg │ │ │ ├── info.svg │ │ │ ├── circle-plus.svg │ │ │ ├── rotate-cw.svg │ │ │ ├── square-x.svg │ │ │ ├── triangle-alert.svg │ │ │ ├── octagon-alert.svg │ │ │ ├── wand-sparkles.svg │ │ │ └── settings.svg │ ├── global.d.ts │ ├── styles │ │ ├── platform.css │ │ ├── infoSection.css │ │ ├── elements.css │ │ ├── fonts │ │ │ └── workSans.css │ │ ├── normalize.css │ │ ├── controls.css │ │ ├── menu.css │ │ ├── layout.css │ │ ├── index.css │ │ ├── penpotSwatches.css │ │ ├── settings.css │ │ ├── shoelaceTokens.css │ │ └── theme.css │ ├── tsconfig.json │ ├── scripts │ │ ├── shoelace.js │ │ ├── toggles.js │ │ ├── main.js │ │ ├── ui.js │ │ ├── devtools.js │ │ ├── titleBar.js │ │ ├── viewMode.js │ │ ├── file.js │ │ ├── settings.js │ │ ├── dom.js │ │ ├── alert.js │ │ ├── contextMenu.js │ │ ├── theme.js │ │ ├── electron-tabs.js │ │ └── instance.js │ └── index.html ├── shared │ ├── platform.js │ ├── settings.js │ ├── instance.js │ └── file.js ├── process │ ├── global.d.ts │ ├── tsconfig.json │ ├── string.js │ ├── platform.js │ ├── index.js │ ├── diagnostics.js │ ├── childProcess.js │ ├── server.js │ ├── path.js │ ├── preload.mjs │ ├── config.js │ ├── file.js │ ├── window.js │ ├── settings.js │ ├── menu.js │ ├── docker.js │ ├── navigation.js │ └── instance.js ├── tools │ ├── color.js │ ├── process.js │ ├── element.js │ ├── value.js │ ├── penpot.js │ ├── id.js │ ├── error.js │ └── object.js └── types │ └── ipc.ts ├── .gitattributes ├── .prettierrc ├── githooks ├── pre-commit └── commit-msg ├── commitlint.config.js ├── .lintstagedrc.json ├── penpot-desktop-banner.png ├── bin ├── setupGitHooks.sh └── docker-compose.yaml ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── tests ├── utils │ ├── fs.ts │ ├── app.ts │ └── server.js ├── application.spec.ts ├── tabs.spec.ts └── settings.spec.ts ├── .vscode └── launch.json ├── eslint.config.js ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── playwright.config.ts ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json -------------------------------------------------------------------------------- /src/base/components/splash.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true 3 | } 4 | -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/Windows/icon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx --no -- lint-staged 4 | -------------------------------------------------------------------------------- /githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx --no -- commitlint --edit 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /src/base/global.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | api: import("../types/ipc.js").Api; 3 | } 4 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,mjs,ts}": "npm run lint:open", 3 | "*": "npm run format:open" 4 | } 5 | -------------------------------------------------------------------------------- /penpot-desktop-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/penpot-desktop-banner.png -------------------------------------------------------------------------------- /src/shared/platform.js: -------------------------------------------------------------------------------- 1 | export const CONTAINER_SOLUTIONS = Object.freeze({ 2 | FLATPAK: "flatpak", 3 | }); 4 | -------------------------------------------------------------------------------- /src/process/global.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | 3 | declare var transparent: boolean; 4 | declare var AppIcon: string; 5 | -------------------------------------------------------------------------------- /src/process/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["**/*", "../tools", "../types", "../shared"] 4 | } 5 | -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/App Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/App Icon.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/Linux/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/Linux/icon.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/logo-black.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/logo-white.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/macOS/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/macOS/icon.icns -------------------------------------------------------------------------------- /src/shared/settings.js: -------------------------------------------------------------------------------- 1 | export const CONFIG_SETTINGS_TITLE_BAR_TYPES = Object.freeze({ 2 | NATIVE: "native", 3 | OVERLAY: "overlay", 4 | }); 5 | -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/App Icon Rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/App Icon Rounded.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/logo-full-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/logo-full-black.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/logo-full-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/logo-full-white.png -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/macOS App Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/macOS App Icon.png -------------------------------------------------------------------------------- /src/base/assets/fonts/WorkSans-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/fonts/WorkSans-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/macOS/macOS App Icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/penpot-logo/macOS/macOS App Icon.icns -------------------------------------------------------------------------------- /bin/setupGitHooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | HOOKS_PATH="./githooks" 4 | 5 | echo "Set local hooks path to $HOOKS_PATH" 6 | git config --local core.hooksPath $HOOKS_PATH 7 | -------------------------------------------------------------------------------- /src/base/assets/fonts/WorkSans-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/author-more/penpot-desktop/HEAD/src/base/assets/fonts/WorkSans-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /src/base/styles/platform.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --top-bar-opacity: 1; 3 | 4 | &[platform="darwin"] { 5 | &[is-focused="false"] { 6 | --top-bar-opacity: 0.5; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/tools/color.js: -------------------------------------------------------------------------------- 1 | export const HSLA_REGEXP = 2 | /^hsla\(\s*(360|3[0-5]\d|[1-2]\d{1,2}|[1-9]\d|[0-9])\s*,\s*((?:100|[0-9]\d?)%)\s*,\s*((?:100|[0-9]\d?)%)\s*,\s*(1|0|1\.00|0?\.\d+)\s*\)$/; 3 | -------------------------------------------------------------------------------- /src/base/styles/infoSection.css: -------------------------------------------------------------------------------- 1 | .info-section { 2 | display: grid; 3 | row-gap: var(--sl-spacing-small); 4 | align-content: flex-start; 5 | 6 | font-size: var(--sl-font-size-small); 7 | } 8 | -------------------------------------------------------------------------------- /src/tools/process.js: -------------------------------------------------------------------------------- 1 | export function isCI() { 2 | try { 3 | // eslint-disable-next-line no-undef 4 | return process.env.CI === "1"; 5 | } catch (error) { 6 | return false; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/base/styles/elements.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | &.color { 3 | width: 14px; 4 | height: 14px; 5 | 6 | background: var(--icon-color); 7 | border-radius: var(--sl-border-radius-medium); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/base/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext", "DOM", "DOM.Iterable"] 5 | }, 6 | "include": ["**/*", "../tools", "../types", "../shared"] 7 | } 8 | -------------------------------------------------------------------------------- /src/tools/element.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if a node is a parent node. 3 | * 4 | * @param {Node} node 5 | * 6 | * @returns {node is ParentNode} 7 | */ 8 | export function isParentNode(node) { 9 | return node.hasChildNodes(); 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /src/base/components/alert.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/tools/value.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @template T 3 | * 4 | * @param {T} value 5 | * 6 | * @returns {value is Exclude} 7 | */ 8 | 9 | export function isNonNull(value) { 10 | return value !== undefined && value !== null; 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .yarn/ 3 | 4 | 5 | .idea/ 6 | .vscode/* 7 | !.vscode/launch.json 8 | 9 | .DS_Store 10 | 11 | dist/ 12 | build/flatpak/.flatpak-builder 13 | build/flatpak/build 14 | 15 | # Playwright 16 | /test-results/ 17 | /playwright-report/ 18 | /blob-report/ 19 | /playwright/.cache/ 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | 5 | "target": "ES2023", 6 | "module": "NodeNext", 7 | "moduleResolution": "nodenext", 8 | 9 | "allowJs": true, 10 | "checkJs": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /src/tools/penpot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests if given URL is a view mode of a file, based on the file's id. 3 | * 4 | * @param {URL} url 5 | * @param {string =} fileId 6 | */ 7 | export function isViewModeUrl(url, fileId) { 8 | const isView = url.hash.startsWith("#/view"); 9 | const isFileView = fileId ? url.hash.includes(fileId) : true; 10 | 11 | return isView && isFileView; 12 | } 13 | -------------------------------------------------------------------------------- /src/process/string.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Splits a string into multiple lines. 3 | * 4 | * @param {string} string 5 | * @param {{ charactersPerLine?: number }} options 6 | */ 7 | export function toMultiline( 8 | string, 9 | { charactersPerLine } = { charactersPerLine: 75 }, 10 | ) { 11 | const pattern = new RegExp(`.{0,${charactersPerLine}}`, "g"); 12 | 13 | return string.match(pattern)?.join("\n"); 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "node:fs/promises"; 2 | 3 | export async function getFile(path: string) { 4 | const data = await readFile(path, "utf8"); 5 | return data && JSON.parse(data); 6 | } 7 | 8 | export function saveFile(path: string, data: Record) { 9 | const dataJSON = JSON.stringify(data, null, "\t"); 10 | writeFile(path, dataJSON, "utf8"); 11 | } 12 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/base/components/controls.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /src/base/styles/fonts/workSans.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Work Sans"; 3 | src: url("../../assets/fonts/WorkSans-VariableFont_wght.ttf") 4 | format("truetype"); 5 | font-weight: 100 900; 6 | font-style: normal; 7 | } 8 | 9 | @font-face { 10 | font-family: "Work Sans"; 11 | src: url("../../assets/fonts/WorkSans-Italic-VariableFont_wght.ttf") 12 | format("truetype"); 13 | font-weight: 100 900; 14 | font-style: italic; 15 | } 16 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/circle-check.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/circle-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/rotate-cw.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/base/styles/normalize.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-synthesis: none; 3 | text-rendering: optimizeLegibility; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | } 7 | 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | * { 15 | margin: 0; 16 | } 17 | 18 | img, 19 | picture, 20 | video { 21 | display: block; 22 | max-width: 100%; 23 | } 24 | 25 | input, 26 | button, 27 | textarea, 28 | select { 29 | font: inherit; 30 | } 31 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/square-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/base/scripts/shoelace.js: -------------------------------------------------------------------------------- 1 | import "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 2 | import { setBasePath } from "../../../node_modules/@shoelace-style/shoelace/cdn/utilities/base-path.js"; 3 | import { registerIconLibrary } from "../../../node_modules/@shoelace-style/shoelace/cdn/utilities/icon-library.js"; 4 | 5 | setBasePath("../../node_modules/@shoelace-style/shoelace/cdn"); 6 | 7 | registerIconLibrary("lucide", { 8 | resolver: (name) => `./assets/icons/lucide/${name}.svg`, 9 | }); 10 | -------------------------------------------------------------------------------- /src/base/styles/controls.css: -------------------------------------------------------------------------------- 1 | .controls { 2 | position: fixed; 3 | z-index: 5; 4 | top: 0px; 5 | left: 0px; 6 | margin: 4px 0px 0px 4px; 7 | app-region: no-drag; 8 | 9 | opacity: var(--top-bar-opacity, 1); 10 | 11 | &::part(base) { 12 | display: flex; 13 | gap: var(--sl-spacing-2x-small); 14 | } 15 | 16 | sl-icon-button { 17 | font-size: 16px; 18 | 19 | &::part(base) { 20 | width: 32px; 21 | height: 32px; 22 | 23 | display: grid; 24 | place-content: center; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/base/scripts/toggles.js: -------------------------------------------------------------------------------- 1 | import { SlButton } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 2 | import { typedQuerySelector } from "./dom.js"; 3 | import { openTab } from "./electron-tabs.js"; 4 | 5 | export async function initToggles() { 6 | const { openTabButton } = await getToggles(); 7 | 8 | openTabButton?.addEventListener("click", () => openTab()); 9 | } 10 | 11 | async function getToggles() { 12 | const openTabButton = typedQuerySelector("#open-tab", SlButton); 13 | 14 | return { openTabButton }; 15 | } 16 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/triangle-alert.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/utils/app.ts: -------------------------------------------------------------------------------- 1 | import { _electron as electron } from "@playwright/test"; 2 | import { platform } from "node:process"; 3 | 4 | export function launchElectronApp() { 5 | return electron.launch({ 6 | // Instead of changing the sandbox binary permissions in the CI environment, which would affect the entire pipeline unless jobs run isolated, sandbox is disabled for Linux in tests. 7 | // https://github.com/electron/electron/issues/17972 8 | args: [process.cwd(), platform === "linux" ? "--no-sandbox" : ""], 9 | env: { 10 | ...process.env, 11 | CI: "1", 12 | }, 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/process/platform.js: -------------------------------------------------------------------------------- 1 | import { CONTAINER_SOLUTIONS } from "../shared/platform.js"; 2 | 3 | export function isMacOs() { 4 | return process.platform === "darwin"; 5 | } 6 | 7 | export function isWindows() { 8 | return process.platform === "win32"; 9 | } 10 | 11 | export function isLinux() { 12 | return process.platform === "linux"; 13 | } 14 | 15 | export function getContainerSolution() { 16 | if (isFlatpakContainer()) { 17 | return CONTAINER_SOLUTIONS.FLATPAK; 18 | } 19 | 20 | return null; 21 | } 22 | 23 | function isFlatpakContainer() { 24 | return Boolean(process.env.FLATPAK_ID); 25 | } 26 | -------------------------------------------------------------------------------- /tests/utils/server.js: -------------------------------------------------------------------------------- 1 | import http from "node:http"; 2 | 3 | const responseText = "Penpot web application mock"; 4 | 5 | /** 6 | * Starts an HTTP server on the given port. 7 | * 8 | * @param {number} port - The port number to listen on. 9 | */ 10 | function createServer(port) { 11 | const server = http.createServer((req, res) => { 12 | res.writeHead(200, { "Content-Type": "text/plain" }); 13 | res.end(responseText); 14 | }); 15 | server.listen(port, () => { 16 | // eslint-disable-next-line no-undef 17 | console.log(`Server running on port ${port}`); 18 | }); 19 | } 20 | 21 | createServer(9008); 22 | createServer(9009); 23 | -------------------------------------------------------------------------------- /src/tools/id.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a random id. 3 | * 4 | * This method is NOT suitable for cryptographic purposes and most suited for low number of generated ids. 5 | * 6 | * @param {number} length 7 | * @returns {string} 8 | */ 9 | 10 | export function generateId(length = 8) { 11 | const allowedCharacters = 12 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 13 | const allowedCharactersCount = allowedCharacters.length; 14 | 15 | const getRandomCharacter = () => 16 | allowedCharacters.charAt( 17 | Math.floor(Math.random() * allowedCharactersCount), 18 | ); 19 | 20 | return Array.from({ length }, getRandomCharacter).join(""); 21 | } 22 | -------------------------------------------------------------------------------- /tests/application.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElectronApplication, expect, test } from "@playwright/test"; 2 | import { describe } from "node:test"; 3 | import { launchElectronApp } from "./utils/app.js"; 4 | 5 | let electronApp: ElectronApplication; 6 | 7 | test.beforeAll(async () => { 8 | electronApp = await launchElectronApp(); 9 | }); 10 | 11 | test.afterAll(async () => { 12 | await electronApp.close(); 13 | }); 14 | 15 | describe("application", () => { 16 | test("should open main window", async () => { 17 | const window = await electronApp.firstWindow(); 18 | 19 | expect(window).toBeDefined(); 20 | expect(await window.title()).toBe("Penpot Desktop"); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/process/index.js: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import electronUpdater from "electron-updater"; 3 | import { MainWindow } from "./window.js"; 4 | 5 | await import("./instance.js"); 6 | await import("./file.js"); 7 | await import("./navigation.js"); 8 | await import("./diagnostics.js"); 9 | 10 | app.enableSandbox(); 11 | 12 | // https://www.electronjs.org/docs/latest/breaking-changes#changed-gtk-4-is-default-when-running-gnome 13 | // https://github.com/electron/electron/issues/46538 14 | app.commandLine.appendSwitch("gtk-version", "3"); 15 | 16 | app.whenReady().then(() => { 17 | electronUpdater.autoUpdater.checkForUpdatesAndNotify(); 18 | MainWindow.create(); 19 | }); 20 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/octagon-alert.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceFolder}", 9 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" 12 | }, 13 | "args": ["."], 14 | "outputCapture": "std" 15 | }, 16 | { 17 | "name": "Attach to Main Process", 18 | "type": "node", 19 | "request": "attach", 20 | "localRoot": "${workspaceFolder}", 21 | "port": 9229, 22 | "skipFiles": ["/**"], 23 | "restart": true 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/process/diagnostics.js: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | 3 | /** 4 | * @param {import("electron").BrowserWindow} window 5 | */ 6 | export function showDiagnostics(window) { 7 | window.webContents.send("diagnostics:toggle", { 8 | system: getSystemDiagnostics(), 9 | gpu: getGPUDiagnostics(), 10 | }); 11 | } 12 | 13 | export function getSystemDiagnostics() { 14 | return { 15 | version: app.getVersion(), 16 | platform: process.platform, 17 | arch: process.arch, 18 | electronVersion: process.versions.electron, 19 | nodeVersion: process.versions.node, 20 | chromeVersion: process.versions.chrome, 21 | }; 22 | } 23 | 24 | export function getGPUDiagnostics() { 25 | return app.getGPUFeatureStatus(); 26 | } 27 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/wand-sparkles.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/base/assets/icons/lucide/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/shared/instance.js: -------------------------------------------------------------------------------- 1 | import { isCI } from "../tools/process.js"; 2 | 3 | export const DEFAULT_INSTANCE = Object.freeze({ 4 | origin: isCI() ? "http://localhost:9008" : "https://design.penpot.app", 5 | label: "Official", 6 | color: "hsla(0, 0%, 0%, 0)", 7 | isDefault: false, 8 | }); 9 | 10 | /** 11 | * Preload script's have limited import possibilities, channel names have to be updated manually. 12 | */ 13 | export const INSTANCE_EVENTS = Object.freeze({ 14 | SETUP_INFO: "instance:setup-info", 15 | GET_ALL: "instance:get-all", 16 | GET_LOCAL_CONFIG: "instance:get-config", 17 | CREATE: "instance:create", 18 | REGISTER: "instance:register", 19 | REMOVE: "instance:remove", 20 | SET_DEFAULT: "instance:setDefault", 21 | UPDATE: "instance:update", 22 | }); 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | 5 | /** @type {import('@typescript-eslint/utils').TSESLint.FlatConfig.ConfigFile} */ 6 | export default [ 7 | { files: ["**/*.{js,mjs,cjs,ts}"] }, 8 | { 9 | files: ["src/base/**/*.{js,mjs,cjs,ts}"], 10 | languageOptions: { globals: globals.browser }, 11 | }, 12 | { 13 | files: ["src/process/**/*.{js,mjs,cjs,ts}"], 14 | languageOptions: { globals: globals.node }, 15 | }, 16 | pluginJs.configs.recommended, 17 | ...tseslint.configs.recommended, 18 | { 19 | rules: { 20 | "@typescript-eslint/no-unused-vars": [ 21 | "error", 22 | { 23 | caughtErrors: "none", 24 | }, 25 | ], 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/base/scripts/main.js: -------------------------------------------------------------------------------- 1 | import "./shoelace.js"; 2 | import "../../../node_modules/electron-tabs/dist/electron-tabs.js"; 3 | 4 | import "../components/instanceCreator.js"; 5 | 6 | import { initTabs } from "./electron-tabs.js"; 7 | import { initInstance } from "./instance.js"; 8 | import { initSettings } from "./settings.js"; 9 | import { initTheme } from "./theme.js"; 10 | import { initToggles } from "./toggles.js"; 11 | import { initTitleBarType } from "./titleBar.js"; 12 | import { initViewMode } from "./viewMode.js"; 13 | 14 | import "./devtools.js"; 15 | 16 | window.addEventListener("DOMContentLoaded", () => { 17 | initTabs(); 18 | initInstance(); 19 | initTheme(); 20 | initToggles(); 21 | initSettings(); 22 | initTitleBarType(); 23 | initViewMode(); 24 | }); 25 | 26 | window.api.onSetFlag((flag, value) => { 27 | document.documentElement.setAttribute(flag, value); 28 | }); 29 | -------------------------------------------------------------------------------- /src/process/childProcess.js: -------------------------------------------------------------------------------- 1 | import { exec } from "@vscode/sudo-prompt"; 2 | 3 | /** 4 | * @typedef {Parameters} ExecParameters 5 | * 6 | * @typedef {ExecParameters[0]} ExecCommand 7 | * @typedef {Extract>} ExecOptions 8 | * 9 | * @typedef {ExecParameters[2]} ExecCallback 10 | * @typedef {Parameters>} CallbackParameters 11 | * 12 | * @typedef {Object} CommandResult 13 | * @property {CallbackParameters[1]} stdout 14 | * @property {CallbackParameters[2]} stderr 15 | * 16 | * @param {ExecCommand} command 17 | * @param {ExecOptions} options 18 | * 19 | * @returns {Promise>} 20 | */ 21 | export function sudoExec(command, options) { 22 | return new Promise((resolve, reject) => { 23 | exec(command, options, (error, stdout, stderr) => { 24 | if (error) { 25 | return reject(error); 26 | } 27 | 28 | return resolve({ stdout, stderr }); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | cooldown: 9 | default-days: 7 10 | - package-ecosystem: "npm" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | cooldown: 15 | default-days: 7 16 | exclude: 17 | - "electron" 18 | groups: 19 | production-dependencies: 20 | dependency-type: "production" 21 | exclude-patterns: 22 | - "@shoelace-style/shoelace" 23 | - "electron-updater" 24 | development-dependencies: 25 | dependency-type: "development" 26 | exclude-patterns: 27 | - "electron" 28 | - "electron-builder" 29 | builder-dependencies: 30 | patterns: 31 | - "electron-builder" 32 | - "electron-updater" 33 | core-dependencies: 34 | patterns: 35 | - "electron" 36 | - "@shoelace-style/shoelace" 37 | -------------------------------------------------------------------------------- /src/base/styles/menu.css: -------------------------------------------------------------------------------- 1 | .context-menu { 2 | display: none; 3 | position: absolute; 4 | height: 100%; 5 | width: 100%; 6 | top: 0; 7 | left: 0; 8 | z-index: var(--sl-z-index-dialog); 9 | 10 | &.visible { 11 | display: block; 12 | } 13 | 14 | & .menu { 15 | position: absolute; 16 | max-width: 200px; 17 | } 18 | 19 | sl-menu { 20 | background-color: var(--menu-background-color); 21 | color: var(--menu-color); 22 | 23 | border-radius: var(--sl-border-radius-large); 24 | 25 | box-shadow: var(--sl-shadow-medium); 26 | } 27 | 28 | sl-menu-item { 29 | &::part(base) { 30 | padding: var(--sl-spacing-2x-small) var(--sl-spacing-small); 31 | } 32 | &::part(checked-icon) { 33 | display: none; 34 | } 35 | &::part(prefix) { 36 | margin-right: var(--sl-spacing-x-small); 37 | } 38 | &::part(label) { 39 | font-size: var(--sl-font-size-small); 40 | } 41 | 42 | &:hover { 43 | &::part(base) { 44 | background: var(--menu-item-background-color-hover); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/base/styles/layout.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --darwin-traffic-lights-width: 75px; 3 | --controls-width: 76px; 4 | --top-bar-gap: 4px; 5 | 6 | --tab-nav-spacing-left: calc(var(--controls-width) + var(--top-bar-gap)); 7 | 8 | &[title-bar-type="overlay"] { 9 | &[platform="linux"] { 10 | --tab-nav-spacing-right: 170px; 11 | } 12 | 13 | &[platform="win32"] { 14 | --tab-nav-spacing-right: 210px; 15 | } 16 | } 17 | 18 | &[platform="darwin"] { 19 | --tab-nav-spacing-left: calc( 20 | var(--darwin-traffic-lights-width) + var(--controls-width) + 21 | var(--top-bar-gap) 22 | ); 23 | 24 | &[is-full-screen="true"], 25 | &[title-bar-type="native"] { 26 | --tab-nav-spacing-left: calc(var(--controls-width) + var(--top-bar-gap)); 27 | } 28 | } 29 | } 30 | 31 | &[platform="darwin"] { 32 | .controls { 33 | left: var(--darwin-traffic-lights-width); 34 | } 35 | 36 | &[is-full-screen="true"], 37 | &[title-bar-type="native"] { 38 | .controls { 39 | left: 0; 40 | } 41 | } 42 | } 43 | 44 | .titled-section { 45 | display: flex; 46 | flex-direction: column; 47 | gap: var(--sl-spacing-medium); 48 | } 49 | -------------------------------------------------------------------------------- /src/base/scripts/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a focus trap between a range of elements. 3 | * 4 | * @param {Array} items - Range of elements to trap the focus between. 5 | * 6 | * @returns {Function} Destroy the focus trap. 7 | */ 8 | export function trapFocus(items) { 9 | const firstElement = items[0]; 10 | const lastElement = items[items.length - 1]; 11 | 12 | firstElement.addEventListener("keydown", handleKeyDown); 13 | lastElement.addEventListener("keydown", handleKeyDown); 14 | 15 | /** 16 | * @param {KeyboardEvent} event 17 | */ 18 | function handleKeyDown(event) { 19 | const { key, shiftKey } = event; 20 | const isTabKey = key === "Tab"; 21 | const isLoopForward = 22 | isTabKey && document.activeElement === lastElement && !shiftKey; 23 | const isLoopBackward = 24 | isTabKey && document.activeElement === firstElement && shiftKey; 25 | const isLoop = isLoopBackward || isLoopForward; 26 | 27 | if (!isLoop) { 28 | return; 29 | } 30 | 31 | event.preventDefault(); 32 | 33 | if (isLoopForward) { 34 | firstElement.focus(); 35 | return; 36 | } 37 | 38 | if (isLoopBackward) { 39 | lastElement.focus(); 40 | } 41 | } 42 | 43 | return () => { 44 | firstElement.removeEventListener("keydown", handleKeyDown); 45 | lastElement.removeEventListener("keydown", handleKeyDown); 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/tools/error.js: -------------------------------------------------------------------------------- 1 | export const ERROR_CODES = Object.freeze({ 2 | NO_AVAILABLE_PORT: "NOPORT", 3 | FAILED_CONTAINER_SETUP: "FAILEDCONTAINER", 4 | MISSING_DOCKER: "NODOCKER", 5 | FAILED_CONFIG_DEPLOY: "NOCONFDEP", 6 | DOCKER_FAILED_SETUP: "DOCKERFAIL", 7 | DOCKER_TAG_UNAVAILABLE: "DOCKERNOTAG", 8 | FAILED_EXPORT: "FAILEXPORT", 9 | FAILED_VALIDATION: "INVALIDDATA", 10 | }); 11 | 12 | /** 13 | * @typedef {string|number} Code 14 | */ 15 | 16 | /** 17 | * Custom error class that extends built-in Error and adds new properties. 18 | */ 19 | export class AppError extends Error { 20 | /** 21 | * @param {Code} code - The error code. 22 | * @param {string} message - The error message. 23 | */ 24 | constructor(code, message) { 25 | super(message); 26 | 27 | this.name = this.constructor.name; 28 | this.code = code; 29 | } 30 | } 31 | 32 | /** 33 | * Checks if a subject is a specific AppError based on the given code. 34 | * 35 | * @param {AppError} error 36 | * @param {Code} code 37 | * 38 | * @returns {boolean} 39 | */ 40 | export function isErrorCode(error, code) { 41 | return error.code === code; 42 | } 43 | 44 | /** 45 | * Checks if a subject is an AppError. 46 | * 47 | * @param {unknown} error 48 | * 49 | * @returns {error is AppError} 50 | */ 51 | export function isAppError(error) { 52 | return error instanceof AppError; 53 | } 54 | -------------------------------------------------------------------------------- /src/base/styles/index.css: -------------------------------------------------------------------------------- 1 | @import url("./normalize.css"); 2 | @import url("./fonts/workSans.css"); 3 | @import url("./theme.css"); 4 | @import url("./layout.css"); 5 | @import url("./elements.css"); 6 | @import url("./controls.css"); 7 | @import url("./settings.css"); 8 | @import url("./menu.css"); 9 | @import url("./infoSection.css"); 10 | 11 | @import url("./platform.css"); 12 | 13 | body { 14 | font-family: var(--sl-font-sans); 15 | font-size: 16px; 16 | 17 | background: var(--color-background); 18 | color: var(--color-neutral); 19 | } 20 | 21 | sl-include.alert-modal { 22 | position: fixed; 23 | z-index: 50; 24 | bottom: 24px; 25 | left: 50%; 26 | transform: translate(-50%); 27 | width: max-content; 28 | } 29 | 30 | drag { 31 | position: fixed; 32 | top: 0px; 33 | left: 0px; 34 | width: 100%; 35 | height: 50px; 36 | app-region: drag; 37 | z-index: 1; 38 | } 39 | 40 | .no-tabs-exist { 41 | display: none; 42 | position: fixed; 43 | top: 50%; 44 | left: 50%; 45 | transform: translate(-50%, -50%); 46 | text-align: center; 47 | 48 | img { 49 | width: 54px; 50 | margin: auto; 51 | } 52 | 53 | h2 { 54 | margin: var(--sl-spacing-medium) auto var(--sl-spacing-small); 55 | } 56 | p { 57 | margin: var(--sl-spacing-small) auto; 58 | } 59 | 60 | sl-button { 61 | margin: var(--sl-spacing-medium) auto var(--sl-spacing-small); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/base/scripts/devtools.js: -------------------------------------------------------------------------------- 1 | import { typedQuerySelector } from "./dom.js"; 2 | import { SlDialog } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 3 | 4 | window.api.diagnostics.onToggle(({ system, gpu }) => { 5 | const dialog = typedQuerySelector("sl-dialog#diagnostics", SlDialog); 6 | const content = dialog?.querySelector("dialog-content"); 7 | 8 | if (dialog && content) { 9 | if (dialog.open) { 10 | dialog.hide(); 11 | return; 12 | } 13 | 14 | content.replaceChildren(); 15 | 16 | const systemSection = document.createElement("div"); 17 | systemSection.className = "titled-section"; 18 | const systemHeader = document.createElement("h3"); 19 | systemHeader.textContent = "System"; 20 | systemSection.appendChild(systemHeader); 21 | const systemPre = document.createElement("pre"); 22 | systemPre.textContent = JSON.stringify(system, null, 2); 23 | systemSection.appendChild(systemPre); 24 | content.appendChild(systemSection); 25 | 26 | const gpuSection = document.createElement("div"); 27 | gpuSection.className = "titled-section"; 28 | const gpuHeader = document.createElement("h3"); 29 | gpuHeader.textContent = "GPU"; 30 | gpuSection.appendChild(gpuHeader); 31 | const gpuPre = document.createElement("pre"); 32 | gpuPre.textContent = JSON.stringify(gpu, null, 2); 33 | gpuSection.appendChild(gpuPre); 34 | content.appendChild(gpuSection); 35 | 36 | dialog.show(); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/shared/file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Preload script's have limited import possibilities, channel names have to be updated manually. 3 | */ 4 | export const FILE_EVENTS = Object.freeze({ 5 | PREPARE_PATH: "file:prepare-path", 6 | EXPORT: "file:export", 7 | CHANGE: "file:change", 8 | }); 9 | 10 | /** 11 | * @typedef {Object} FileInfo 12 | * @property {string} name - The name of the file. 13 | * @property {string} projectName - The name of the project. 14 | * 15 | * JSDoc doesn't support Object extension: https://github.com/jsdoc/jsdoc/issues/1199 16 | * @typedef {FileInfo & {data: ArrayBuffer}} File 17 | */ 18 | 19 | /** 20 | * @param {Object} obj 21 | * 22 | * @returns {obj is File} 23 | */ 24 | export function isFile(obj) { 25 | return isFileInfo(obj) && "data" in obj && obj.data instanceof ArrayBuffer; 26 | } 27 | 28 | /** 29 | * @param {Object} obj 30 | * 31 | * @returns {obj is FileInfo} 32 | */ 33 | export function isFileInfo(obj) { 34 | return ( 35 | !!obj && typeof obj === "object" && "name" in obj && "projectName" in obj 36 | ); 37 | } 38 | 39 | /** 40 | * @param {Array} arr 41 | * 42 | * @returns {arr is Array} 43 | */ 44 | export function isArrayOfFiles(arr) { 45 | return Array.isArray(arr) && arr.every(isFile); 46 | } 47 | 48 | /** 49 | * @param {Array} arr 50 | * 51 | * @returns {arr is Array} 52 | */ 53 | export function isArrayOfFileInfos(arr) { 54 | return Array.isArray(arr) && arr.every(isFileInfo); 55 | } 56 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: "./tests", 16 | /* Run tests in files in parallel */ 17 | // Tests check if configuration file has been written correctly. Since they currently use the same file, it causes race conditions in assertions when run in parallel. 18 | fullyParallel: false, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: "line", 27 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 28 | use: { 29 | /* Base URL to use in actions like `await page.goto('/')`. */ 30 | // baseURL: 'http://localhost:3000', 31 | 32 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 33 | trace: "on-first-retry", 34 | }, 35 | 36 | /* Run your local dev server before starting the tests */ 37 | webServer: { 38 | command: "node ./tests/utils/server.js", 39 | reuseExistingServer: !process.env.CI, 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/base/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Penpot Desktop 4 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 27 | 28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | 39 | 40 | 41 |

No tabs are opened

42 |

Add a new tab to start making awesome things.

43 | Create a tab 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /tests/tabs.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElectronApplication, expect, test } from "@playwright/test"; 2 | import { describe } from "node:test"; 3 | import { launchElectronApp } from "./utils/app.js"; 4 | 5 | let electronApp: ElectronApplication; 6 | 7 | test.beforeEach(async () => { 8 | electronApp = await launchElectronApp(); 9 | }); 10 | 11 | test.afterEach(async () => { 12 | await electronApp.close(); 13 | }); 14 | 15 | describe("tabs", () => { 16 | test("should show no tabs screen", async () => { 17 | const window = await electronApp.firstWindow(); 18 | 19 | const screen = window.locator(".no-tabs-exist"); 20 | const tabs = window.locator("tab-group .tabs > .tab"); 21 | 22 | await expect(screen).toBeHidden(); 23 | await expect(tabs).toHaveCount(1); 24 | 25 | const tab = tabs.first(); 26 | await tab.getByRole("button", { name: "×" }).click(); 27 | 28 | await expect(tabs).toHaveCount(0); 29 | await expect(screen).toBeVisible(); 30 | await expect(screen).toContainText("No tabs are opened"); 31 | await expect(screen).toContainText( 32 | "Add a new tab to start making awesome things.", 33 | ); 34 | }); 35 | 36 | test("should add a tab from no tabs screen", async () => { 37 | const window = await electronApp.firstWindow(); 38 | 39 | const tabs = window.locator("tab-group .tabs > .tab"); 40 | const tab = tabs.first(); 41 | await tab.getByRole("button", { name: "×" }).click(); 42 | 43 | await expect(tabs).toHaveCount(0); 44 | 45 | const addTabButton = window.getByRole("button", { 46 | name: "Create a tab", 47 | }); 48 | await addTabButton.waitFor({ state: "visible" }); 49 | await addTabButton.click(); 50 | 51 | await expect(tabs).toHaveCount(1); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/process/server.js: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:net"; 2 | import { AppError, ERROR_CODES } from "../tools/error.js"; 3 | 4 | /** 5 | * Checks if the specified port is available. 6 | * 7 | * Creates a server and attempts to bind it to a port. 8 | * 9 | * @param {number} port - The port to check. 10 | * @param {string} host - THe host to use for binding. 11 | * 12 | * @returns {Promise} - Returns true if port is available, false if not or a check failed. 13 | */ 14 | export function isPortAvailable(port, host = "127.0.0.1") { 15 | return new Promise((resolve, reject) => { 16 | const server = createServer() 17 | .once("error", (/** @type {NodeJS.ErrnoException} */ error) => { 18 | const { code } = error; 19 | const isPortInUse = code === "EADDRINUSE"; 20 | 21 | if (isPortInUse) { 22 | resolve(false); 23 | } 24 | 25 | reject(error); 26 | }) 27 | .once("listening", () => { 28 | server.once("close", () => resolve(true)).close(); 29 | }) 30 | .listen(port, host); 31 | }); 32 | } 33 | 34 | /** 35 | * Finds the first available port from the provided range. 36 | * 37 | * @param {[number, number]} range - Inclusive range of ports to test. 38 | * @param {string =} host - The host to check the port availability on. 39 | * 40 | * @returns {Promise} 41 | * @throws Throws an error if no ports are available from a given range. 42 | */ 43 | export async function findAvailablePort(range, host) { 44 | const [start, end] = range; 45 | 46 | for (let port = start; port <= end; port++) { 47 | if (await isPortAvailable(port, host)) { 48 | return port; 49 | } 50 | } 51 | 52 | throw new AppError( 53 | ERROR_CODES.NO_AVAILABLE_PORT, 54 | `No available ports found in the range ${start}-${end}.`, 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/tools/object.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep freeze an object. 3 | * 4 | * @template T 5 | * 6 | * @param {T} obj 7 | * 8 | * @returns {T extends object ? Readonly : T} 9 | */ 10 | export function deepFreeze(obj) { 11 | const isObject = typeof obj === "object"; 12 | const isNull = obj === null; 13 | const isFrozen = Object.isFrozen(obj); 14 | 15 | if (isObject && !isNull && !isFrozen) { 16 | for (const key of Object.getOwnPropertyNames(obj)) { 17 | deepFreeze(obj[/** @type {keyof T}*/ (key)]); 18 | } 19 | Object.freeze(obj); 20 | } 21 | 22 | return /** @type {typeof obj extends object ? Readonly : typeof obj} */ ( 23 | obj 24 | ); 25 | } 26 | 27 | /** 28 | * Observes top-level properties of an object and runs a callback on a property update. 29 | * 30 | * @template {Object} O 31 | * 32 | * @param {O} obj 33 | * @param {(result: O) => void} callback 34 | * 35 | * @returns {InstanceType>} 36 | */ 37 | export function observe(obj, callback) { 38 | const handler = { 39 | /** 40 | * @param {O} target 41 | * @param {PropertyKey} prop 42 | * @param {unknown} value 43 | */ 44 | set(target, prop, value) { 45 | Reflect.set(target, prop, value); 46 | callback(obj); 47 | 48 | return true; 49 | }, 50 | /** 51 | * @param {O} target 52 | * @param {PropertyKey} prop 53 | * @param {PropertyDescriptor} descriptor 54 | */ 55 | defineProperty(target, prop, descriptor) { 56 | Reflect.defineProperty(target, prop, descriptor); 57 | callback(obj); 58 | 59 | return true; 60 | }, 61 | /** 62 | * @param {O} target 63 | * @param {PropertyKey} prop 64 | */ 65 | deleteProperty(target, prop) { 66 | Reflect.deleteProperty(target, prop); 67 | callback(obj); 68 | 69 | return true; 70 | }, 71 | }; 72 | 73 | return new Proxy(obj, handler); 74 | } 75 | -------------------------------------------------------------------------------- /src/base/styles/penpotSwatches.css: -------------------------------------------------------------------------------- 1 | /* 2 | Penpot's official palette. 3 | 4 | Repo: https://github.com/penpot/penpot-plugins/tree/main/libs/plugins-styles 5 | License: https://github.com/penpot/penpot-plugins/blob/main/LICENSE 6 | */ 7 | 8 | :root { 9 | /*BACKGROUND*/ 10 | 11 | /*dark*/ 12 | --db-primary: #18181a; 13 | --db-secondary: #000000; 14 | --db-tertiary: #212426; 15 | --db-quaternary: #2e3434; 16 | 17 | /*light*/ 18 | --lb-primary: #ffffff; 19 | --lb-secondary: #e8eaee; 20 | --lb-tertiary: #f3f4f6; 21 | --lb-quaternary: #eef0f2; 22 | 23 | /*FOREGROUND*/ 24 | 25 | /*dark*/ 26 | 27 | --df-primary: #ffffff; 28 | --df-secondary: #8f9da3; 29 | 30 | /*light*/ 31 | --lf-primary: #000000; 32 | --lf-secondary: #495e74; 33 | 34 | /*ACCENT*/ 35 | 36 | /*dark*/ 37 | --da-primary: #7efff5; 38 | --da-primary-muted: #426158; 39 | --da-secondary: #bb97d8; 40 | --da-tertiary: #00d1b8; 41 | --da-quaternary: #ff6fe0; 42 | 43 | /*light*/ 44 | --la-primary: #6911d4; 45 | --la-primary-muted: #e1d2f5; 46 | --la-secondary: #1345aa; 47 | --la-tertiary: #8c33eb; 48 | --la-quaternary: #ff6fe0; 49 | 50 | /*STATUS COLOR*/ 51 | --success-50: #f0f8ff; 52 | --success-500: #2d9f8f; 53 | --success-950: #0a2927; 54 | --warning-50: #fff4ed; 55 | --warning-500: #fe4811; 56 | --warning-950: #440806; 57 | --error-50: #fff0f3; 58 | --error-200: #ffcada; 59 | --error-500: #ff3277; 60 | --error-700: #c80857; 61 | --error-950: #500124; 62 | --info-50: #f0f8ff; 63 | --info-500: #0e9be9; 64 | --info-950: #082c49; 65 | 66 | /*APP COLOR*/ 67 | --app-white: #ffffff; 68 | --app-black: #000000; 69 | --app-pink: #f49ef7; 70 | --app-blue: #75cafc; 71 | --app-gold: #fdcd79; 72 | --app-indigo: #a9bdfa; 73 | --app-red: #faa6b7; 74 | --app-yellow: #dee563; 75 | --app-purple: #cbaaff; 76 | --app-lemon: #b1e96f; 77 | --app-orange: #f9b489; 78 | } 79 | -------------------------------------------------------------------------------- /src/base/scripts/titleBar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Parameters>[1]} TitleBarTypeSetting 3 | */ 4 | 5 | import { SlSelect } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 6 | import { CONFIG_SETTINGS_TITLE_BAR_TYPES } from "../../shared/settings.js"; 7 | import { getIncludedElement } from "./dom.js"; 8 | 9 | /** @type {TitleBarTypeSetting | null} */ 10 | let currentSetting = null; 11 | 12 | export async function initTitleBarType() { 13 | currentSetting = await window.api.getSetting("titleBarType"); 14 | 15 | prepareForm(currentSetting); 16 | } 17 | 18 | /** 19 | * @param {TitleBarTypeSetting | null} settingValue 20 | */ 21 | async function prepareForm(settingValue) { 22 | const { titleBarTypeSelect } = await getSettingForm(); 23 | 24 | if (titleBarTypeSelect && settingValue) { 25 | titleBarTypeSelect.setAttribute("value", settingValue); 26 | } 27 | 28 | titleBarTypeSelect?.addEventListener("sl-change", (event) => { 29 | const { target } = event; 30 | const value = target instanceof SlSelect && target.value; 31 | 32 | if (isTitleBarTypeSetting(value)) { 33 | currentSetting = value; 34 | window.api.setSetting("titleBarType", value); 35 | } 36 | }); 37 | } 38 | 39 | async function getSettingForm() { 40 | const titleBarTypeSelect = await getIncludedElement( 41 | "#title-bar-type-select", 42 | "#include-settings", 43 | SlSelect, 44 | ); 45 | 46 | return { titleBarTypeSelect }; 47 | } 48 | 49 | /** 50 | * @param {unknown} value 51 | * @returns {value is TitleBarTypeSetting} 52 | */ 53 | function isTitleBarTypeSetting(value) { 54 | return ( 55 | typeof value === "string" && 56 | Object.values(CONFIG_SETTINGS_TITLE_BAR_TYPES).includes( 57 | /** @type {typeof CONFIG_SETTINGS_TITLE_BAR_TYPES[keyof typeof CONFIG_SETTINGS_TITLE_BAR_TYPES]} */ ( 58 | value 59 | ), 60 | ) 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "penpot-desktop", 3 | "version": "0.22.0", 4 | "author": "Author More", 5 | "license": "AGPL-3.0-or-later", 6 | "trustedDependencies": [ 7 | "electron" 8 | ], 9 | "main": "src/process/index.js", 10 | "homepage": "https://github.com/author-more/penpot-desktop", 11 | "keywords": [ 12 | "design", 13 | "prototyping", 14 | "mockups", 15 | "graphics" 16 | ], 17 | "bugs": { 18 | "url": "https://github.com/author-more/penpot-desktop/issues", 19 | "email": "penpotdesktop@authormore.com" 20 | }, 21 | "type": "module", 22 | "scripts": { 23 | "setup": "./bin/setupGitHooks.sh", 24 | "start": "npm run dev", 25 | "build": "electron-builder --config build/electron-builder.yml", 26 | "build:arm": "electron-builder --config build/electron-builder.yml --arm64", 27 | "dev": "electron .", 28 | "dev:debug": "electron . --inspect", 29 | "format": "prettier . --write", 30 | "format:open": "prettier --write --ignore-unknown", 31 | "format:check": "prettier . --check", 32 | "lint": "eslint .", 33 | "lint:open": "eslint", 34 | "compile:check": "tsc --noEmit", 35 | "check": "npm run format:check && npm run lint && tsc", 36 | "test": "playwright test" 37 | }, 38 | "dependencies": { 39 | "@shoelace-style/shoelace": "^2.20.1", 40 | "@vscode/sudo-prompt": "^9.3.1", 41 | "electron-tabs": "^1.0.1", 42 | "electron-updater": "^6.6.2", 43 | "electron-window-state": "^5.0.3", 44 | "jszip": "^3.10.1", 45 | "zod": "^4.1.12" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/cli": "^20.1.0", 49 | "@commitlint/config-conventional": "^20.0.0", 50 | "@eslint/js": "^9.38.0", 51 | "@playwright/test": "^1.56.1", 52 | "@types/node": "^24.9.1", 53 | "electron": "^39.2.7", 54 | "electron-builder": "^26.0.12", 55 | "eslint": "^9.38.0", 56 | "globals": "^16.4.0", 57 | "lint-staged": "^16.2.5", 58 | "prettier": "3.6.2", 59 | "typescript": "^5.9.3", 60 | "typescript-eslint": "^8.46.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/process/path.js: -------------------------------------------------------------------------------- 1 | import { promisify } from "node:util"; 2 | import child_process from "node:child_process"; 3 | import path from "node:path"; 4 | import fs from "node:fs"; 5 | import { isMacOs, isWindows } from "./platform.js"; 6 | 7 | const exec = promisify(child_process.exec); 8 | const access = promisify(fs.access); 9 | const stat = promisify(fs.stat); 10 | 11 | /** 12 | * Returns a full path to the command. 13 | * 14 | * @param {string} command 15 | * 16 | * @returns 17 | */ 18 | export async function getCommandPath(command) { 19 | return ( 20 | (await getCommandByShell(command)) || (await getCommandFromPath(command)) 21 | ); 22 | } 23 | 24 | /** 25 | * Executes shell command to get path if command is available. 26 | * 27 | * @param {string} command 28 | * 29 | * @returns 30 | */ 31 | async function getCommandByShell(command) { 32 | const cmd = isWindows() 33 | ? `where "$path:${command}"` 34 | : `command -v ${command}`; 35 | 36 | try { 37 | const { stdout } = await exec(cmd); 38 | if (stdout) { 39 | const path = isWindows() ? stdout.split("\r")[0] : stdout; 40 | return path.trim(); 41 | } 42 | 43 | return null; 44 | } catch (error) { 45 | return null; 46 | } 47 | } 48 | 49 | /** 50 | * Checks PATH for the command's executable, returns path if found. 51 | * 52 | * @param {string} command 53 | * 54 | * @returns 55 | */ 56 | export async function getCommandFromPath(command) { 57 | const paths = process.env.PATH?.split(path.delimiter) || []; 58 | 59 | if (isMacOs()) { 60 | paths.push("/usr/local/bin"); 61 | } 62 | 63 | for (const dir of new Set(paths)) { 64 | const commandPath = path.join( 65 | dir, 66 | process.platform === "win32" ? `${command}.exe` : command, 67 | ); 68 | 69 | try { 70 | // Exclude directories. For example, on Linux, PATH contains `docker` directories. 71 | const fileStats = await stat(commandPath); 72 | if (fileStats.isDirectory()) { 73 | continue; 74 | } 75 | 76 | await access(commandPath, fs.constants.X_OK); 77 | 78 | return commandPath; 79 | } catch (error) { 80 | continue; 81 | } 82 | } 83 | 84 | return null; 85 | } 86 | -------------------------------------------------------------------------------- /src/base/scripts/viewMode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Parameters>[1]} AutoReloadSetting 3 | * @typedef {Parameters>[1]} ViewModeWindowSetting 4 | */ 5 | 6 | import { SlCheckbox } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 7 | import { getIncludedElement } from "./dom.js"; 8 | 9 | export async function initViewMode() { 10 | const enableAutoReload = await window.api.getSetting("enableAutoReload"); 11 | const enableViewModeWindow = await window.api.getSetting( 12 | "enableViewModeWindow", 13 | ); 14 | 15 | prepareForm({ enableAutoReload, enableViewModeWindow }); 16 | } 17 | 18 | /** 19 | * @typedef {Object} PrepareFormOptions 20 | * @property {AutoReloadSetting} enableAutoReload 21 | * @property {ViewModeWindowSetting} enableViewModeWindow 22 | * 23 | * @param {PrepareFormOptions} settingValues 24 | */ 25 | async function prepareForm({ enableAutoReload, enableViewModeWindow }) { 26 | const { autoReloadSwitch, viewModeWindowSwitch } = await getSettingForm(); 27 | 28 | if (autoReloadSwitch) { 29 | autoReloadSwitch.checked = enableAutoReload; 30 | 31 | autoReloadSwitch.addEventListener("sl-change", (event) => { 32 | const { target } = event; 33 | const value = target instanceof SlCheckbox && target.checked; 34 | 35 | window.api.setSetting("enableAutoReload", value); 36 | }); 37 | } 38 | 39 | if (viewModeWindowSwitch) { 40 | viewModeWindowSwitch.checked = enableViewModeWindow; 41 | 42 | viewModeWindowSwitch.addEventListener("sl-change", (event) => { 43 | const { target } = event; 44 | const value = target instanceof SlCheckbox && target.checked; 45 | 46 | window.api.setSetting("enableViewModeWindow", value); 47 | }); 48 | } 49 | } 50 | 51 | async function getSettingForm() { 52 | const autoReloadSwitch = await getIncludedElement( 53 | "#auto-reload-switch", 54 | "#include-settings", 55 | SlCheckbox, 56 | ); 57 | const viewModeWindowSwitch = await getIncludedElement( 58 | "#view-mode-window-switch", 59 | "#include-settings", 60 | SlCheckbox, 61 | ); 62 | 63 | return { autoReloadSwitch, viewModeWindowSwitch }; 64 | } 65 | -------------------------------------------------------------------------------- /src/base/scripts/file.js: -------------------------------------------------------------------------------- 1 | import { isArrayOfFileInfos, isArrayOfFiles } from "../../shared/file.js"; 2 | import { ERROR_CODES, isAppError, isErrorCode } from "../../tools/error.js"; 3 | import { showAlert } from "./alert.js"; 4 | 5 | /** 6 | * @param {Array} files 7 | * @param {Array} failedExports 8 | */ 9 | export async function handleFileExport(files, failedExports) { 10 | try { 11 | if (!isArrayOfFiles(files) || !isArrayOfFileInfos(failedExports)) { 12 | throw new Error("Invalid export bundles provided."); 13 | } 14 | 15 | const { status } = await window.api.file.export(files); 16 | 17 | const isSuccess = status === "success"; 18 | const hasFailedExports = failedExports.length > 0; 19 | const isFullSuccess = isSuccess && !hasFailedExports; 20 | const isPartialSuccess = isSuccess && hasFailedExports; 21 | 22 | const failedExportsFiles = 23 | isPartialSuccess && 24 | failedExports 25 | .map(({ name, projectName }) => `${projectName}/${name}`) 26 | .join("\n"); 27 | const alertType = isFullSuccess ? "success" : "warning"; 28 | const alertHeading = isFullSuccess 29 | ? "Projects saved successfully" 30 | : "Projects saved with issues"; 31 | const alertMessage = isFullSuccess 32 | ? "The projects have been saved successfully." 33 | : `Projects have been exported, but some files failed to download${typeof failedExportsFiles === "string" ? `: \n ${failedExportsFiles}` : ". Couldn't retrieve the list of files."}`; 34 | 35 | showAlert( 36 | alertType, 37 | { 38 | heading: alertHeading, 39 | message: alertMessage, 40 | }, 41 | isFullSuccess 42 | ? { 43 | duration: 3000, 44 | } 45 | : { 46 | closable: true, 47 | }, 48 | ); 49 | } catch (error) { 50 | const isError = error instanceof Error; 51 | const isValidationError = 52 | isAppError(error) && isErrorCode(error, ERROR_CODES.FAILED_VALIDATION); 53 | const message = 54 | isError || isValidationError 55 | ? error.message 56 | : "Something went wrong during the saving of the files."; 57 | 58 | showAlert( 59 | "danger", 60 | { 61 | heading: "Failed to save the projects", 62 | message, 63 | }, 64 | { 65 | closable: true, 66 | }, 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/process/preload.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | const { contextBridge, ipcRenderer } = require("electron"); 4 | 5 | contextBridge.exposeInMainWorld( 6 | "api", 7 | /** @type {import("../types/ipc.js").Api} */ ({ 8 | send: (channel, data) => { 9 | let validChannels = [ 10 | "updateApp", 11 | "ReloadApp", 12 | "MaximizeWindow", 13 | "UnmaximizeWindow", 14 | "MinimizeWindow", 15 | "OpenHelp", 16 | "OpenOffline", 17 | "OpenCredits", 18 | "openTabMenu", 19 | ]; 20 | 21 | if (validChannels.includes(channel)) { 22 | ipcRenderer.send(channel, data); 23 | } 24 | }, 25 | instance: { 26 | getSetupInfo: () => ipcRenderer.invoke("instance:setup-info"), 27 | getAll: () => ipcRenderer.invoke("instance:get-all"), 28 | getConfig: (id) => ipcRenderer.invoke("instance:get-config", id), 29 | create: (instance) => ipcRenderer.invoke("instance:create", instance), 30 | update: (id, instance) => 31 | ipcRenderer.invoke("instance:update", id, instance), 32 | remove: (id) => ipcRenderer.send("instance:remove", id), 33 | setDefault: (id) => ipcRenderer.send("instance:setDefault", id), 34 | }, 35 | file: { 36 | export: (file) => ipcRenderer.invoke("file:export", file), 37 | change: (fileId) => ipcRenderer.send("file:change", fileId), 38 | }, 39 | diagnostics: { 40 | onToggle: (callback) => { 41 | ipcRenderer.on("diagnostics:toggle", (_event, diagnosticsData) => 42 | callback(diagnosticsData), 43 | ); 44 | }, 45 | }, 46 | tab: { 47 | onSetDefault: (callback) => { 48 | ipcRenderer.on("tab:set-default", (_event, value) => callback(value)); 49 | }, 50 | onOpen: (callback) => 51 | ipcRenderer.on("tab:open", (_event, value) => callback(value)), 52 | onMenuAction: (callback) => 53 | ipcRenderer.on("tab:menu-action", (_event, value) => callback(value)), 54 | }, 55 | setTheme: (themeId) => { 56 | ipcRenderer.send("set-theme", themeId); 57 | }, 58 | getSetting: (setting) => { 59 | return ipcRenderer.invoke("setting:get", setting); 60 | }, 61 | setSetting: (setting, value) => { 62 | ipcRenderer.send("setting:set", setting, value); 63 | }, 64 | onSetFlag: (callback) => { 65 | ipcRenderer.on("set-flag", (_event, [flag, value]) => 66 | callback(flag, value), 67 | ); 68 | }, 69 | }), 70 | ); 71 | -------------------------------------------------------------------------------- /src/base/assets/penpot-logo/Linux/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/base/styles/settings.css: -------------------------------------------------------------------------------- 1 | .settings { 2 | --sl-overlay-background-color: none; 3 | 4 | &::part(body) { 5 | display: grid; 6 | align-content: start; 7 | row-gap: var(--sl-spacing-2x-large); 8 | } 9 | 10 | &::part(panel) { 11 | --size: 20rem; 12 | 13 | height: calc(100% - var(--top-bar-height)); 14 | top: unset; 15 | bottom: 0; 16 | 17 | box-shadow: unset; 18 | 19 | /* Visible overflow allows the close button be next to the drawer. */ 20 | overflow: visible; 21 | } 22 | 23 | .close { 24 | position: absolute; 25 | top: 15px; 26 | right: 0; 27 | transform: translateX(100%); 28 | 29 | &::part(base) { 30 | height: 50px; 31 | 32 | background-color: var(--color-background); 33 | 34 | border-top-left-radius: 0; 35 | border-bottom-left-radius: 0; 36 | 37 | font-size: var(--sl-font-size-large); 38 | } 39 | 40 | &:hover { 41 | &::part(base) { 42 | color: var(--color-neutral); 43 | } 44 | } 45 | } 46 | 47 | sl-button-group { 48 | display: grid; 49 | place-content: center; 50 | } 51 | 52 | sl-dialog { 53 | --width: 75vw; 54 | } 55 | } 56 | 57 | .panel-list { 58 | display: grid; 59 | row-gap: var(--sl-spacing-small); 60 | 61 | .list { 62 | display: grid; 63 | row-gap: var(--sl-spacing-x-small); 64 | } 65 | 66 | > sl-button { 67 | width: 100%; 68 | } 69 | } 70 | 71 | .panel { 72 | display: grid; 73 | grid-template-columns: min-content auto min-content; 74 | align-items: center; 75 | 76 | gap: var(--sl-spacing-small); 77 | padding: var(--sl-spacing-x-small) var(--sl-spacing-small); 78 | 79 | background-color: var(--panel-background-color); 80 | 81 | border-radius: var(--sl-border-radius-large); 82 | 83 | .color { 84 | width: 16px; 85 | height: 16px; 86 | 87 | border-radius: var(--sl-border-radius-small); 88 | } 89 | 90 | .body { 91 | display: grid; 92 | gap: var(--sl-spacing-2x-small); 93 | } 94 | 95 | .label { 96 | font-size: var(--sl-font-size-small); 97 | color: var(--panel-label-color); 98 | } 99 | .hint { 100 | font-size: var(--sl-font-size-x-small); 101 | color: var(--panel-hint-color); 102 | } 103 | } 104 | 105 | .info { 106 | text-align: left; 107 | 108 | ul { 109 | display: flex; 110 | flex-direction: column; 111 | 112 | gap: var(--sl-spacing-medium); 113 | 114 | font-size: var(--sl-font-size-small); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/process/config.js: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { copyFile, readFile, writeFile } from "node:fs/promises"; 3 | import { join } from "node:path"; 4 | 5 | /** 6 | * @template Config 7 | * 8 | * @param {string} configName 9 | * 10 | * @returns {Promise} 11 | */ 12 | export async function readConfig(configName) { 13 | const configFilePath = getConfigFilePath(configName); 14 | 15 | try { 16 | const configData = await readFile(configFilePath, "utf8"); 17 | const config = configData && JSON.parse(configData); 18 | 19 | return config; 20 | } catch (error) { 21 | const isError = error instanceof Error; 22 | const isNoFile = isError && "code" in error && error.code === "ENOENT"; 23 | const message = `[ERROR] [config:read:${configName}] ${isError ? error.message : "Failed to read config."}`; 24 | 25 | if (isError && !isNoFile) { 26 | throw new Error(message); 27 | } 28 | 29 | console.error(message); 30 | } 31 | } 32 | 33 | /** 34 | * @template Config 35 | * 36 | * @param {string} configName 37 | * @param {Partial} config 38 | */ 39 | export function writeConfig(configName, config) { 40 | const configFilePath = getConfigFilePath(configName); 41 | 42 | try { 43 | const configJSON = JSON.stringify(config, null, "\t"); 44 | writeFile(configFilePath, configJSON, "utf8"); 45 | } catch (error) { 46 | const isError = error instanceof Error; 47 | const message = isError ? error.message : "Failed to save the config."; 48 | console.error(`[ERROR] [config:write:${configName}] ${message}`); 49 | } 50 | } 51 | 52 | /** 53 | * @param {string} configName 54 | * @param {string =} suffix 55 | */ 56 | export function duplicateConfig(configName, suffix) { 57 | const configFilePath = getConfigFilePath(configName); 58 | const modifier = suffix ? `.${suffix}` : ""; 59 | const configFileCopyPath = getConfigFilePath(`${configName}${modifier}`); 60 | 61 | try { 62 | copyFile(configFilePath, configFileCopyPath); 63 | } catch (error) { 64 | const isError = error instanceof Error; 65 | const message = isError 66 | ? error.message 67 | : "Failed to duplicate the config."; 68 | console.error(`[ERROR] [config:duplicate:${configName}] ${message}`); 69 | } 70 | } 71 | 72 | /** 73 | * @param {string} configName 74 | * 75 | * @returns 76 | */ 77 | function getConfigFilePath(configName) { 78 | const configDir = app.getPath("userData"); 79 | 80 | return join(configDir, `${configName}.json`); 81 | } 82 | -------------------------------------------------------------------------------- /src/base/scripts/settings.js: -------------------------------------------------------------------------------- 1 | import { 2 | SlDrawer, 3 | SlIconButton, 4 | } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 5 | import { getIncludedElement } from "./dom.js"; 6 | 7 | export async function initSettings() { 8 | const { 9 | toggleSettingsButton, 10 | closeSettingsButton, 11 | openDocsButton, 12 | openSelfhostButton, 13 | openCreditsButton, 14 | } = await getTriggers(); 15 | 16 | toggleSettingsButton?.addEventListener("click", () => toggleSettings()); 17 | closeSettingsButton?.addEventListener("click", () => toggleSettings()); 18 | openDocsButton?.addEventListener("click", () => window.api.send("OpenHelp")); 19 | openSelfhostButton?.addEventListener("click", () => 20 | window.api.send("OpenOffline"), 21 | ); 22 | openCreditsButton?.addEventListener("click", () => 23 | window.api.send("OpenCredits"), 24 | ); 25 | } 26 | 27 | async function toggleSettings() { 28 | const { settingsDrawer } = await getSettingsElements(); 29 | 30 | if (settingsDrawer?.open) { 31 | settingsDrawer?.hide(); 32 | return; 33 | } 34 | 35 | settingsDrawer?.show(); 36 | } 37 | 38 | export async function disableSettingsFocusTrap() { 39 | const { settingsDrawer } = await getSettingsElements(); 40 | 41 | settingsDrawer?.modal.activateExternal(); 42 | } 43 | 44 | export async function enableSettingsFocusTrap() { 45 | const { settingsDrawer } = await getSettingsElements(); 46 | 47 | settingsDrawer?.modal.deactivateExternal(); 48 | } 49 | 50 | async function getTriggers() { 51 | const toggleSettingsButton = await getIncludedElement( 52 | "#toggle-settings", 53 | "#include-controls", 54 | SlIconButton, 55 | ); 56 | const closeSettingsButton = await getIncludedElement( 57 | "#close-settings", 58 | "#include-settings", 59 | SlIconButton, 60 | ); 61 | const openDocsButton = await getIncludedElement( 62 | "#open-docs", 63 | "#include-settings", 64 | HTMLAnchorElement, 65 | ); 66 | const openSelfhostButton = await getIncludedElement( 67 | "#open-selfhost", 68 | "#include-settings", 69 | HTMLAnchorElement, 70 | ); 71 | const openCreditsButton = await getIncludedElement( 72 | "#open-credits", 73 | "#include-settings", 74 | HTMLAnchorElement, 75 | ); 76 | 77 | return { 78 | toggleSettingsButton, 79 | closeSettingsButton, 80 | openDocsButton, 81 | openSelfhostButton, 82 | openCreditsButton, 83 | }; 84 | } 85 | 86 | async function getSettingsElements() { 87 | const settingsDrawer = await getIncludedElement( 88 | "#settings", 89 | "#include-settings", 90 | SlDrawer, 91 | ); 92 | 93 | return { settingsDrawer }; 94 | } 95 | -------------------------------------------------------------------------------- /src/types/ipc.ts: -------------------------------------------------------------------------------- 1 | import type { NativeTheme } from "electron"; 2 | import { Settings } from "../process/settings.js"; 3 | import { getContainerSolution } from "../process/platform.js"; 4 | import { File } from "../shared/file.js"; 5 | import { 6 | getGPUDiagnostics, 7 | getSystemDiagnostics, 8 | } from "../process/diagnostics.js"; 9 | import { Tag } from "../process/docker.js"; 10 | import { AllInstances, LocalInstance } from "../process/instance.js"; 11 | import { Instances } from "../base/scripts/instance.js"; 12 | 13 | export type Api = { 14 | send: (channel: string, data?: unknown) => void; 15 | instance: { 16 | getSetupInfo: () => Promise<{ 17 | isDockerAvailable: boolean; 18 | dockerTags: Tag["name"][]; 19 | containerSolution: ReturnType; 20 | }>; 21 | getAll: () => Promise; 22 | getConfig: (id: string) => Promise< 23 | | (Settings["instances"][number] & { 24 | localInstance?: Pick< 25 | LocalInstance, 26 | "tag" | "isInstanceTelemetryEnabled" 27 | >; 28 | }) 29 | | null 30 | >; 31 | create: (instance?: Record) => Promise; 32 | update: (id: string, instance: Record) => Promise; 33 | remove: (id: string) => void; 34 | setDefault: (id: string) => void; 35 | }; 36 | file: { 37 | // Unexposed method used between the webview preload and the main process 38 | // preparePath: ( 39 | // projectName: string, 40 | // ) => Promise<{ status: "success" | "fail" }>; 41 | export: (files: File[]) => Promise<{ status: "success" | "fail" }>; 42 | change: (fileId: string) => void; 43 | }; 44 | diagnostics: { 45 | onToggle: ( 46 | callback: (diagnosticsData: { 47 | system: ReturnType; 48 | gpu: ReturnType; 49 | }) => void, 50 | ) => void; 51 | }; 52 | tab: { 53 | onSetDefault: ( 54 | callback: ( 55 | instance: Pick, 56 | ) => void, 57 | ) => void; 58 | onOpen: (callback: (href: string) => void) => void; 59 | onMenuAction: ( 60 | callback: ({ command, tabId }: TabContextMenuAction) => void, 61 | ) => void; 62 | }; 63 | setTheme: (themeId: NativeTheme["themeSource"]) => void; 64 | getSetting: (setting: S) => Promise; 65 | setSetting: ( 66 | setting: S, 67 | value: Settings[S], 68 | ) => void; 69 | onSetFlag: (callback: (flag: string, value: string) => void) => void; 70 | }; 71 | 72 | type TabContextMenuAction = { command: string; tabId: number }; 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Penpot Desktop](./penpot-desktop-banner.png) 2 | 3 | # Penpot Desktop 4 | 5 | Penpot Desktop is an unofficial desktop application for the open-source design tool, Penpot. 6 | 7 | It provides you with access to the functionality of the browser version of Penpot with an experience of a desktop application. It comes with 8 | 9 | - system-level application experience e.g. a dedicated window, file extension association, 10 | - versatile dark-light mode setup, 11 | - tab interface for easy navigation between projects, 12 | - ability to connect to different instances e.g. officially hosted, local for offline work, 13 | - local instance creator, based on the official Docker setup, 14 | - batch export of projects, 15 | - and more are coming. 16 | 17 | 📡 Penpot Desktop loads the Penpot web application like a browser does. For offline use, the built-in [local instance creator](https://github.com/author-more/penpot-desktop/wiki/Self%E2%80%90hosting#instance-creator) can set up and run a local Penpot instance via Docker (per the official self‑hosting guide). 18 | 19 | ## Quick Links 20 | 21 | - [Penpot](https://penpot.app/) - The official website for Penpot. 22 | - [Documentation](https://github.com/author-more/penpot-desktop/wiki) - The Penpot Desktop knowledge base 23 | - [Installation instruction](https://github.com/author-more/penpot-desktop/wiki/Installation) 24 | - [Offline-use guide](https://github.com/author-more/penpot-desktop/wiki/Self%E2%80%90hosting) 25 | 26 | ## Development and Building 27 | 28 | 1. Ensure the environment meets the following requirements: 29 | - Supported OS: 30 | - Windows 31 | - macOS 32 | - Linux 33 | - [NodeJS](https://nodejs.org/) 34 | - [Git](https://git-scm.com/) (optional) 35 | 36 | For the detailed list of requirements, see the [prerequisites in Electron documentation](https://www.electronjs.org/docs/latest/tutorial/tutorial-prerequisites). 37 | 38 | 1. Clone the repository or download the source code. 39 | 1. Navigate to the project's directory. 40 | 1. Run `npm ci` to install packages. 41 | _Other package managers such as Yarn, PNPM, or Bun should work as well._ 42 | 1. (Optional) Run `npm run setup` to prepare development environment. 43 | 1. (Optional) Run `npm run dev` to start the application in development mode. This will open a new window with the application running. 44 | 45 | > Note: Penpot Desktop is using ES Modules. Make sure to read the [ES Modules (ESM) in Electron guide](https://www.electronjs.org/docs/latest/tutorial/esm). 46 | 47 | 1. Run `npm run build` to build the application. By default, it will build for the current OS and architecture, but you can pass flags to build for other platforms. See the [Electron Builder documentation](https://www.electron.build/cli) for more information. 48 | -------------------------------------------------------------------------------- /src/process/file.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, dialog, ipcMain } from "electron"; 2 | import { FILE_EVENTS } from "../shared/file.js"; 3 | import JSZip from "jszip"; 4 | import { createWriteStream } from "node:fs"; 5 | import { getMainWindow } from "./window.js"; 6 | import { z } from "zod"; 7 | import { AppError, ERROR_CODES } from "../tools/error.js"; 8 | import { isViewModeUrl } from "../tools/penpot.js"; 9 | 10 | const filesSchema = z.array( 11 | z.object({ 12 | name: z.string(), 13 | projectName: z.string(), 14 | data: z.instanceof(ArrayBuffer), 15 | }), 16 | ); 17 | 18 | const fileIdSchema = z.string(); 19 | 20 | /** 21 | * @type {string | null} 22 | */ 23 | let exportPath; 24 | 25 | ipcMain.handle(FILE_EVENTS.PREPARE_PATH, async () => { 26 | const { canceled, filePath } = await dialog.showSaveDialog(getMainWindow()); 27 | 28 | if (canceled || !filePath) { 29 | return { status: "fail" }; 30 | } 31 | 32 | exportPath = filePath; 33 | 34 | return { status: "success" }; 35 | }); 36 | 37 | ipcMain.handle(FILE_EVENTS.EXPORT, async (_event, files) => { 38 | const { success: isValidExport, data: filesValid } = 39 | filesSchema.safeParse(files); 40 | 41 | if (!isValidExport) { 42 | throw new AppError( 43 | ERROR_CODES.FAILED_VALIDATION, 44 | "Files bundle failed validation.", 45 | ); 46 | } 47 | 48 | try { 49 | const archive = new JSZip(); 50 | 51 | filesValid.forEach(({ name, projectName, data }) => { 52 | const path = `${projectName}/${name}.penpot`; 53 | 54 | archive.file(path, data); 55 | }); 56 | 57 | return new Promise((resolve) => { 58 | if (!exportPath) { 59 | throw new Error("Export path is not set."); 60 | } 61 | 62 | archive 63 | .generateNodeStream({ streamFiles: true }) 64 | .pipe(createWriteStream(exportPath)) 65 | .on("error", () => { 66 | exportPath = null; 67 | 68 | throw new Error("Failed to save the archive."); 69 | }) 70 | .on("finish", () => { 71 | exportPath = null; 72 | 73 | resolve({ status: "success" }); 74 | }); 75 | }); 76 | } catch (error) { 77 | const message = 78 | error instanceof Error ? error.message : "Failed to save the projects."; 79 | 80 | throw new AppError(ERROR_CODES.FAILED_EXPORT, message); 81 | } 82 | }); 83 | 84 | ipcMain.on(FILE_EVENTS.CHANGE, (_event, fileId) => { 85 | const { success: isValidFileId, data: fileIdValid } = 86 | fileIdSchema.safeParse(fileId); 87 | 88 | if (!isValidFileId) { 89 | return; 90 | } 91 | 92 | const windows = BrowserWindow.getAllWindows(); 93 | const viewModeWindow = windows.find((window) => { 94 | const url = window.webContents.getURL(); 95 | const windowURL = new URL(url); 96 | 97 | return isViewModeUrl(windowURL, fileIdValid); 98 | }); 99 | 100 | viewModeWindow?.reload(); 101 | }); 102 | -------------------------------------------------------------------------------- /src/base/components/settings.html: -------------------------------------------------------------------------------- 1 | 8 | 15 | 16 |
17 |

Theme

18 | 19 | Theme 20 | Light 21 | Dark 22 | System 23 | Penpot 24 | 25 |
26 | 27 |
28 |

Instances

29 |
30 |
31 | 32 | 33 | 39 | Add instance 40 | 41 | 42 | 48 | Create local instance 49 | 50 | 51 |
52 |
53 | 54 |
55 |

Title Bar Type

56 | 61 | Title bar type 62 | Native 63 | Overlay 64 | 65 |
66 | 67 |
68 |

View Mode

69 | Enable auto-reload 70 | Open in a new window 71 |
72 | 73 |
74 |

Info

75 | 80 |
81 | 82 | 83 | 84 | 85 |
86 | 87 | 102 | -------------------------------------------------------------------------------- /src/base/scripts/dom.js: -------------------------------------------------------------------------------- 1 | import { SlInclude } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 2 | 3 | /** 4 | * @template T 5 | * @typedef {new (...args: any[]) => T} Class 6 | */ 7 | 8 | /** 9 | * Retrieves an element from the DOM based on the provided element and include selectors, and type. 10 | * 11 | * @overload 12 | * @param {string} selector 13 | * @param {string | string[]} includeSelector 14 | * @returns {Promise} 15 | */ 16 | /** 17 | * @template E 18 | * @overload 19 | * @param {string} selector 20 | * @param {string | string[]} includeSelector 21 | * @param {Class =} type 22 | * @returns {Promise> | null>} 23 | * 24 | */ 25 | /** 26 | * @param {string} selector - The CSS selector of the element to retrieve. 27 | * @param {string | string[]} includeSelector - The CSS selector of the sl-include element or an array in case of nested includes. 28 | * @param {Class =} type - The expected type of the element. 29 | */ 30 | export async function getIncludedElement(selector, includeSelector, type) { 31 | /** @type {SlInclude | null} */ 32 | let includeElement; 33 | 34 | const isNestedInclude = Array.isArray(includeSelector); 35 | if (isNestedInclude) { 36 | const includeSelectorsReversed = includeSelector.toReversed(); 37 | const hasMultipleIncludes = includeSelectorsReversed.length > 1; 38 | const topInclude = includeSelectorsReversed[0]; 39 | const followingIncludes = includeSelectorsReversed.slice(1); 40 | 41 | includeElement = hasMultipleIncludes 42 | ? await getIncludedElement(topInclude, followingIncludes, SlInclude) 43 | : document.querySelector(topInclude); 44 | } else { 45 | includeElement = document.querySelector(includeSelector); 46 | } 47 | 48 | if (!includeElement) { 49 | return null; 50 | } 51 | 52 | const getElement = () => 53 | type 54 | ? typedQuerySelector(selector, type, includeElement) 55 | : includeElement.querySelector(selector); 56 | 57 | const element = getElement(); 58 | if (element) { 59 | return element; 60 | } 61 | 62 | return new Promise((resolve) => { 63 | includeElement.addEventListener("sl-load", () => { 64 | const element = getElement(); 65 | 66 | resolve(element); 67 | }); 68 | }); 69 | } 70 | 71 | /** 72 | * Retrieves an element from the DOM based on the provided selector and expected type. 73 | * 74 | * @template E 75 | * @param {string} selector 76 | * @param {Class} type 77 | * @param {ParentNode | null | undefined} parent 78 | * @return {E | null} 79 | */ 80 | export function typedQuerySelector(selector, type, parent = document) { 81 | const element = parent?.querySelector(selector); 82 | if (isInstanceOf(element, type)) { 83 | return element; 84 | } 85 | 86 | return null; 87 | } 88 | 89 | /** 90 | * Checks if the subject is an instance of the specified type. 91 | * 92 | * @template S 93 | * @template T 94 | * 95 | * @param {S} subject - The element to check. 96 | * @param {Class} type - The constructor function of the type to check against. 97 | * 98 | * @returns {subject is T} 99 | */ 100 | export function isInstanceOf(subject, type) { 101 | return subject instanceof type; 102 | } 103 | -------------------------------------------------------------------------------- /src/base/scripts/alert.js: -------------------------------------------------------------------------------- 1 | import { 2 | SlIcon, 3 | SlAlert, 4 | } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 5 | import { getIncludedElement, typedQuerySelector } from "./dom.js"; 6 | 7 | /** 8 | * @typedef {Object} AlertConfiguration 9 | * @property {Variant} variant 10 | * @property {Content} content 11 | * @property {Partial =} options 12 | * 13 | * @typedef {'primary' | 'success' | 'neutral' | 'warning' | 'danger'} Variant 14 | * @typedef {[label: string, url: string]} Link 15 | * 16 | * @typedef {Object} Content 17 | * @property {string} heading 18 | * @property {string} message 19 | * @property {Array =} links 20 | * 21 | * @typedef {Object} Options 22 | * @property {number} duration 23 | * @property {boolean} closable 24 | * @property {boolean} open 25 | */ 26 | 27 | /** 28 | * @param {Variant} variant 29 | * @param {Content} content 30 | * @param {Partial} options 31 | */ 32 | export async function showAlert(variant, content, options = {}) { 33 | const alert = await createAlert(variant, content, options); 34 | 35 | if (!alert) { 36 | return; 37 | } 38 | 39 | document.body.append(alert); 40 | alert.toast(); 41 | } 42 | 43 | /** 44 | * @param {Variant} variant 45 | * @param {Content} content 46 | * @param {Partial} options 47 | */ 48 | export async function createAlert( 49 | variant = "primary", 50 | { heading, message, links = [] }, 51 | { duration = Infinity, open = false, closable = false } = {}, 52 | ) { 53 | const { alertTemplate } = await getAlertElements(); 54 | 55 | if (!alertTemplate) { 56 | return null; 57 | } 58 | 59 | const alert = document.importNode(alertTemplate.content, true); 60 | 61 | const iconEl = typedQuerySelector("sl-icon", SlIcon, alert); 62 | if (iconEl) { 63 | const iconNameVariantMapping = Object.freeze({ 64 | primary: "info", 65 | success: "circle-check", 66 | neutral: "settings", 67 | warning: "triangle-alert", 68 | danger: "octagon-alert", 69 | }); 70 | iconEl.name = iconNameVariantMapping[variant]; 71 | } 72 | 73 | const headingEl = alert.querySelector("strong"); 74 | if (headingEl) { 75 | headingEl.innerText = heading; 76 | } 77 | 78 | const messageEl = alert.querySelector("p"); 79 | if (messageEl) { 80 | messageEl.innerText = message; 81 | } 82 | 83 | const alertLinksEl = alert.querySelector("alert-links"); 84 | const linkItems = links.map(([label, url]) => { 85 | const anchorEl = document.createElement("a"); 86 | anchorEl.innerText = label; 87 | anchorEl.href = url; 88 | anchorEl.target = "_blank"; 89 | anchorEl.rel = "noopener noreferrer"; 90 | 91 | return anchorEl; 92 | }); 93 | alertLinksEl?.replaceChildren(...linkItems); 94 | 95 | const alertEl = typedQuerySelector("sl-alert", SlAlert, alert); 96 | if (alertEl) { 97 | alertEl.variant = variant; 98 | alertEl.duration = duration; 99 | alertEl.closable = closable; 100 | alertEl.open = open; 101 | } 102 | 103 | return alertEl; 104 | } 105 | 106 | async function getAlertElements() { 107 | const alertTemplate = await getIncludedElement( 108 | "#template-alert", 109 | "#include-alert", 110 | HTMLTemplateElement, 111 | ); 112 | 113 | return { alertTemplate }; 114 | } 115 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | # Required for Electron Builder to create and update releases on Github 7 | contents: write 8 | 9 | env: 10 | DEPENDENCIES_BOT_NAME: "dependabot[bot]" 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest, macos-latest] 17 | 18 | runs-on: ${{ matrix.os}} 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | with: 23 | fetch-depth: 0 24 | - uses: actions/setup-node@v6 25 | with: 26 | node-version: 22 27 | - name: Install dependencies 28 | run: npm ci 29 | - name: Validate commit messages - last push commit 30 | if: github.event_name == 'push' && github.event.pusher.name != env.DEPENDENCIES_BOT_NAME 31 | run: npx commitlint --last --verbose 32 | - name: Validate commit messages - PR's commits 33 | if: github.event_name == 'pull_request' && github.event.pull_request.user.login != env.DEPENDENCIES_BOT_NAME 34 | run: npx commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.event.pull_request.head.sha }}" --verbose 35 | - name: Run code checks 36 | run: npm run check 37 | - name: Run test 38 | shell: bash 39 | run: | 40 | if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then 41 | xvfb-run npm run test 42 | else 43 | npm run test 44 | fi 45 | - name: Build macOS with signing & notarization 46 | if: ${{ matrix.os == 'macos-latest' && github.event_name == 'push' && github.ref_name == 'main' }} 47 | timeout-minutes: 15 48 | shell: bash 49 | env: 50 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | CSC_LINK: ${{ secrets.CSC_LINK }} 52 | CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 53 | APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} 54 | APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} 55 | run: | 56 | set -euo pipefail 57 | 58 | # @electron/notarize (used by electron-builder) requires the API key to be stored in a file 59 | mkdir -p "$HOME/keys" 60 | KEY_FILE="$HOME/keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" 61 | printf "%s" "${{ secrets.APPLE_API_KEY }}" | base64 --decode > "$KEY_FILE" 62 | chmod 600 "$KEY_FILE" 63 | 64 | # Clean up the key file 65 | trap 'rm -f -- "${KEY_FILE:-}"' EXIT INT TERM 66 | 67 | export APPLE_API_KEY="$KEY_FILE" 68 | 69 | npm run build -- --x64 --arm64 70 | - name: Build base 71 | if: ${{ !(matrix.os == 'macos-latest' && github.event_name == 'push' && github.ref_name == 'main') }} 72 | timeout-minutes: 15 73 | shell: bash 74 | run: | 75 | if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref_name }}" = "main" ]; then 76 | npm run build -- --x64 --arm64 77 | else 78 | npm run build -- --x64 --arm64 --publish=never 79 | fi 80 | env: 81 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 82 | - name: Save artifacts 83 | uses: actions/upload-artifact@v5 84 | with: 85 | name: build-${{ matrix.os }} 86 | path: | 87 | dist/latest*.yml 88 | dist/*.exe 89 | dist/*.AppImage 90 | dist/*.dmg 91 | dist/*.rpm 92 | dist/*.deb 93 | dist/*.zip 94 | dist/*.blockmap 95 | retention-days: 1 96 | -------------------------------------------------------------------------------- /src/base/styles/shoelaceTokens.css: -------------------------------------------------------------------------------- 1 | :root, 2 | :host { 3 | --sl-color-success-50: rgb(240, 248, 255); 4 | --sl-color-success-100: rgb(210, 240, 255); 5 | --sl-color-success-200: rgb(180, 230, 255); 6 | --sl-color-success-300: rgb(150, 220, 255); 7 | --sl-color-success-400: rgb(120, 210, 255); 8 | --sl-color-success-500: rgb(45, 159, 143); 9 | --sl-color-success-600: rgb(40, 140, 125); 10 | --sl-color-success-700: rgb(35, 120, 110); 11 | --sl-color-success-800: rgb(30, 100, 95); 12 | --sl-color-success-900: rgb(25, 80, 80); 13 | --sl-color-success-950: rgb(10, 41, 39); 14 | 15 | --sl-color-warning-50: rgb(255, 244, 237); 16 | --sl-color-warning-100: rgb(255, 224, 220); 17 | --sl-color-warning-200: rgb(255, 204, 203); 18 | --sl-color-warning-300: rgb(255, 184, 185); 19 | --sl-color-warning-400: rgb(255, 164, 169); 20 | --sl-color-warning-500: rgb(254, 72, 17); 21 | --sl-color-warning-600: rgb(240, 60, 10); 22 | --sl-color-warning-700: rgb(225, 50, 5); 23 | --sl-color-warning-800: rgb(200, 40, 0); 24 | --sl-color-warning-900: rgb(175, 30, 0); 25 | --sl-color-warning-950: rgb(68, 8, 6); 26 | 27 | --sl-color-danger-50: rgb(255, 240, 243); 28 | --sl-color-danger-100: rgb(255, 220, 225); 29 | --sl-color-danger-200: rgb(255, 202, 218); 30 | --sl-color-danger-300: rgb(255, 180, 195); 31 | --sl-color-danger-400: rgb(255, 150, 165); 32 | --sl-color-danger-500: rgb(255, 50, 119); 33 | --sl-color-danger-600: rgb(200, 40, 90); 34 | --sl-color-danger-700: rgb(200, 8, 87); 35 | --sl-color-danger-800: rgb(150, 5, 65); 36 | --sl-color-danger-900: rgb(100, 2, 40); 37 | --sl-color-danger-950: rgb(80, 2, 5); 38 | } 39 | 40 | :root, 41 | :host, 42 | .sl-theme-light { 43 | --sl-color-primary-50: rgb(225, 210, 245); 44 | --sl-color-primary-100: rgb(200, 180, 240); 45 | --sl-color-primary-200: rgb(175, 150, 235); 46 | --sl-color-primary-300: rgb(150, 120, 230); 47 | --sl-color-primary-400: rgb(125, 90, 225); 48 | --sl-color-primary-500: rgb(105, 17, 212); 49 | --sl-color-primary-600: rgb(85, 10, 180); 50 | --sl-color-primary-700: rgb(65, 5, 150); 51 | --sl-color-primary-800: rgb(45, 0, 120); 52 | --sl-color-primary-900: rgb(25, 0, 90); 53 | --sl-color-primary-950: rgb(10, 0, 60); 54 | 55 | --sl-color-neutral-0: rgb(255, 255, 255); 56 | --sl-color-neutral-50: rgb(240, 240, 240); 57 | --sl-color-neutral-100: rgb(220, 220, 220); 58 | --sl-color-neutral-200: rgb(200, 200, 200); 59 | --sl-color-neutral-300: rgb(150, 150, 150); 60 | --sl-color-neutral-400: rgb(100, 100, 100); 61 | --sl-color-neutral-500: rgb(70, 70, 70); 62 | --sl-color-neutral-600: rgb(50, 50, 50); 63 | --sl-color-neutral-700: rgb(30, 30, 30); 64 | --sl-color-neutral-800: rgb(15, 15, 15); 65 | --sl-color-neutral-900: rgb(5, 5, 5); 66 | --sl-color-neutral-1000: rgb(0, 0, 0); 67 | } 68 | 69 | :host, 70 | .sl-theme-dark { 71 | --sl-color-primary-50: rgb(224, 255, 253); 72 | --sl-color-primary-100: rgb(188, 255, 250); 73 | --sl-color-primary-200: rgb(152, 255, 247); 74 | --sl-color-primary-300: rgb(116, 255, 244); 75 | --sl-color-primary-400: rgb(80, 255, 241); 76 | --sl-color-primary-500: rgb(46, 255, 238); 77 | --sl-color-primary-600: rgb(36, 204, 204); 78 | --sl-color-primary-700: rgb(26, 153, 170); 79 | --sl-color-primary-800: rgb(16, 102, 136); 80 | --sl-color-primary-900: rgb(6, 51, 102); 81 | --sl-color-primary-950: rgb(3, 26, 51); 82 | 83 | --sl-color-neutral-0: rgb(0, 0, 0); 84 | --sl-color-neutral-50: rgb(5, 5, 5); 85 | --sl-color-neutral-100: rgb(15, 15, 15); 86 | --sl-color-neutral-200: rgb(30, 30, 30); 87 | --sl-color-neutral-300: rgb(50, 50, 50); 88 | --sl-color-neutral-400: rgb(70, 70, 70); 89 | --sl-color-neutral-500: rgb(100, 100, 100); 90 | --sl-color-neutral-600: rgb(150, 150, 150); 91 | --sl-color-neutral-700: rgb(200, 200, 200); 92 | --sl-color-neutral-800: rgb(220, 220, 220); 93 | --sl-color-neutral-900: rgb(240, 240, 240); 94 | --sl-color-neutral-1000: rgb(255, 255, 255); 95 | } 96 | -------------------------------------------------------------------------------- /src/base/scripts/contextMenu.js: -------------------------------------------------------------------------------- 1 | import { 2 | SlMenu, 3 | SlMenuItem, 4 | } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 5 | import { typedQuerySelector } from "./dom.js"; 6 | import { trapFocus } from "./ui.js"; 7 | 8 | /** 9 | * @typedef {Object} MenuItem 10 | * @property {string} label 11 | * @property {string =} color 12 | * @property {Function} onClick 13 | */ 14 | 15 | /** @type {ResizeObserver | null} */ 16 | let resizeObserver; 17 | /** @type {HTMLElement | null} */ 18 | let currentHost; 19 | 20 | /** 21 | * Opens a context menu with given items, positioned "relatively" to a host element. 22 | * 23 | * @param {HTMLElement} host 24 | * @param {MenuItem[]} items 25 | */ 26 | export function showContextMenu(host, items) { 27 | const { contextMenu, menu } = getContextMenuElement(); 28 | if (!contextMenu || !menu) { 29 | return; 30 | } 31 | 32 | currentHost = host; 33 | 34 | const menuItems = items.map(createContextMenuItem); 35 | menu.replaceChildren(...menuItems); 36 | 37 | trapFocus(menuItems); 38 | 39 | // When inserting elements browser(s) set tabindex of the last element to -1, to prevent it from catching focus. It has to be manually reset. 40 | setTimeout(() => { 41 | menuItems.forEach((element) => (element.tabIndex = 0)); 42 | menuItems[0].focus(); 43 | }); 44 | 45 | // By default the menu has display set to `none`. 46 | // Position calculation has to be delayed until the menu is part of the DOM. Otherwise the menu's size and position are reported as 0. 47 | resizeObserver = new ResizeObserver(() => { 48 | const { 49 | top: hostTop, 50 | left: hostLeft, 51 | height: hostHeight, 52 | width: hostWidth, 53 | } = host.getBoundingClientRect(); 54 | const { width: menuWidth } = menu.getBoundingClientRect(); 55 | menu.style.top = `${hostTop + hostHeight + 4}`; 56 | menu.style.left = `${hostLeft + hostWidth - menuWidth}`; 57 | }); 58 | resizeObserver.observe(menu); 59 | 60 | contextMenu.addEventListener("click", hideContextMenu); 61 | contextMenu.addEventListener("keydown", hideWithKeyboard); 62 | contextMenu.classList.add("visible"); 63 | } 64 | 65 | /** 66 | * @param {KeyboardEvent} event 67 | */ 68 | function hideWithKeyboard(event) { 69 | const { key } = event; 70 | const isEscape = key === "Escape"; 71 | 72 | if (isEscape) { 73 | event.preventDefault(); 74 | hideContextMenu(); 75 | } 76 | } 77 | 78 | export function hideContextMenu() { 79 | const { contextMenu, menu } = getContextMenuElement(); 80 | if (!contextMenu || !menu) { 81 | return; 82 | } 83 | 84 | contextMenu?.classList.remove("visible"); 85 | contextMenu.removeEventListener("click", hideContextMenu); 86 | contextMenu.removeEventListener("keydown", hideWithKeyboard); 87 | resizeObserver?.unobserve(menu); 88 | 89 | if (currentHost) { 90 | currentHost.focus(); 91 | currentHost = null; 92 | } 93 | } 94 | 95 | /** 96 | * Creates a menu item element. 97 | * 98 | * @param {MenuItem} item 99 | */ 100 | function createContextMenuItem({ label, color, onClick }) { 101 | const menuItem = new SlMenuItem(); 102 | menuItem.innerText = label; 103 | menuItem.addEventListener("click", (event) => { 104 | event.stopPropagation(); 105 | onClick(); 106 | }); 107 | 108 | if (color) { 109 | const colorIcon = document.createElement("div"); 110 | colorIcon.classList.add("icon", "color"); 111 | colorIcon.style.setProperty("--icon-color", color); 112 | 113 | const slotPrefix = document.createElement("slot"); 114 | slotPrefix.slot = "prefix"; 115 | 116 | slotPrefix.append(colorIcon); 117 | menuItem.prepend(slotPrefix); 118 | } 119 | 120 | return menuItem; 121 | } 122 | 123 | function getContextMenuElement() { 124 | const contextMenu = typedQuerySelector("#context-menu", HTMLDivElement); 125 | const menu = typedQuerySelector("sl-menu.menu", SlMenu, contextMenu); 126 | 127 | return { contextMenu, menu }; 128 | } 129 | -------------------------------------------------------------------------------- /src/base/scripts/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Parameters[0]} ThemeId 3 | * @typedef {Awaited>>} ThemeSetting 4 | * @typedef {import("electron").IpcMessageEvent} IpcMessageEvent 5 | */ 6 | 7 | import { SlSelect } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 8 | import { getIncludedElement } from "./dom.js"; 9 | import { requestTabTheme } from "./electron-tabs.js"; 10 | 11 | export const THEME_TAB_EVENTS = Object.freeze({ 12 | REQUEST_UPDATE: "theme-request-update", 13 | UPDATE: "theme-update", 14 | }); 15 | const THEME_MEDIA = Object.freeze({ 16 | LIGHT: "(prefers-color-scheme: light)", 17 | DARK: "(prefers-color-scheme: dark)", 18 | }); 19 | 20 | /** @type {ThemeSetting | null} */ 21 | let currentThemeSetting = null; 22 | 23 | export async function initTheme() { 24 | currentThemeSetting = await window.api.getSetting("theme"); 25 | 26 | setTheme(currentThemeSetting); 27 | prepareForm(currentThemeSetting); 28 | syncThemeClass(); 29 | } 30 | 31 | function syncThemeClass() { 32 | /** 33 | * @function 34 | * @param {Pick} arg0 35 | */ 36 | const mediaMatchHandler = ({ matches, media }) => { 37 | if (!matches) { 38 | return; 39 | } 40 | 41 | if (media === THEME_MEDIA.LIGHT) { 42 | document.documentElement.classList.remove("sl-theme-dark"); 43 | document.documentElement.classList.add("sl-theme-light"); 44 | return; 45 | } 46 | 47 | if (media === THEME_MEDIA.DARK) { 48 | document.documentElement.classList.remove("sl-theme-light"); 49 | document.documentElement.classList.add("sl-theme-dark"); 50 | } 51 | }; 52 | 53 | Object.values(THEME_MEDIA).forEach((query) => { 54 | const match = matchMedia(query); 55 | const { matches, media } = match; 56 | 57 | mediaMatchHandler({ matches, media }); 58 | match.addEventListener("change", mediaMatchHandler); 59 | }); 60 | } 61 | 62 | /** 63 | * @param {ThemeSetting | null} themeSetting 64 | */ 65 | async function prepareForm(themeSetting) { 66 | const { themeSelect } = await getThemeSettingsForm(); 67 | 68 | if (themeSelect && themeSetting) { 69 | themeSelect.setAttribute("value", themeSetting); 70 | } 71 | 72 | themeSelect?.addEventListener("sl-change", (event) => { 73 | const { target } = event; 74 | const value = target instanceof SlSelect && target.value; 75 | 76 | if (isThemeSetting(value)) { 77 | const isTabTheme = value === "tab"; 78 | 79 | currentThemeSetting = value; 80 | window.api.setSetting("theme", value); 81 | 82 | if (isTabTheme) { 83 | requestTabTheme(); 84 | return; 85 | } 86 | 87 | setTheme(value); 88 | } 89 | }); 90 | } 91 | 92 | /** 93 | * @param {string} themeId 94 | */ 95 | function setTheme(themeId) { 96 | if (isThemeId(themeId)) { 97 | window.api.setTheme(themeId); 98 | } 99 | } 100 | 101 | async function getThemeSettingsForm() { 102 | const themeSelect = await getIncludedElement( 103 | "#theme-select", 104 | "#include-settings", 105 | SlSelect, 106 | ); 107 | 108 | return { themeSelect }; 109 | } 110 | 111 | /** 112 | * @param {string} inTabTheme 113 | */ 114 | export function handleInTabThemeUpdate(inTabTheme) { 115 | const shouldUseInTabTheme = currentThemeSetting === "tab"; 116 | 117 | if (shouldUseInTabTheme) { 118 | setTheme(inTabTheme); 119 | } 120 | } 121 | 122 | /** 123 | * 124 | * @param {unknown} value 125 | * @returns {value is ThemeId} 126 | */ 127 | function isThemeId(value) { 128 | return ( 129 | typeof value === "string" && ["light", "dark", "system"].includes(value) 130 | ); 131 | } 132 | 133 | /** 134 | * 135 | * @param {unknown} value 136 | * @returns {value is ThemeSetting} 137 | */ 138 | function isThemeSetting(value) { 139 | return ( 140 | typeof value === "string" && 141 | ["light", "dark", "system", "tab"].includes(value) 142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /src/base/components/tabs.html: -------------------------------------------------------------------------------- 1 | 2 | 162 | 163 | -------------------------------------------------------------------------------- /src/process/window.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, shell, nativeTheme } from "electron"; 2 | import windowStateKeeper from "electron-window-state"; 3 | import path from "path"; 4 | 5 | import { setAppMenu, getTabMenu } from "./menu.js"; 6 | import { deepFreeze } from "../tools/object.js"; 7 | import { settings } from "./settings.js"; 8 | import { CONFIG_SETTINGS_TITLE_BAR_TYPES } from "../shared/settings.js"; 9 | 10 | const TITLEBAR_OVERLAY = deepFreeze({ 11 | BASE: { 12 | height: 40, 13 | }, 14 | DARK: { 15 | color: "#18181a", 16 | symbolColor: "#ffffff", 17 | }, 18 | LIGHT: { 19 | color: "#ffffff", 20 | symbolColor: "#000000", 21 | }, 22 | }); 23 | 24 | const FLAGS = Object.freeze({ 25 | PLATFORM: "platform", 26 | FULL_SCREEN: "is-full-screen", 27 | FOCUS: "is-focused", 28 | TITLE_BAR_TYPE: "title-bar-type", 29 | }); 30 | 31 | const titleBarType = settings.titleBarType; 32 | 33 | /** @type {import("electron").BrowserWindow} */ 34 | let mainWindow; 35 | 36 | export function getMainWindow() { 37 | return mainWindow; 38 | } 39 | 40 | export const MainWindow = { 41 | create: function () { 42 | let mainWindowState = windowStateKeeper({ 43 | // Remember the positiona and size of the window 44 | defaultWidth: 1400, 45 | defaultHeight: 900, 46 | }); 47 | mainWindow = new BrowserWindow({ 48 | // Size 49 | x: mainWindowState.x, 50 | y: mainWindowState.y, 51 | width: mainWindowState.width, 52 | height: mainWindowState.height, 53 | minWidth: 1000, 54 | minHeight: 400, 55 | transparent: global.transparent, 56 | vibrancy: "sidebar", 57 | // Titlebar 58 | trafficLightPosition: { x: 16, y: 12 }, // for macOS 59 | ...(titleBarType === CONFIG_SETTINGS_TITLE_BAR_TYPES.OVERLAY && { 60 | titleBarStyle: "hidden", 61 | titleBarOverlay: TITLEBAR_OVERLAY.BASE, 62 | frame: false, 63 | }), 64 | // Other Options 65 | autoHideMenuBar: true, 66 | icon: global.AppIcon, 67 | webPreferences: { 68 | preload: path.join(app.getAppPath(), "src/process/preload.mjs"), 69 | webviewTag: true, 70 | }, 71 | }); 72 | mainWindow.loadFile(path.join(app.getAppPath(), "src/base/index.html")); 73 | mainWindow.on("ready-to-show", () => { 74 | mainWindow.webContents.send("set-flag", [ 75 | FLAGS.PLATFORM, 76 | process.platform, 77 | ]); 78 | mainWindow.webContents.send("set-flag", [ 79 | FLAGS.TITLE_BAR_TYPE, 80 | titleBarType, 81 | ]); 82 | }); 83 | 84 | // IPC Functions 85 | ipcMain.on("ReloadApp", () => { 86 | mainWindow.reload(); 87 | }); 88 | ipcMain.on("MaximizeWindow", () => { 89 | mainWindow.maximize(); 90 | }); 91 | ipcMain.on("UnmaximizeWindow", () => { 92 | mainWindow.restore(); 93 | }); 94 | ipcMain.on("MinimizeWindow", () => { 95 | mainWindow.minimize(); 96 | }); 97 | ipcMain.on("OpenHelp", () => { 98 | shell.openExternal("https://github.com/author-more/penpot-desktop/wiki"); 99 | }); 100 | ipcMain.on("OpenOffline", () => { 101 | shell.openExternal( 102 | "https://github.com/author-more/penpot-desktop/wiki/Self%E2%80%90hosting", 103 | ); 104 | }); 105 | ipcMain.on("OpenCredits", () => { 106 | shell.openExternal( 107 | "https://github.com/author-more/penpot-desktop/wiki/Credits", 108 | ); 109 | }); 110 | ipcMain.on("openTabMenu", (_event, tabId) => { 111 | const tabMenu = getTabMenu(tabId); 112 | tabMenu.popup({ 113 | window: mainWindow, 114 | }); 115 | }); 116 | ipcMain.on("set-theme", (_event, themeId) => { 117 | nativeTheme.themeSource = themeId; 118 | 119 | if (titleBarType === CONFIG_SETTINGS_TITLE_BAR_TYPES.OVERLAY) { 120 | mainWindow.setTitleBarOverlay?.({ 121 | ...TITLEBAR_OVERLAY.BASE, 122 | ...(nativeTheme.shouldUseDarkColors 123 | ? TITLEBAR_OVERLAY.DARK 124 | : TITLEBAR_OVERLAY.LIGHT), 125 | }); 126 | } 127 | }); 128 | 129 | mainWindow.on("enter-full-screen", () => { 130 | mainWindow.webContents.send("set-flag", [FLAGS.FULL_SCREEN, true]); 131 | }); 132 | mainWindow.on("leave-full-screen", () => { 133 | mainWindow.webContents.send("set-flag", [FLAGS.FULL_SCREEN, false]); 134 | }); 135 | mainWindow.on("focus", () => { 136 | mainWindow.webContents.send("set-flag", [FLAGS.FOCUS, true]); 137 | }); 138 | mainWindow.on("blur", () => { 139 | mainWindow.webContents.send("set-flag", [FLAGS.FOCUS, false]); 140 | }); 141 | 142 | mainWindowState.manage(mainWindow); 143 | setAppMenu(); 144 | }, 145 | }; 146 | -------------------------------------------------------------------------------- /src/process/settings.js: -------------------------------------------------------------------------------- 1 | import { app, dialog, ipcMain, shell } from "electron"; 2 | import { observe } from "../tools/object.js"; 3 | import { duplicateConfig, readConfig, writeConfig } from "./config.js"; 4 | import { z, ZodError } from "zod"; 5 | import { DEFAULT_INSTANCE } from "../shared/instance.js"; 6 | import { getMainWindow } from "./window.js"; 7 | import { HSLA_REGEXP } from "../tools/color.js"; 8 | import { CONFIG_SETTINGS_TITLE_BAR_TYPES } from "../shared/settings.js"; 9 | import { instanceIdSchema } from "./instance.js"; 10 | 11 | const CONFIG_SETTINGS_NAME = "settings"; 12 | const CONFIG_SETTINGS_ENTRY_NAMES = Object.freeze([ 13 | "theme", 14 | "titleBarType", 15 | "instances", 16 | "enableAutoReload", 17 | "enableViewModeWindow", 18 | ]); 19 | 20 | const titleBarTypes = Object.values(CONFIG_SETTINGS_TITLE_BAR_TYPES); 21 | 22 | const settingsSchema = z.object({ 23 | theme: z.enum(["light", "dark", "system", "tab"]).default("system"), 24 | titleBarType: z 25 | .enum([titleBarTypes[0], ...titleBarTypes.slice(1)]) 26 | .optional() 27 | .default(CONFIG_SETTINGS_TITLE_BAR_TYPES.OVERLAY), 28 | enableAutoReload: z.boolean().default(false), 29 | enableViewModeWindow: z.boolean().default(false), 30 | instances: z 31 | .array( 32 | z 33 | .object({ 34 | id: instanceIdSchema.default(() => crypto.randomUUID()), 35 | origin: z.url().default(DEFAULT_INSTANCE.origin), 36 | label: z.string().default("Your instance"), 37 | color: z 38 | .string() 39 | .trim() 40 | // For settings with the old, invalid, default color value, updates the setting and prevents the settings invalidation. 41 | .transform((value) => { 42 | const isOldDefault = value === "hsla(0,0,0,0)"; 43 | 44 | return isOldDefault ? DEFAULT_INSTANCE.color : value; 45 | }) 46 | .pipe( 47 | z 48 | .string() 49 | .regex( 50 | HSLA_REGEXP, 51 | `Invalid format. Currently, only the legacy format (with comma separated values), without optional units (deg), is supported. For example, ${DEFAULT_INSTANCE.color}.`, 52 | ), 53 | ) 54 | .default(DEFAULT_INSTANCE.color), 55 | isDefault: z.boolean().default(false), 56 | }) 57 | .prefault({}), 58 | ) 59 | .default([]), 60 | }); 61 | 62 | /** 63 | * @typedef {z.infer} Settings 64 | */ 65 | const userSettings = await getUserSettings(); 66 | writeConfig(CONFIG_SETTINGS_NAME, userSettings); 67 | 68 | export const settings = observe(userSettings, (newSettings) => { 69 | writeConfig(CONFIG_SETTINGS_NAME, newSettings); 70 | }); 71 | 72 | ipcMain.handle( 73 | "setting:get", 74 | /** 75 | * @template {keyof Settings} S 76 | * 77 | * @function 78 | * @param {import("electron").IpcMainInvokeEvent} _event 79 | * @param {S} setting 80 | * 81 | * @returns {Settings[S] | undefined} 82 | */ 83 | (_event, setting) => { 84 | const isAllowedSetting = CONFIG_SETTINGS_ENTRY_NAMES.includes(setting); 85 | 86 | if (isAllowedSetting) { 87 | return settings[setting]; 88 | } 89 | }, 90 | ); 91 | 92 | ipcMain.on( 93 | "setting:set", 94 | /** 95 | * @template {keyof Settings} S 96 | * 97 | * @function 98 | * @param {import("electron").IpcMainEvent} _event 99 | * @param {S} setting 100 | * @param {Settings[S]} value 101 | */ 102 | (_event, setting, value) => { 103 | const isAllowedSetting = CONFIG_SETTINGS_ENTRY_NAMES.includes(setting); 104 | 105 | if (isAllowedSetting) { 106 | settings[setting] = value; 107 | } 108 | }, 109 | ); 110 | 111 | async function getUserSettings() { 112 | let settings; 113 | 114 | try { 115 | /** @type {Settings | Record} */ 116 | const userSettings = (await readConfig(CONFIG_SETTINGS_NAME)) || {}; 117 | 118 | settings = settingsSchema.parse(userSettings); 119 | } catch (error) { 120 | settings = settingsSchema.parse({}); 121 | 122 | if (error instanceof Error) { 123 | duplicateConfig(CONFIG_SETTINGS_NAME, "old"); 124 | 125 | app.whenReady().then(() => { 126 | showSettingsValidationIssue(error); 127 | }); 128 | } 129 | } 130 | 131 | const hasInstances = !!settings.instances[0]; 132 | if (!hasInstances) { 133 | settings.instances.push({ 134 | ...DEFAULT_INSTANCE, 135 | id: crypto.randomUUID(), 136 | }); 137 | } 138 | 139 | const hasOneInstance = settings.instances.length === 1; 140 | if (hasOneInstance) { 141 | settings.instances[0].isDefault = true; 142 | } 143 | 144 | return settings; 145 | } 146 | 147 | /** 148 | * @param {Error} error 149 | */ 150 | function showSettingsValidationIssue(error) { 151 | const mainWindow = getMainWindow(); 152 | 153 | const isZodError = error instanceof ZodError; 154 | const errorDetails = 155 | (isZodError && 156 | error.issues 157 | .map(({ path, message }) => { 158 | const dataInfo = path.join("/"); 159 | 160 | return `${dataInfo}: ${message}`; 161 | }) 162 | .join("\n")) || 163 | error.message; 164 | 165 | const DIALOG_DECISIONS = Object.freeze({ 166 | CONFIRM: 0, 167 | REPORT: 1, 168 | }); 169 | const decision = dialog.showMessageBoxSync(mainWindow, { 170 | type: "error", 171 | title: "Settings Error", 172 | message: `The app encountered an issue with your settings.`, 173 | detail: `The settings file contains invalid data and was backed up to "settings.old.json". The app will use the default settings.\n\nReported errors:\n${errorDetails}\n\n If you didn't manually edit the settings file, please report this issue.`, 174 | buttons: ["OK", "Report"], 175 | defaultId: DIALOG_DECISIONS.CONFIRM, 176 | cancelId: DIALOG_DECISIONS.CONFIRM, 177 | }); 178 | 179 | const isReport = decision === DIALOG_DECISIONS.REPORT; 180 | if (isReport) { 181 | shell.openExternal("https://github.com/author-more/penpot-desktop/issues"); 182 | return; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/process/menu.js: -------------------------------------------------------------------------------- 1 | import { app, Menu, shell } from "electron"; 2 | import { getMainWindow } from "./window.js"; 3 | import { showDiagnostics } from "./diagnostics.js"; 4 | 5 | /** 6 | * @typedef {import("electron").MenuItemConstructorOptions} MenuItemConstructorOptions 7 | */ 8 | 9 | export function setAppMenu() { 10 | const mainWindow = getMainWindow(); 11 | //TypeScript has problems evaluating types with ternary operators in the menu template, hence the push method solution. 12 | const isMacOs = process.platform === "darwin"; 13 | 14 | /** 15 | * @type {MenuItemConstructorOptions[]} 16 | */ 17 | const template = []; 18 | 19 | /** @type {MenuItemConstructorOptions} */ 20 | const aboutMenu = { 21 | label: app.name, 22 | submenu: [ 23 | { role: "about" }, 24 | { type: "separator" }, 25 | { role: "services" }, 26 | { type: "separator" }, 27 | { role: "hide" }, 28 | { role: "hideOthers" }, 29 | { role: "unhide" }, 30 | { type: "separator" }, 31 | { role: "quit" }, 32 | ], 33 | }; 34 | 35 | /** @type {MenuItemConstructorOptions} */ 36 | const fileMenu = { 37 | label: "File", 38 | submenu: [ 39 | { 40 | label: "New Tab", 41 | accelerator: "CmdOrCtrl+T", 42 | click: () => { 43 | mainWindow.webContents.executeJavaScript( 44 | `document.querySelector("tab-group").shadowRoot.querySelector("div > nav > div.buttons > button").click()`, 45 | ); 46 | }, 47 | }, 48 | { 49 | label: "Close Tab", 50 | accelerator: "CmdOrCtrl+W", 51 | click: () => { 52 | mainWindow.webContents.executeJavaScript( 53 | `document.querySelector("tab-group").shadowRoot.querySelector("div > nav > div.tabs > div.tab.visible.active > span.tab-close > button").click()`, 54 | ); 55 | }, 56 | }, 57 | { type: "separator" }, 58 | { 59 | label: "Quit", 60 | accelerator: "CmdOrCtrl+Q", 61 | click: () => { 62 | app.quit(); 63 | }, 64 | }, 65 | ], 66 | }; 67 | 68 | /** @type {MenuItemConstructorOptions} */ 69 | const editMenu = { 70 | label: "Edit", 71 | submenu: [ 72 | { role: "undo" }, 73 | { role: "redo" }, 74 | { type: "separator" }, 75 | { role: "cut" }, 76 | { role: "copy" }, 77 | { role: "paste" }, 78 | ], 79 | }; 80 | 81 | if (Array.isArray(editMenu.submenu)) { 82 | if (isMacOs) { 83 | editMenu.submenu.push( 84 | { role: "pasteAndMatchStyle" }, 85 | { role: "delete" }, 86 | { role: "selectAll" }, 87 | { type: "separator" }, 88 | { 89 | label: "Speech", 90 | submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], 91 | }, 92 | ); 93 | } else { 94 | editMenu.submenu.push( 95 | { role: "delete" }, 96 | { type: "separator" }, 97 | { role: "selectAll" }, 98 | ); 99 | } 100 | } 101 | 102 | /** @type {MenuItemConstructorOptions} */ 103 | const viewMenu = { 104 | label: "View", 105 | submenu: [ 106 | { 107 | label: "Reload Tab", 108 | accelerator: "CmdOrCtrl+R", 109 | click: async () => { 110 | mainWindow.webContents.executeJavaScript( 111 | `document.querySelector("tab-group").shadowRoot.querySelector("webview.visible").reload()`, 112 | ); 113 | }, 114 | }, 115 | { 116 | label: "Reload Window", 117 | accelerator: "CmdOrCtrl+Shift+R", 118 | click: async () => { 119 | mainWindow.reload(); 120 | }, 121 | }, 122 | { role: "toggleDevTools" }, 123 | { 124 | label: "Open Tab Developer Tools", 125 | accelerator: "CmdOrCtrl+Shift+D", 126 | click: () => { 127 | mainWindow.webContents.executeJavaScript( 128 | `document.querySelector("body > #include-tabs > tab-group").shadowRoot.querySelector("div > div > webview.visible").openDevTools()`, 129 | ); 130 | }, 131 | }, 132 | { 133 | label: "Toggle Diagnostics", 134 | accelerator: "CmdOrCtrl+Shift+Y", 135 | click: () => { 136 | showDiagnostics(mainWindow); 137 | }, 138 | }, 139 | { type: "separator" }, 140 | { role: "resetZoom" }, 141 | { role: "zoomIn" }, 142 | { role: "zoomOut" }, 143 | { type: "separator" }, 144 | { role: "togglefullscreen" }, 145 | ], 146 | }; 147 | 148 | /** @type {MenuItemConstructorOptions} */ 149 | const windowMenu = { 150 | label: "Window", 151 | submenu: [{ role: "minimize" }, { role: "zoom" }], 152 | }; 153 | 154 | if (Array.isArray(windowMenu.submenu)) { 155 | if (isMacOs) { 156 | windowMenu.submenu.push( 157 | { type: "separator" }, 158 | { role: "front" }, 159 | { type: "separator" }, 160 | { 161 | role: "close", 162 | accelerator: "CmdOrCtrl+Shift+W", 163 | }, 164 | ); 165 | } else { 166 | windowMenu.submenu.push({ 167 | role: "close", 168 | accelerator: "CmdOrCtrl+Shift+W", 169 | }); 170 | } 171 | } 172 | 173 | /** @type {MenuItemConstructorOptions} */ 174 | const helpMenu = { 175 | role: "help", 176 | submenu: [ 177 | { 178 | label: "User Guide", 179 | click: () => { 180 | shell.openExternal("https://help.penpot.app/user-guide/"); 181 | }, 182 | }, 183 | { 184 | label: "FAQ", 185 | click: () => { 186 | shell.openExternal("https://help.penpot.app/faqs"); 187 | }, 188 | }, 189 | { 190 | label: "Learn to Self-host", 191 | click: () => { 192 | shell.openExternal("https://penpot.app/self-host"); 193 | }, 194 | }, 195 | { 196 | label: "Penpot Community", 197 | click: () => { 198 | shell.openExternal("https://community.penpot.app/"); 199 | }, 200 | }, 201 | { type: "separator" }, 202 | { 203 | label: "Source Code", 204 | click: () => { 205 | shell.openExternal("https://github.com/author-more/penpot-desktop"); 206 | }, 207 | }, 208 | ], 209 | }; 210 | 211 | if (isMacOs) { 212 | template.push(aboutMenu); 213 | } 214 | template.push(fileMenu); 215 | template.push(editMenu); 216 | template.push(viewMenu); 217 | template.push(windowMenu); 218 | template.push(helpMenu); 219 | 220 | const menu = Menu.buildFromTemplate(template); 221 | Menu.setApplicationMenu(menu); 222 | } 223 | 224 | /** 225 | * @param {number} tabId 226 | */ 227 | export function getTabMenu(tabId) { 228 | const mainWindow = getMainWindow(); 229 | 230 | /** @type {(command: string) => void} */ 231 | const dispatchAction = (command) => 232 | mainWindow.webContents.send("tab:menu-action", { 233 | command, 234 | tabId, 235 | }); 236 | 237 | return Menu.buildFromTemplate([ 238 | { 239 | label: "Reload Tab", 240 | click: () => dispatchAction("reload-tab"), 241 | }, 242 | { 243 | label: "Duplicate Tab", 244 | click: () => dispatchAction("duplicate-tab"), 245 | }, 246 | { type: "separator" }, 247 | { 248 | label: "Close Other Tabs", 249 | click: () => dispatchAction("close-tabs-other"), 250 | }, 251 | { 252 | label: "Close Tabs To Right", 253 | click: () => dispatchAction("close-tabs-right"), 254 | }, 255 | { 256 | label: "Close Tabs To Left", 257 | click: () => dispatchAction("close-tabs-left"), 258 | }, 259 | ]); 260 | } 261 | -------------------------------------------------------------------------------- /src/process/docker.js: -------------------------------------------------------------------------------- 1 | import { promisify } from "node:util"; 2 | import child_process from "node:child_process"; 3 | import path from "node:path"; 4 | import { app } from "electron"; 5 | import { AppError, ERROR_CODES } from "../tools/error.js"; 6 | import { getCommandPath } from "./path.js"; 7 | import { constants as fsConstants, copyFile } from "node:fs/promises"; 8 | import { sudoExec } from "./childProcess.js"; 9 | import { isLinux } from "./platform.js"; 10 | 11 | const exec = promisify(child_process.exec); 12 | 13 | /** 14 | * @typedef {Object} TagImage 15 | * @property {string} architecture 16 | * @property {string} features 17 | * @property {string|null} variant 18 | * @property {string} digest 19 | * @property {string} os 20 | * @property {string} os_features 21 | * @property {string|null} os_version 22 | * @property {number} size 23 | * @property {string} status 24 | * @property {string} last_pulled 25 | * @property {string} last_pushed 26 | */ 27 | 28 | /** 29 | * @typedef {Object} Tag 30 | * @property {number} creator 31 | * @property {number} id 32 | * @property {TagImage[]} images 33 | * @property {string} last_updated 34 | * @property {number} last_updater 35 | * @property {string} last_updater_username 36 | * @property {string} name 37 | * @property {number} repository 38 | * @property {number} full_size 39 | * @property {boolean} v2 40 | * @property {string} tag_status 41 | * @property {string} tag_last_pulled 42 | * @property {string} tag_last_pushed 43 | * @property {string} media_type 44 | * @property {string} content_type 45 | * @property {string} digest 46 | */ 47 | 48 | /** 49 | * @typedef {Object} Tags 50 | * @property {number} count 51 | * @property {string|null} next 52 | * @property {string|null} previous 53 | * @property {Tag[]} results 54 | */ 55 | 56 | /** 57 | * @typedef {import("zod").z.infer} LocalInstanceConfig 58 | * 59 | * @typedef {Object} CommandOptions 60 | * @property {boolean =} isSudoEnabled 61 | * @property {boolean =} isInstanceTelemetryEnabled 62 | */ 63 | 64 | export const DOCKER_REPOSITORIES = Object.freeze({ 65 | FRONTEND: "penpotapp/frontend", 66 | }); 67 | 68 | const sudoOptions = { 69 | name: "Penpot Desktop", 70 | }; 71 | const dockerPath = await getCommandPath("docker"); 72 | 73 | export async function isDockerAvailable() { 74 | if (!dockerPath) { 75 | return false; 76 | } 77 | 78 | try { 79 | await exec(`"${dockerPath}" --version`); 80 | await exec(`"${dockerPath}" compose version`); 81 | 82 | return true; 83 | } catch (error) { 84 | return false; 85 | } 86 | } 87 | 88 | /** 89 | * Retrieves the list of available Docker tags. 90 | * 91 | * @param {string} repository 92 | */ 93 | export async function getAvailableTags(repository) { 94 | try { 95 | const res = await fetch( 96 | `https://hub.docker.com/v2/repositories/${repository}/tags`, 97 | ); 98 | 99 | if (!res.ok) { 100 | return []; 101 | } 102 | 103 | const { results } = /** @type {Tags}*/ (await res.json()); 104 | const tags = results.map(({ name }) => name); 105 | 106 | return tags; 107 | } catch (error) { 108 | return []; 109 | } 110 | } 111 | /** 112 | * Checks if a specific Docker tag is available. 113 | * 114 | * @param {string} repository 115 | * @param {string} tag 116 | */ 117 | export async function isTagAvailable(repository, tag) { 118 | try { 119 | const res = await fetch( 120 | `https://hub.docker.com/v2/repositories/${repository}/tags/${tag}`, 121 | ); 122 | 123 | if (!res.ok) { 124 | return false; 125 | } 126 | 127 | const { tag_status } = /** @type {Tag}*/ (await res.json()); 128 | const isActive = tag_status === "active"; 129 | 130 | return isActive; 131 | } catch (error) { 132 | return false; 133 | } 134 | } 135 | 136 | /** 137 | * Creates and starts containers 138 | * 139 | * @typedef {LocalInstanceConfig["ports"]} ContainerPorts 140 | * 141 | * @param {"up" | "pull"} command 142 | * @param {string} containerNamePrefix 143 | * @param {Tag["name"]} tag 144 | * @param {ContainerPorts} ports 145 | * @param {CommandOptions} options 146 | */ 147 | export async function compose( 148 | command, 149 | containerNamePrefix, 150 | tag, 151 | { frontend: frontendPort, mailcatch: mailcatchPort }, 152 | { isSudoEnabled, isInstanceTelemetryEnabled } = {}, 153 | ) { 154 | if (!dockerPath) { 155 | throw new AppError(ERROR_CODES.MISSING_DOCKER, "Docker command not found."); 156 | } 157 | 158 | if (!(await isTagAvailable(DOCKER_REPOSITORIES.FRONTEND, tag))) { 159 | throw new AppError( 160 | ERROR_CODES.DOCKER_TAG_UNAVAILABLE, 161 | `Tag ${tag} is not available.`, 162 | ); 163 | } 164 | 165 | const dockerComposeFilePath = await deployComposeFile(); 166 | const instanceTelemetryFlag = `${isInstanceTelemetryEnabled ? "enable" : "disable"}-telemetry`; 167 | 168 | const envVariables = { 169 | PENPOT_VERSION: `${tag}`, 170 | PENPOT_DESKTOP_FRONTEND_PORT: `${frontendPort}`, 171 | PENPOT_DESKTOP_MAILCATCH_PORT: `${mailcatchPort}`, 172 | PENPOT_DESKTOP_FLAGS: `${instanceTelemetryFlag}`, 173 | PENPOT_DESKTOP_BACKEND_TELEMETRY: `${isInstanceTelemetryEnabled}`, 174 | }; 175 | const envVariablesCommandString = Object.entries(envVariables).reduce( 176 | (envVarString, [key, value]) => { 177 | return `${envVarString} ${key}=${value}`; 178 | }, 179 | "", 180 | ); 181 | const commandString = command === "up" ? "up -d" : "pull"; 182 | const dockerComposeCommand = `"${dockerPath}" compose -p ${containerNamePrefix} -f "${dockerComposeFilePath}" ${commandString}`; 183 | 184 | try { 185 | const optionEnv = { 186 | env: { ...process.env, ...envVariables }, 187 | }; 188 | 189 | if (isSudoEnabled) { 190 | // Variables from the `env` option are excluded by `pkexec` and `kdesudo`. On Linux they will be set with the command. 191 | const command = isLinux() 192 | ? `${envVariablesCommandString} ${dockerComposeCommand}` 193 | : dockerComposeCommand; 194 | 195 | await sudoExec(command, { 196 | ...sudoOptions, 197 | ...(!isLinux() && optionEnv), 198 | }); 199 | } else { 200 | await exec(dockerComposeCommand, { 201 | ...optionEnv, 202 | }); 203 | } 204 | } catch (error) { 205 | const message = 206 | error instanceof Error 207 | ? error.message 208 | : `Failed to run Docker's compose ${command} command.`; 209 | 210 | throw new AppError(ERROR_CODES.DOCKER_FAILED_SETUP, message); 211 | } 212 | } 213 | 214 | async function deployComposeFile() { 215 | const fileName = "docker-compose.yaml"; 216 | const composeFileAsarPath = path.join(app.getAppPath(), "bin", fileName); 217 | const deployPath = path.join(app.getPath("userData"), fileName); 218 | 219 | try { 220 | await copyFile(composeFileAsarPath, deployPath, fsConstants.COPYFILE_EXCL); 221 | } catch (error) { 222 | const isError = error instanceof Error; 223 | const isExistingFile = 224 | isError && "code" in error && error.code === "EEXIST"; 225 | 226 | if (!isExistingFile) { 227 | throw new AppError( 228 | ERROR_CODES.FAILED_CONFIG_DEPLOY, 229 | "Failed to deploy Docker Compose config.", 230 | ); 231 | } 232 | } 233 | 234 | return deployPath; 235 | } 236 | -------------------------------------------------------------------------------- /src/process/navigation.js: -------------------------------------------------------------------------------- 1 | import { app, shell, dialog } from "electron"; 2 | import { URL } from "url"; 3 | import { join } from "path"; 4 | import { toMultiline } from "./string.js"; 5 | import { getMainWindow } from "./window.js"; 6 | import { settings } from "./settings.js"; 7 | import { isViewModeUrl } from "../tools/penpot.js"; 8 | 9 | // Covered origins and URLs are scoped to the Penpot web app (e.g. links in the Menu > Help & info). 10 | const ALLOWED_INTERNAL_ORIGINS = Object.freeze([ 11 | "https://penpot.app", 12 | "https://help.penpot.app", 13 | ]); 14 | const ALLOWED_AUTH_ORIGINS = Object.freeze([ 15 | "https://accounts.google.com", 16 | "https://github.com", 17 | "https://gitlab.com", 18 | ]); 19 | const ALLOWED_EXTERNAL_URLS = Object.freeze([ 20 | "https://community.penpot.app/", 21 | "https://www.youtube.com/c/Penpot", // Tutorials 22 | "https://github.com/penpot/penpot", 23 | // Local instance instructions 24 | "https://docs.docker.com/get-started/get-docker/", 25 | ]); 26 | 27 | app.on("web-contents-created", (event, contents) => { 28 | const mainWindow = getMainWindow(); 29 | 30 | // Open links in a new tab or a browser, instead of a new window 31 | contents.setWindowOpenHandler(({ url }) => { 32 | const parsedUrl = new URL(url); 33 | const isAllowedOrigin = [ 34 | ...ALLOWED_INTERNAL_ORIGINS, 35 | ...getUserInstanceOrigins(settings), 36 | ].includes(parsedUrl.origin); 37 | const isAllowedExternal = ALLOWED_EXTERNAL_URLS.includes(parsedUrl.href); 38 | const isAllowedNavigation = isAllowedOrigin || isAllowedExternal; 39 | 40 | if (isAllowedOrigin) { 41 | if (isViewModeUrl(parsedUrl) && settings.enableViewModeWindow) { 42 | return { 43 | action: "allow", 44 | overrideBrowserWindowOptions: { autoHideMenuBar: true }, 45 | }; 46 | } 47 | 48 | mainWindow.webContents.send("tab:open", parsedUrl.href); 49 | } else { 50 | console.warn( 51 | `[WARNING] [app.web-contents-created.setWindowOpenHandler] Forbidden origin: ${parsedUrl.origin}`, 52 | ); 53 | } 54 | 55 | if (isAllowedExternal) { 56 | shell.openExternal(parsedUrl.href); 57 | } else { 58 | console.warn( 59 | `[WARNING] [app.web-contents-created.setWindowOpenHandler] Forbidden external URL: ${parsedUrl.href}`, 60 | ); 61 | } 62 | 63 | if (!isAllowedNavigation) { 64 | console.error( 65 | `[ERROR] [app.web-contents-created.setWindowOpenHandler] Forbidden navigation.`, 66 | ); 67 | 68 | showNavigationQuestion(parsedUrl.href, { 69 | buttons: ["Open in a browser"], 70 | onAllow: () => shell.openExternal(parsedUrl.href), 71 | logLabel: "app.web-contents-created.setWindowOpenHandler", 72 | }); 73 | } 74 | 75 | return { action: "deny" }; 76 | }); 77 | 78 | // Limit navigation within the app 79 | contents.on("will-navigate", (event, url) => { 80 | const parsedUrl = new URL(url); 81 | const isAllowedOrigin = [ 82 | ...ALLOWED_INTERNAL_ORIGINS, 83 | ...ALLOWED_AUTH_ORIGINS, 84 | ...getUserInstanceOrigins(settings), 85 | ].includes(parsedUrl.origin); 86 | 87 | if (!isAllowedOrigin) { 88 | console.error( 89 | `[ERROR] [app.web-contents-created.will-navigate] Forbidden origin: ${parsedUrl.origin}`, 90 | ); 91 | 92 | showNavigationQuestion(parsedUrl.href, { 93 | buttons: ["Open"], 94 | onCancel: () => event.preventDefault(), 95 | logLabel: "app.web-contents-created.will-navigate", 96 | }); 97 | } 98 | }); 99 | 100 | contents.on("will-redirect", (event) => { 101 | const internalOrigins = [ 102 | ...ALLOWED_INTERNAL_ORIGINS, 103 | ...getUserInstanceOrigins(settings), 104 | ]; 105 | const currentUrl = contents.getURL(); 106 | 107 | // A new/empty tab doesn't have a URL before redirect to its initial page. 108 | if (!currentUrl) { 109 | return; 110 | } 111 | 112 | const parsedCurrentUrl = new URL(currentUrl); 113 | const parsedUrl = new URL(event.url); 114 | const isFromInternalOrigin = internalOrigins.includes( 115 | parsedCurrentUrl.origin, 116 | ); 117 | const isToInternalOrigin = internalOrigins.includes(parsedUrl.origin); 118 | 119 | if (!isFromInternalOrigin && isToInternalOrigin) { 120 | // Prevents Electron from holding sessions for external services e.g. OpenID providers. 121 | console.log("Clear non-instance origins data."); 122 | 123 | contents.session.clearData({ 124 | excludeOrigins: [...getUserInstanceOrigins(settings)], 125 | }); 126 | } 127 | }); 128 | 129 | contents.on("will-attach-webview", (event, webPreferences, params) => { 130 | webPreferences.allowRunningInsecureContent = false; 131 | webPreferences.contextIsolation = true; 132 | webPreferences.enableBlinkFeatures = ""; 133 | webPreferences.experimentalFeatures = false; 134 | webPreferences.nodeIntegration = false; 135 | webPreferences.nodeIntegrationInSubFrames = false; 136 | webPreferences.nodeIntegrationInWorker = false; 137 | webPreferences.sandbox = true; 138 | webPreferences.webSecurity = true; 139 | 140 | const allowedPreloadScriptPath = join( 141 | app.getAppPath(), 142 | "src/base/scripts/webviews/preload.mjs", 143 | ); 144 | const isAllowedPreloadScript = 145 | !webPreferences.preload || 146 | webPreferences.preload === allowedPreloadScriptPath; 147 | 148 | if (!isAllowedPreloadScript) { 149 | console.warn( 150 | `[WARNING] [app.web-contents-created.will-attach-webview] Forbidden preload script.`, 151 | ); 152 | 153 | delete webPreferences.preload; 154 | } 155 | 156 | const parsedSrc = new URL(params.src); 157 | const isAllowedOrigin = [ 158 | ...ALLOWED_INTERNAL_ORIGINS, 159 | ...getUserInstanceOrigins(settings), 160 | ].includes(parsedSrc.origin); 161 | 162 | if (!isAllowedOrigin) { 163 | console.error( 164 | `[ERROR] [app.web-contents-created.will-attach-webview] Forbidden origin: ${parsedSrc.origin}`, 165 | ); 166 | 167 | event.preventDefault(); 168 | } 169 | }); 170 | }); 171 | 172 | /** 173 | * Presents a question dialog about the given url and executes selected action. 174 | * 175 | * @typedef {() => void} Callback 176 | * 177 | * @param {string} url 178 | * @param {{ buttons: [string], onCancel?: Callback, onAllow?: Callback, logLabel?: string}} options 179 | */ 180 | function showNavigationQuestion(url, { buttons, onCancel, onAllow, logLabel }) { 181 | const mainWindow = getMainWindow(); 182 | 183 | const DIALOG_NAVIGATION_ANSWERS = Object.freeze({ 184 | CANCEL: 0, 185 | ALLOW: 1, 186 | }); 187 | const decision = dialog.showMessageBoxSync(mainWindow, { 188 | type: "question", 189 | title: "Navigation request", 190 | message: `Do you want to open this website?`, 191 | detail: toMultiline(url), 192 | buttons: ["Cancel", ...buttons], 193 | defaultId: DIALOG_NAVIGATION_ANSWERS.CANCEL, 194 | cancelId: DIALOG_NAVIGATION_ANSWERS.CANCEL, 195 | }); 196 | 197 | switch (decision) { 198 | case DIALOG_NAVIGATION_ANSWERS.ALLOW: 199 | console.log(`[INFO] [${logLabel}.navigation-question] Allow`); 200 | onAllow?.(); 201 | break; 202 | case DIALOG_NAVIGATION_ANSWERS.CANCEL: 203 | default: 204 | console.log(`[INFO] [${logLabel}.navigation-question] Cancel`); 205 | onCancel?.(); 206 | } 207 | } 208 | 209 | /** 210 | * @param {import("./settings.js").Settings} settings 211 | */ 212 | function getUserInstanceOrigins(settings) { 213 | return new Set(settings.instances.map(({ origin }) => origin)); 214 | } 215 | -------------------------------------------------------------------------------- /tests/settings.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElectronApplication, expect, Page, test } from "@playwright/test"; 2 | import { describe } from "node:test"; 3 | import { launchElectronApp } from "./utils/app.js"; 4 | import { getFile, saveFile } from "./utils/fs.js"; 5 | import { join } from "node:path"; 6 | 7 | let electronApp: ElectronApplication; 8 | let userDataPath: string; 9 | 10 | const CONFIG_NAME = "settings.json"; 11 | 12 | const DEFAULT_CONFIG = { 13 | theme: "system", 14 | titleBarType: "overlay", 15 | instances: [ 16 | { 17 | origin: "http://localhost:9008", 18 | label: "Official", 19 | color: "hsla(0, 0%, 0%, 0)", 20 | isDefault: false, 21 | id: "f6657e6b-4b07-4320-8a3a-9ead32f9549a", 22 | }, 23 | ], 24 | }; 25 | 26 | test.beforeEach(async () => { 27 | electronApp = await launchElectronApp(); 28 | userDataPath = await electronApp.evaluate(async ({ app }) => { 29 | return app.getPath("userData"); 30 | }); 31 | }); 32 | 33 | test.afterEach(async () => { 34 | saveFile(join(userDataPath, CONFIG_NAME), DEFAULT_CONFIG); 35 | 36 | await electronApp.close(); 37 | }); 38 | 39 | describe("settings", () => { 40 | const openSettings = async (page: Page) => { 41 | const toggleButton = page.getByRole("button", { 42 | name: "Toggle settings", 43 | }); 44 | 45 | toggleButton.waitFor({ state: "visible" }); 46 | await toggleButton.click(); 47 | 48 | const sidePanel = page.locator("sl-drawer#settings"); 49 | expect(await sidePanel.isVisible()).toBeTruthy(); 50 | }; 51 | 52 | test("should open", async () => { 53 | const window = await electronApp.firstWindow(); 54 | 55 | await openSettings(window); 56 | }); 57 | 58 | test("should control theme", async () => { 59 | const window = await electronApp.firstWindow(); 60 | 61 | await openSettings(window); 62 | 63 | const selector = window.locator("sl-select#theme-select"); 64 | await selector.waitFor({ state: "visible" }); 65 | 66 | for (const newValue of ["light", "dark", "system"]) { 67 | await selector.click(); 68 | 69 | const option = selector.locator(`sl-option[value="${newValue}"]`); 70 | await option.waitFor({ state: "visible" }); 71 | await option.click(); 72 | await option.waitFor({ state: "hidden" }); 73 | 74 | const appThemePropertyValue = await electronApp.evaluate( 75 | async ({ nativeTheme }) => { 76 | return nativeTheme.themeSource; 77 | }, 78 | ); 79 | 80 | expect(appThemePropertyValue).toBe(newValue); 81 | 82 | const config = await getFile(join(userDataPath, CONFIG_NAME)); 83 | expect(config.theme).toBe(newValue); 84 | } 85 | }); 86 | 87 | describe("instance", () => { 88 | test("should add/remove item", async () => { 89 | const window = await electronApp.firstWindow(); 90 | 91 | await openSettings(window); 92 | 93 | const itemList = window.locator("#instance-list .panel"); 94 | await expect(itemList).toHaveCount(1); 95 | 96 | const addItemButton = window.getByRole("button", { 97 | name: "Add instance", 98 | }); 99 | await addItemButton.click(); 100 | 101 | await expect(itemList).toHaveCount(2); 102 | expect((await getConfig()).instances.length).toBe(2); 103 | 104 | const newItem = itemList.last(); 105 | const instanceSettingsButton = newItem.getByRole("button", { 106 | name: "Open settings", 107 | }); 108 | await instanceSettingsButton.click(); 109 | 110 | const instanceSettingsModal = window.locator( 111 | "sl-dialog#instance-creator-dialog", 112 | ); 113 | const deleteItemButton = instanceSettingsModal.getByRole("button", { 114 | name: "Delete instance", 115 | }); 116 | await deleteItemButton.click(); 117 | 118 | await expect(itemList).toHaveCount(1); 119 | expect((await getConfig()).instances.length).toBe(1); 120 | }); 121 | 122 | test("should edit label", async () => { 123 | const window = await electronApp.firstWindow(); 124 | const currentValue = "Official"; 125 | const newValue = "Local instance"; 126 | 127 | await openSettings(window); 128 | 129 | const itemList = window.locator("#instance-list .panel"); 130 | const item = itemList.first(); 131 | 132 | const instanceSettingsButton = item.getByRole("button", { 133 | name: "Open settings", 134 | }); 135 | await instanceSettingsButton.click(); 136 | 137 | const instanceSettingsModal = window.locator( 138 | "sl-dialog#instance-creator-dialog", 139 | ); 140 | 141 | const field = instanceSettingsModal.getByLabel("Label"); 142 | 143 | await expect(field).toHaveValue(currentValue); 144 | await field.fill(newValue); 145 | await expect(field).toHaveValue(newValue); 146 | 147 | const updateItemButton = instanceSettingsModal.getByRole("button", { 148 | name: "Update", 149 | }); 150 | await updateItemButton.click(); 151 | 152 | expect(item).toContainText(newValue); 153 | const config = await getFile(join(userDataPath, CONFIG_NAME)); 154 | expect(config.instances[0].label).toBe(newValue); 155 | }); 156 | 157 | test("should edit origin", async () => { 158 | const window = await electronApp.firstWindow(); 159 | const currentValue = "http://localhost:9008"; 160 | const newValue = "http://localhost:9009"; 161 | 162 | await openSettings(window); 163 | 164 | const itemList = window.locator("#instance-list .panel"); 165 | const item = itemList.first(); 166 | 167 | const instanceSettingsButton = item.getByRole("button", { 168 | name: "Open settings", 169 | }); 170 | await instanceSettingsButton.click(); 171 | 172 | const instanceSettingsModal = window.locator( 173 | "sl-dialog#instance-creator-dialog", 174 | ); 175 | 176 | const field = instanceSettingsModal.getByLabel("Origin"); 177 | 178 | await expect(field).toHaveValue(currentValue); 179 | await field.fill(newValue); 180 | await expect(field).toHaveValue(newValue); 181 | 182 | const updateItemButton = instanceSettingsModal.getByRole("button", { 183 | name: "Update", 184 | }); 185 | await updateItemButton.click(); 186 | 187 | expect(item).toContainText(newValue); 188 | const config = await getFile(join(userDataPath, CONFIG_NAME)); 189 | expect(config.instances[0].origin).toBe(newValue); 190 | }); 191 | 192 | test("should set default", async () => { 193 | const window = await electronApp.firstWindow(); 194 | 195 | await openSettings(window); 196 | 197 | const itemList = window.locator("#instance-list .panel"); 198 | const addItemButton = window.getByRole("button", { 199 | name: "Add instance", 200 | }); 201 | await addItemButton.click(); 202 | 203 | await expect(itemList).toHaveCount(2); 204 | expect((await getConfig()).instances[1].isDefault).toBe(false); 205 | 206 | const newItem = itemList.last(); 207 | newItem.click({ button: "right" }); 208 | 209 | const contextMenu = window.locator("#context-menu sl-menu"); 210 | await contextMenu.waitFor({ state: "visible" }); 211 | const setDefaultOption = contextMenu.getByRole("menuitem", { 212 | name: "Set as default", 213 | }); 214 | await setDefaultOption.click(); 215 | await contextMenu.waitFor({ state: "hidden" }); 216 | 217 | expect((await getConfig()).instances[1].isDefault).toBe(true); 218 | 219 | const instanceSettingsButton = newItem.getByRole("button", { 220 | name: "Open settings", 221 | }); 222 | await instanceSettingsButton.click(); 223 | 224 | const instanceSettingsModal = window.locator( 225 | "sl-dialog#instance-creator-dialog", 226 | ); 227 | const deleteItemButton = instanceSettingsModal.getByRole("button", { 228 | name: "Delete instance", 229 | }); 230 | 231 | await expect(deleteItemButton).toBeDisabled(); 232 | }); 233 | }); 234 | }); 235 | 236 | async function getConfig() { 237 | const configPath = join(userDataPath, CONFIG_NAME); 238 | return await getFile(configPath); 239 | } 240 | -------------------------------------------------------------------------------- /src/base/styles/theme.css: -------------------------------------------------------------------------------- 1 | @import url(./penpotSwatches.css); 2 | @import url(./shoelaceTokens.css); 3 | 4 | :root { 5 | /* Title bar overlay (window controls) size has to be updated manually. */ 6 | --top-bar-height: 40px; 7 | 8 | @media (prefers-color-scheme: light) { 9 | --color-background: var(--lb-primary); 10 | --color-primary: var(--la-primary); 11 | --color-neutral: var(--lf-primary); 12 | 13 | --button-color: var(--lf-secondary); 14 | --button-color-hover: var(--lf-primary); 15 | --button-background-color: var(--lb-tertiary); 16 | --button-background-color-hover: var(--lb-tertiary); 17 | --button-border-color-focus: var(--la-primary); 18 | 19 | --button-color-primary: var(--lb-secondary); 20 | --button-color-primary-hover: var(--lb-secondary); 21 | --button-background-color-primary: var(--la-primary); 22 | --button-background-color-primary-hover: var(--la-tertiary); 23 | 24 | --button-color-danger: var(--lf-secondary); 25 | --button-color-danger-hover: var(--lb-secondary); 26 | --button-background-color-danger: transparent; 27 | --button-background-color-danger-hover: var(--error-500); 28 | 29 | --link-color: var(--la-primary); 30 | --link-color-hover: var(--lb-secondary); 31 | --link-background-color-hover: var(--la-primary); 32 | 33 | --color-input-border-color: var(--la-primary-muted); 34 | --input-select-list-border-color: var(--lb-quaternary); 35 | 36 | --panel-background-color: var(--lb-tertiary); 37 | --panel-label-color: var(--lf-primary); 38 | --panel-hint-color: var(--lf-secondary); 39 | 40 | --menu-color: var(--lf-primary); 41 | --menu-background-color: var(--lb-quaternary); 42 | --menu-item-background-color-hover: var(--lb-secondary); 43 | 44 | --et-tab-color: var(--lf-primary); 45 | --et-tab-background-color: var(--lb-tertiary); 46 | /* Derived from --lb-secondary, darkened for higher contrast. */ 47 | --et-tab-background-color-active: hsl(220, 15%, 88%); 48 | --et-tab-border-color: var(--lf-secondary); 49 | 50 | --sl-input-background-color: var(--lb-tertiary); 51 | --sl-input-background-color-hover: var(--lb-quaternary); 52 | --sl-input-color: var(--lf-primary); 53 | --sl-input-color-hover: var(--lf-primary); 54 | --sl-input-icon-color: var(--lf-secondary); 55 | 56 | --sl-focus-ring-color: var(--la-primary); 57 | } 58 | 59 | @media (prefers-color-scheme: dark) { 60 | --color-background: var(--db-primary); 61 | --color-primary: var(--da-primary); 62 | --color-neutral: var(--df-primary); 63 | 64 | --button-color: var(--df-secondary); 65 | --button-color-hover: var(--da-primary); 66 | --button-background-color: var(--db-tertiary); 67 | --button-background-color-hover: var(--db-tertiary); 68 | 69 | --button-color-primary: var(--db-secondary); 70 | --button-color-primary-hover: var(--db-secondary); 71 | --button-background-color-primary: var(--da-primary); 72 | --button-background-color-primary-hover: var(--da-tertiary); 73 | 74 | --button-color-danger: var(--db-secondary); 75 | --button-color-danger-hover: var(--db-secondary); 76 | --button-background-color-danger: var(--error-500); 77 | --button-background-color-danger-hover: var(--error-700); 78 | 79 | --link-color: var(--da-primary); 80 | --link-color-hover: var(--db-secondary); 81 | --link-background-color-hover: var(--da-primary); 82 | 83 | --color-input-border-color: var(--da-primary-muted); 84 | --input-select-list-border-color: var(--db-quaternary); 85 | 86 | --panel-background-color: var(--db-tertiary); 87 | --panel-label-color: var(--df-primary); 88 | --panel-hint-color: var(--df-secondary); 89 | 90 | --menu-color: var(--df-primary); 91 | --menu-background-color: var(--db-tertiary); 92 | --menu-item-background-color-hover: var(--db-quaternary); 93 | 94 | --et-tab-color: var(--df-primary); 95 | --et-tab-background-color: var(--db-tertiary); 96 | /* Derived from --db-quaternary, lightened for higher contrast. */ 97 | --et-tab-background-color-active: hsl(180, 6%, 22%); 98 | --et-tab-border-color: var(--df-secondary); 99 | 100 | --sl-input-background-color: var(--db-tertiary); 101 | --sl-input-background-color-hover: var(--db-quaternary); 102 | --sl-input-color: var(--df-primary); 103 | --sl-input-color-hover: var(--df-primary); 104 | --sl-input-icon-color: var(--df-secondary); 105 | 106 | --sl-focus-ring-color: var(--da-primary); 107 | } 108 | 109 | --et-tab-font-size: 13px; 110 | 111 | --sl-font-sans: 112 | "Work Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 113 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 114 | "Segoe UI Symbol"; 115 | 116 | --sl-panel-background-color: var(--color-background); 117 | 118 | --sl-input-border-color: transparent; 119 | --sl-input-font-weight: var(--sl-font-weight-semibold); 120 | 121 | --sl-focus-ring-width: 2px; 122 | } 123 | 124 | * { 125 | &:focus-visible { 126 | outline: var(--sl-focus-ring-width) var(--sl-focus-ring-style) 127 | var(--sl-focus-ring-color); 128 | outline-offset: var(--sl-focus-ring-offset); 129 | } 130 | } 131 | 132 | sl-button, 133 | sl-icon-button { 134 | --sl-border-width: 2px; 135 | 136 | &::part(base) { 137 | color: var(--button-color); 138 | background-color: var(--button-background-color); 139 | 140 | border-radius: var(--sl-border-radius-large); 141 | } 142 | 143 | &[variant="primary"]::part(base) { 144 | color: var(--button-color-primary); 145 | background-color: var(--button-background-color-primary); 146 | } 147 | 148 | &[variant="danger"]::part(base) { 149 | color: var(--button-color-danger); 150 | background-color: var(--button-background-color-danger); 151 | } 152 | 153 | &:not([disabled]):hover, 154 | &:active { 155 | &::part(base) { 156 | color: var(--button-color-hover); 157 | } 158 | 159 | &[variant="primary"]::part(base) { 160 | color: var(--button-color-primary-hover); 161 | background-color: var(--button-background-color-primary-hover); 162 | } 163 | 164 | &[variant="danger"]::part(base) { 165 | color: var(--button-color-primary-hover); 166 | background-color: var(--button-background-color-danger-hover); 167 | } 168 | } 169 | 170 | sl-popup::part(base) { 171 | border-radius: var(--sl-border-radius-large); 172 | } 173 | } 174 | 175 | sl-button-group { 176 | sl-button, 177 | sl-icon-button { 178 | &::part(base) { 179 | border-radius: 0; 180 | } 181 | 182 | &:first-child::part(base) { 183 | border-top-left-radius: var(--sl-border-radius-large); 184 | border-bottom-left-radius: var(--sl-border-radius-large); 185 | } 186 | 187 | &:last-child::part(base) { 188 | border-top-right-radius: var(--sl-border-radius-large); 189 | border-bottom-right-radius: var(--sl-border-radius-large); 190 | } 191 | } 192 | } 193 | 194 | sl-button { 195 | font-size: var(--sl-font-size-small); 196 | } 197 | 198 | sl-select { 199 | --sl-panel-background-color: var(--sl-input-background-color); 200 | --sl-panel-border-color: var(--input-select-list-border-color); 201 | 202 | &::part(expand-icon) { 203 | color: var(--sl-input-icon-color); 204 | } 205 | &::part(combobox) { 206 | min-height: 42px; 207 | border-radius: var(--sl-border-radius-large); 208 | } 209 | &::part(listbox) { 210 | margin-top: 4px; 211 | border-radius: var(--sl-border-radius-large); 212 | } 213 | 214 | &:hover { 215 | &::part(combobox) { 216 | background-color: var(--sl-input-background-color-hover); 217 | } 218 | } 219 | } 220 | 221 | sl-option { 222 | &:not([aria-selected="true"]):hover { 223 | &::part(base) { 224 | background: var(--sl-input-background-color-hover); 225 | } 226 | } 227 | } 228 | 229 | sl-color-picker { 230 | --sl-input-border-color: var(--color-input-border-color); 231 | 232 | &::part(trigger) { 233 | height: 16px; 234 | width: 16px; 235 | 236 | border-radius: var(--sl-border-radius-medium); 237 | } 238 | 239 | &:hover { 240 | --sl-input-border-color: var(--color-primary); 241 | } 242 | } 243 | 244 | a { 245 | color: var(--link-color); 246 | padding: var(--sl-spacing-3x-small) var(--sl-spacing-2x-small); 247 | text-decoration: none; 248 | 249 | position: relative; 250 | left: calc(0px - var(--sl-spacing-2x-small)); 251 | 252 | border-radius: var(--sl-border-radius-small); 253 | 254 | &:hover { 255 | color: var(--link-color-hover); 256 | background-color: var(--link-background-color-hover); 257 | } 258 | } 259 | 260 | ul { 261 | list-style: none; 262 | 263 | padding: 0; 264 | } 265 | -------------------------------------------------------------------------------- /src/process/instance.js: -------------------------------------------------------------------------------- 1 | import { app, ipcMain, shell } from "electron"; 2 | import { join } from "node:path"; 3 | import { settings } from "./settings.js"; 4 | import { DEFAULT_INSTANCE, INSTANCE_EVENTS } from "../shared/instance.js"; 5 | import { 6 | compose, 7 | DOCKER_REPOSITORIES, 8 | getAvailableTags, 9 | isDockerAvailable, 10 | } from "./docker.js"; 11 | 12 | import { z, ZodError } from "zod"; 13 | import { findAvailablePort } from "./server.js"; 14 | import { isErrorCode, ERROR_CODES, isAppError } from "../tools/error.js"; 15 | import { generateId } from "../tools/id.js"; 16 | import { readConfig, writeConfig } from "./config.js"; 17 | import { observe } from "../tools/object.js"; 18 | import { getContainerSolution } from "./platform.js"; 19 | import { getMainWindow } from "./window.js"; 20 | 21 | /** 22 | * @typedef {(import("./settings.js").Settings['instances'][number] & { isLocal: boolean})[]} AllInstances 23 | * 24 | * @typedef {z.infer} LocalInstance 25 | * @typedef {z.infer} LocalInstances 26 | */ 27 | 28 | const DEFAULT_FRONTEND_CONTAINER_PORT = 9001; 29 | const DEFAULT_MAILCATCH_CONTAINER_PORT = 1080; 30 | const CONFIG_INSTANCES_NAME = "instances"; 31 | const CONTAINER_ID_PREFIX = `pd`; 32 | 33 | const checkboxSchema = z 34 | .literal("on") 35 | .optional() 36 | .transform((value) => Boolean(value)); 37 | 38 | const dockerTag = z.union([ 39 | z.literal(["latest", "main"]), 40 | z.string().regex(/^\d+\.\d+\.\d+$/), 41 | ]); 42 | 43 | export const instanceIdSchema = z.uuid(); 44 | 45 | export const instanceFormSchema = z.object({ 46 | label: z.string().trim().min(1), 47 | color: z.string(), 48 | origin: z.string().optional(), 49 | localInstance: z 50 | .object({ 51 | tag: dockerTag, 52 | enableElevatedAccess: checkboxSchema, 53 | enableInstanceTelemetry: checkboxSchema, 54 | runContainerUpdate: checkboxSchema.optional(), 55 | }) 56 | .optional(), 57 | }); 58 | 59 | export const localInstanceConfig = z.object({ 60 | dockerId: z.string().transform((value) => { 61 | const hasPrefixDuplicate = value.startsWith("pd-pd"); 62 | 63 | return hasPrefixDuplicate ? value.replace(/^pd-pd/, "pd") : value; 64 | }), 65 | tag: dockerTag.default("latest"), 66 | ports: z.object({ 67 | frontend: z.number().min(0).max(65535), 68 | mailcatch: z.number().min(0).max(65535), 69 | }), 70 | isInstanceTelemetryEnabled: z.boolean(), 71 | }); 72 | export const instancesConfigSchema = z 73 | .record(z.string(), localInstanceConfig) 74 | .default({}); 75 | 76 | const instancesConfig = await getInstancesConfig(); 77 | export const localInstances = observe(instancesConfig, (newInstances) => { 78 | writeConfig(CONFIG_INSTANCES_NAME, newInstances); 79 | }); 80 | 81 | const penpotDockerRepositoryAvailableTags = await getAvailableTags( 82 | DOCKER_REPOSITORIES.FRONTEND, 83 | ); 84 | 85 | ipcMain.handle(INSTANCE_EVENTS.SETUP_INFO, async () => ({ 86 | isDockerAvailable: await isDockerAvailable(), 87 | dockerTags: penpotDockerRepositoryAvailableTags, 88 | containerSolution: getContainerSolution(), 89 | })); 90 | 91 | ipcMain.handle(INSTANCE_EVENTS.GET_ALL, async () => { 92 | const instances = settings.instances.map((instance) => { 93 | const isLocal = !!localInstances[instance.id]; 94 | 95 | return { 96 | ...instance, 97 | isLocal, 98 | }; 99 | }); 100 | 101 | return instances; 102 | }); 103 | 104 | ipcMain.handle(INSTANCE_EVENTS.GET_LOCAL_CONFIG, async (_event, id) => { 105 | const isValidId = instanceIdSchema.safeParse(id); 106 | if (!isValidId.success) { 107 | return null; 108 | } 109 | 110 | const instance = settings.instances.find((instance) => instance.id === id); 111 | if (!instance) { 112 | return null; 113 | } 114 | 115 | const localInstance = localInstances[id]; 116 | const isLocal = !!localInstance; 117 | const { tag, isInstanceTelemetryEnabled } = localInstance || {}; 118 | 119 | return { 120 | ...instance, 121 | ...(isLocal && { 122 | localInstance: { 123 | tag, 124 | isInstanceTelemetryEnabled, 125 | }, 126 | }), 127 | }; 128 | }); 129 | 130 | ipcMain.handle(INSTANCE_EVENTS.CREATE, async (_event, instance) => { 131 | const id = crypto.randomUUID(); 132 | 133 | if (!instance) { 134 | registerInstance({ 135 | ...DEFAULT_INSTANCE, 136 | id, 137 | }); 138 | 139 | return id; 140 | } 141 | 142 | let validInstance; 143 | let ports = {}; 144 | 145 | try { 146 | validInstance = instanceFormSchema.parse(instance); 147 | ports.frontend = await findAvailablePort([ 148 | DEFAULT_FRONTEND_CONTAINER_PORT, 149 | DEFAULT_FRONTEND_CONTAINER_PORT + 9, 150 | ]); 151 | ports.mailcatch = await findAvailablePort([ 152 | DEFAULT_MAILCATCH_CONTAINER_PORT, 153 | DEFAULT_MAILCATCH_CONTAINER_PORT + 9, 154 | ]); 155 | } catch (error) { 156 | let message; 157 | 158 | if (error instanceof ZodError) { 159 | message = "Invalid input."; 160 | } 161 | if ( 162 | isAppError(error) && 163 | isErrorCode(error, ERROR_CODES.NO_AVAILABLE_PORT) 164 | ) { 165 | message = error.message; 166 | } 167 | 168 | console.error(`[ERROR] [instance:create]: ${message}`); 169 | 170 | throw new Error(message); 171 | } 172 | 173 | const { label, localInstance } = validInstance; 174 | const containerNameId = `${CONTAINER_ID_PREFIX}-${generateId().toLowerCase()}`; 175 | 176 | try { 177 | if (localInstance) { 178 | const { tag, enableElevatedAccess, enableInstanceTelemetry } = 179 | localInstance; 180 | 181 | await compose("up", containerNameId, tag, ports, { 182 | isSudoEnabled: enableElevatedAccess, 183 | isInstanceTelemetryEnabled: enableInstanceTelemetry, 184 | }); 185 | 186 | localInstances[id] = { 187 | ...localInstances[id], 188 | dockerId: containerNameId, 189 | tag, 190 | ports, 191 | isInstanceTelemetryEnabled: enableInstanceTelemetry, 192 | }; 193 | } 194 | 195 | registerInstance({ 196 | ...DEFAULT_INSTANCE, 197 | id, 198 | label, 199 | origin: `http://localhost:${ports.frontend}`, 200 | }); 201 | 202 | return id; 203 | } catch (error) { 204 | const message = isAppError(error) 205 | ? error.message 206 | : "Something went wrong during the local instance setup."; 207 | 208 | throw new Error(message); 209 | } 210 | }); 211 | 212 | ipcMain.on(INSTANCE_EVENTS.REMOVE, (_event, id) => { 213 | const userDataPath = app.getPath("sessionData"); 214 | const partitionPath = join(userDataPath, "Partitions", id); 215 | 216 | shell.trashItem(partitionPath); 217 | settings.instances = settings.instances.filter( 218 | ({ id: registeredId }) => registeredId !== id, 219 | ); 220 | delete localInstances[id]; 221 | }); 222 | 223 | ipcMain.on(INSTANCE_EVENTS.SET_DEFAULT, (_event, id) => { 224 | settings.instances = settings.instances.map((instance) => { 225 | instance.isDefault = instance.id === id ? true : false; 226 | return instance; 227 | }); 228 | }); 229 | 230 | ipcMain.handle(INSTANCE_EVENTS.UPDATE, async (_event, id, instance) => { 231 | let validInstance; 232 | 233 | try { 234 | instanceIdSchema.parse(id); 235 | validInstance = instanceFormSchema.parse(instance); 236 | } catch (error) { 237 | let message; 238 | 239 | if (error instanceof ZodError) { 240 | message = "Invalid input."; 241 | } 242 | 243 | console.error(`[ERROR] [instance:update]: ${message}`); 244 | 245 | throw new Error(message); 246 | } 247 | 248 | const { localInstance, ...instanceCore } = validInstance; 249 | 250 | const existingSettings = 251 | settings.instances.find(({ id: existingId }) => id === existingId) || 252 | DEFAULT_INSTANCE; 253 | registerInstance({ ...existingSettings, ...instanceCore, id }); 254 | 255 | const { isDefault } = existingSettings; 256 | if (isDefault) { 257 | const { origin, color } = instanceCore; 258 | getMainWindow().webContents.send("tab:set-default", { id, origin, color }); 259 | } 260 | 261 | if (localInstance && localInstances[id]) { 262 | const { 263 | tag: newTag, 264 | enableInstanceTelemetry, 265 | enableElevatedAccess, 266 | runContainerUpdate, 267 | } = localInstance; 268 | 269 | if (!runContainerUpdate) { 270 | return; 271 | } 272 | 273 | localInstances[id] = { 274 | ...localInstances[id], 275 | tag: newTag, 276 | isInstanceTelemetryEnabled: enableInstanceTelemetry, 277 | }; 278 | 279 | const { dockerId, tag, ports, isInstanceTelemetryEnabled } = 280 | localInstances[id]; 281 | const isSudoEnabled = enableElevatedAccess; 282 | 283 | await compose("pull", dockerId, tag, ports, { 284 | isInstanceTelemetryEnabled, 285 | isSudoEnabled, 286 | }); 287 | await compose("up", dockerId, tag, ports, { 288 | isInstanceTelemetryEnabled, 289 | isSudoEnabled, 290 | }); 291 | } 292 | }); 293 | 294 | /** 295 | * Add instance to the registry. 296 | * 297 | * @param {import("./settings.js").Settings["instances"][number]} instance 298 | */ 299 | function registerInstance(instance) { 300 | const { id, origin } = instance; 301 | const hasValidOrigin = URL.canParse(origin); 302 | if (hasValidOrigin) { 303 | const instanceIndex = settings.instances.findIndex( 304 | ({ id: registeredId }) => registeredId === id, 305 | ); 306 | if (instanceIndex > -1) { 307 | settings.instances = settings.instances.toSpliced( 308 | instanceIndex, 309 | 1, 310 | instance, 311 | ); 312 | return; 313 | } 314 | 315 | settings.instances = [...settings.instances, instance]; 316 | } else { 317 | console.warn( 318 | `[WARN] [IPC.${INSTANCE_EVENTS.REGISTER}] Failed with: ${origin}`, 319 | ); 320 | } 321 | } 322 | 323 | async function getInstancesConfig() { 324 | /** @type {LocalInstances | Record} */ 325 | const instancesConfig = (await readConfig(CONFIG_INSTANCES_NAME)) || {}; 326 | 327 | return instancesConfigSchema.parse(instancesConfig); 328 | } 329 | -------------------------------------------------------------------------------- /bin/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Based on the official Docker Compose config for self-hosting. 2 | # The original's repo: https://github.com/penpot/penpot/blob/develop/docker/images/docker-compose.yaml 3 | # The original's license: https://github.com/penpot/penpot/blob/develop/LICENSE 4 | 5 | ## Common flags: 6 | # demo-users 7 | # email-verification 8 | # log-emails 9 | # log-invitation-tokens 10 | # login-with-github 11 | # login-with-gitlab 12 | # login-with-google 13 | # login-with-ldap 14 | # login-with-oidc 15 | # login-with-password 16 | # prepl-server 17 | # registration 18 | # secure-session-cookies 19 | # smtp 20 | # smtp-debug 21 | # telemetry 22 | # webhooks 23 | ## 24 | ## You can read more about all available flags and other 25 | ## environment variables here: 26 | ## https://help.penpot.app/technical-guide/configuration/#advanced-configuration 27 | # 28 | # WARNING: if you're exposing Penpot to the internet, you should remove the flags 29 | # 'disable-secure-session-cookies' and 'disable-email-verification' 30 | x-flags: &penpot-flags 31 | PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies ${PENPOT_DESKTOP_FLAGS} 32 | 33 | x-uri: &penpot-public-uri 34 | PENPOT_PUBLIC_URI: http://localhost:${PENPOT_DESKTOP_FRONTEND_PORT} 35 | 36 | x-body-size: &penpot-http-body-size 37 | # Max body size (30MiB); Used for plain requests, should never be 38 | # greater than multi-part size 39 | PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280 40 | 41 | # Max multipart body size (350MiB) 42 | PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600 43 | 44 | networks: 45 | penpot: 46 | 47 | volumes: 48 | penpot_postgres_v15: 49 | penpot_assets: 50 | # penpot_traefik: 51 | # penpot_minio: 52 | 53 | services: 54 | ## Traefik service declaration example. Consider using it if you are going to expose 55 | ## penpot to the internet, or a different host than `localhost`. 56 | 57 | # traefik: 58 | # image: traefik:v3.3 59 | # networks: 60 | # - penpot 61 | # command: 62 | # - "--api.insecure=true" 63 | # - "--entryPoints.web.address=:80" 64 | # - "--providers.docker=true" 65 | # - "--providers.docker.exposedbydefault=false" 66 | # - "--entryPoints.websecure.address=:443" 67 | # - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" 68 | # - "--certificatesresolvers.letsencrypt.acme.email=" 69 | # - "--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json" 70 | # volumes: 71 | # - "penpot_traefik:/traefik" 72 | # - "/var/run/docker.sock:/var/run/docker.sock" 73 | # ports: 74 | # - "80:80" 75 | # - "443:443" 76 | 77 | penpot-frontend: 78 | image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" 79 | restart: always 80 | ports: 81 | - ${PENPOT_DESKTOP_FRONTEND_PORT}:8080 82 | 83 | volumes: 84 | - penpot_assets:/opt/data/assets 85 | 86 | depends_on: 87 | - penpot-backend 88 | - penpot-exporter 89 | 90 | networks: 91 | - penpot 92 | 93 | # labels: 94 | # - "traefik.enable=true" 95 | 96 | # ## HTTPS: example of labels for the case where penpot will be exposed to the 97 | # ## internet with HTTPS using traefik. 98 | 99 | # - "traefik.http.routers.penpot-https.rule=Host(``)" 100 | # - "traefik.http.routers.penpot-https.entrypoints=websecure" 101 | # - "traefik.http.routers.penpot-https.tls.certresolver=letsencrypt" 102 | # - "traefik.http.routers.penpot-https.tls=true" 103 | 104 | environment: 105 | <<: [*penpot-flags, *penpot-http-body-size] 106 | 107 | penpot-backend: 108 | image: "penpotapp/backend:${PENPOT_VERSION:-latest}" 109 | restart: always 110 | 111 | volumes: 112 | - penpot_assets:/opt/data/assets 113 | 114 | depends_on: 115 | penpot-postgres: 116 | condition: service_healthy 117 | penpot-valkey: 118 | condition: service_healthy 119 | 120 | networks: 121 | - penpot 122 | 123 | ## Configuration envronment variables for the backend container. 124 | 125 | environment: 126 | <<: [*penpot-flags, *penpot-public-uri, *penpot-http-body-size] 127 | 128 | ## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems 129 | ## (eg http sessions, or invitations) are derived. 130 | ## 131 | ## If you leave it commented, all created sessions and invitations will 132 | ## become invalid on container restart. 133 | ## 134 | ## If you going to uncomment this, we recommend to use a trully randomly generated 135 | ## 512 bits base64 encoded string here. You can generate one with: 136 | ## 137 | ## python3 -c "import secrets; print(secrets.token_urlsafe(64))" 138 | 139 | # PENPOT_SECRET_KEY: my-insecure-key 140 | 141 | ## The PREPL host. Mainly used for external programatic access to penpot backend 142 | ## (example: admin). By default it will listen on `localhost` but if you are going to use 143 | ## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`. 144 | 145 | # PENPOT_PREPL_HOST: 0.0.0.0 146 | 147 | ## Database connection parameters. Don't touch them unless you are using custom 148 | ## postgresql connection parameters. 149 | 150 | PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot 151 | PENPOT_DATABASE_USERNAME: penpot 152 | PENPOT_DATABASE_PASSWORD: penpot 153 | 154 | ## Valkey (or previously redis) is used for the websockets notifications. Don't touch 155 | ## unless the valkey container has different parameters or different name. 156 | 157 | PENPOT_REDIS_URI: redis://penpot-valkey/0 158 | 159 | ## Default configuration for assets storage: using filesystem based with all files 160 | ## stored in a docker volume. 161 | 162 | PENPOT_ASSETS_STORAGE_BACKEND: assets-fs 163 | PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets 164 | 165 | ## Also can be configured to to use a S3 compatible storage 166 | ## service like MiniIO. Look below for minio service setup. 167 | 168 | # AWS_ACCESS_KEY_ID: 169 | # AWS_SECRET_ACCESS_KEY: 170 | # PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 171 | # PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000 172 | # PENPOT_STORAGE_ASSETS_S3_BUCKET: 173 | 174 | ## Telemetry. When enabled, a periodical process will send anonymous data about this 175 | ## instance. Telemetry data will enable us to learn how the application is used, 176 | ## based on real scenarios. If you want to help us, please leave it enabled. You can 177 | ## audit what data we send with the code available on github. 178 | 179 | PENPOT_TELEMETRY_ENABLED: ${PENPOT_DESKTOP_BACKEND_TELEMETRY} 180 | PENPOT_TELEMETRY_REFERER: penpot-desktop 181 | 182 | ## Example SMTP/Email configuration. By default, emails are sent to the mailcatch 183 | ## service, but for production usage it is recommended to setup a real SMTP 184 | ## provider. Emails are used to confirm user registrations & invitations. Look below 185 | ## how the mailcatch service is configured. 186 | 187 | PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com 188 | PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com 189 | PENPOT_SMTP_HOST: penpot-mailcatch 190 | PENPOT_SMTP_PORT: 1025 191 | PENPOT_SMTP_USERNAME: 192 | PENPOT_SMTP_PASSWORD: 193 | PENPOT_SMTP_TLS: false 194 | PENPOT_SMTP_SSL: false 195 | 196 | penpot-exporter: 197 | image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" 198 | restart: always 199 | 200 | depends_on: 201 | penpot-valkey: 202 | condition: service_healthy 203 | 204 | networks: 205 | - penpot 206 | 207 | environment: 208 | # Don't touch it; this uses an internal docker network to 209 | # communicate with the frontend. 210 | PENPOT_PUBLIC_URI: http://penpot-frontend:8080 211 | 212 | ## Valkey (or previously Redis) is used for the websockets notifications. 213 | PENPOT_REDIS_URI: redis://penpot-valkey/0 214 | 215 | penpot-postgres: 216 | image: "postgres:15" 217 | restart: always 218 | stop_signal: SIGINT 219 | 220 | healthcheck: 221 | test: ["CMD-SHELL", "pg_isready -U penpot"] 222 | interval: 2s 223 | timeout: 10s 224 | retries: 5 225 | start_period: 2s 226 | 227 | volumes: 228 | - penpot_postgres_v15:/var/lib/postgresql/data 229 | 230 | networks: 231 | - penpot 232 | 233 | environment: 234 | - POSTGRES_INITDB_ARGS=--data-checksums 235 | - POSTGRES_DB=penpot 236 | - POSTGRES_USER=penpot 237 | - POSTGRES_PASSWORD=penpot 238 | 239 | penpot-valkey: 240 | image: valkey/valkey:8.1 241 | restart: always 242 | 243 | healthcheck: 244 | test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] 245 | interval: 1s 246 | timeout: 3s 247 | retries: 5 248 | start_period: 3s 249 | 250 | networks: 251 | - penpot 252 | 253 | ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the 254 | ## port 1080 for read all emails the penpot platform has sent. Should be only used as a 255 | ## temporal solution while no real SMTP provider is configured. 256 | 257 | penpot-mailcatch: 258 | image: sj26/mailcatcher:latest 259 | restart: always 260 | expose: 261 | - "1025" 262 | ports: 263 | - "${PENPOT_DESKTOP_MAILCATCH_PORT}:1080" 264 | networks: 265 | - penpot 266 | 267 | ## Example configuration of MiniIO (S3 compatible object storage service); If you don't 268 | ## have preference, then just use filesystem, this is here just for the completeness. 269 | 270 | # minio: 271 | # image: "minio/minio:latest" 272 | # command: minio server /mnt/data --console-address ":9001" 273 | # restart: always 274 | # 275 | # volumes: 276 | # - "penpot_minio:/mnt/data" 277 | # 278 | # environment: 279 | # - MINIO_ROOT_USER=minioadmin 280 | # - MINIO_ROOT_PASSWORD=minioadmin 281 | # 282 | # ports: 283 | # - 9000:9000 284 | # - 9001:9001 285 | -------------------------------------------------------------------------------- /src/base/scripts/electron-tabs.js: -------------------------------------------------------------------------------- 1 | import { SlIconButton } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 2 | import { FILE_EVENTS } from "../../shared/file.js"; 3 | import { DEFAULT_INSTANCE } from "../../shared/instance.js"; 4 | import { isViewModeUrl } from "../../tools/penpot.js"; 5 | import { showAlert } from "./alert.js"; 6 | import { hideContextMenu, showContextMenu } from "./contextMenu.js"; 7 | import { getIncludedElement, typedQuerySelector } from "./dom.js"; 8 | import { handleFileExport } from "./file.js"; 9 | import { handleInTabThemeUpdate, THEME_TAB_EVENTS } from "./theme.js"; 10 | 11 | /** 12 | * @typedef {import("electron-tabs").TabGroup} TabGroup 13 | * @typedef {import("electron-tabs").Tab} Tab 14 | * @typedef {import("electron").WebviewTag} WebviewTag 15 | * 16 | * @typedef {Object} TabOptions 17 | * @property {string =} accentColor 18 | * @property {string =} partition 19 | */ 20 | 21 | const PRELOAD_PATH = "./scripts/webviews/preload.mjs"; 22 | const DEFAULT_TAB_OPTIONS = Object.freeze({ 23 | src: DEFAULT_INSTANCE.origin, 24 | active: true, 25 | webviewAttributes: { 26 | preload: PRELOAD_PATH, 27 | allowpopups: true, 28 | }, 29 | ready: tabReadyHandler, 30 | }); 31 | 32 | const TAB_STYLE_PROPERTIES = Object.freeze({ 33 | ACCENT_COLOR: "--tab-accent-color", 34 | ACCENT_COLOR_HUE: "--tab-accent-color-hue", 35 | ACCENT_COLOR_SATURATION: "--tab-accent-color-saturation", 36 | ACCENT_COLOR_LIGHTNESS: "--tab-accent-color-lightness", 37 | ACCENT_COLOR_ALPHA: "--tab-accent-color-alpha", 38 | }); 39 | 40 | export async function initTabs() { 41 | const tabGroup = await getTabGroup(); 42 | 43 | tabGroup?.on("tab-removed", () => { 44 | handleNoTabs(); 45 | }); 46 | tabGroup?.on("tab-added", () => { 47 | handleNoTabs(); 48 | }); 49 | 50 | prepareTabReloadButton(); 51 | 52 | window.api.tab.onOpen(openTab); 53 | window.api.tab.onMenuAction(handleTabMenuAction); 54 | window.api.tab.onSetDefault(({ id, origin, color }) => 55 | setDefaultTab(origin, { accentColor: color, partition: id }), 56 | ); 57 | 58 | const addTabButton = typedQuerySelector( 59 | ".buttons > button", 60 | HTMLButtonElement, 61 | tabGroup?.shadow, 62 | ); 63 | addTabButton?.addEventListener("contextmenu", async () => { 64 | const instances = await window.api.getSetting("instances"); 65 | const hasMultipleInstances = instances.length > 1; 66 | 67 | if (!hasMultipleInstances) { 68 | return; 69 | } 70 | 71 | const menuItems = instances.map(({ id, origin, label, color }) => ({ 72 | label: label || origin, 73 | color, 74 | onClick: () => { 75 | openTab(origin, { accentColor: color, partition: id }); 76 | hideContextMenu(); 77 | }, 78 | })); 79 | 80 | showContextMenu(addTabButton, menuItems); 81 | }); 82 | } 83 | 84 | /** 85 | * @param {string =} href 86 | * @param {TabOptions} options 87 | */ 88 | export async function setDefaultTab(href, { accentColor, partition } = {}) { 89 | const tabGroup = await getTabGroup(); 90 | 91 | tabGroup?.setDefaultTab({ 92 | ...DEFAULT_TAB_OPTIONS, 93 | ...(href ? { src: href } : {}), 94 | webviewAttributes: { 95 | ...DEFAULT_TAB_OPTIONS.webviewAttributes, 96 | ...(partition && { partition: `persist:${partition}` }), 97 | }, 98 | ready: (tab) => tabReadyHandler(tab, { accentColor }), 99 | }); 100 | } 101 | 102 | /** 103 | * @param {string =} href 104 | * @param {TabOptions} options 105 | */ 106 | export async function openTab(href, { accentColor, partition } = {}) { 107 | const tabGroup = await getTabGroup(); 108 | const activeTab = tabGroup?.getActiveTab(); 109 | 110 | // Use the same instance as the active tab if not requested otherwise. 111 | const activeTabProperties = activeTab && getTabProperties(activeTab); 112 | partition = partition || activeTabProperties?.partition; 113 | accentColor = accentColor || activeTabProperties?.accentColor; 114 | 115 | tabGroup?.addTab( 116 | href 117 | ? { 118 | ...DEFAULT_TAB_OPTIONS, 119 | src: href, 120 | webviewAttributes: { 121 | ...DEFAULT_TAB_OPTIONS.webviewAttributes, 122 | ...(partition && { partition: `persist:${partition}` }), 123 | }, 124 | ready: (tab) => { 125 | tabReadyHandler(tab, { accentColor }); 126 | }, 127 | } 128 | : undefined, 129 | ); 130 | } 131 | 132 | async function prepareTabReloadButton() { 133 | const reloadButton = await getIncludedElement( 134 | "#reload-tab", 135 | "#include-controls", 136 | SlIconButton, 137 | ); 138 | const tabGroup = await getTabGroup(); 139 | 140 | reloadButton?.addEventListener("click", () => { 141 | const tab = tabGroup?.getActiveTab(); 142 | /** @type {WebviewTag} */ (tab?.webview)?.reload(); 143 | }); 144 | } 145 | 146 | /** 147 | * @param {Tab} tab 148 | * @param {TabOptions} options 149 | */ 150 | function tabReadyHandler(tab, { accentColor } = {}) { 151 | const webview = /** @type {WebviewTag} */ (tab.webview); 152 | 153 | if (accentColor) { 154 | const [hue, saturation, lightness, alpha] = 155 | accentColor 156 | ?.replaceAll(/[hsla()]/g, "") 157 | .split(",") 158 | .map((entry) => entry.trim()) || []; 159 | 160 | [ 161 | [TAB_STYLE_PROPERTIES.ACCENT_COLOR, accentColor], 162 | [TAB_STYLE_PROPERTIES.ACCENT_COLOR_HUE, hue], 163 | [TAB_STYLE_PROPERTIES.ACCENT_COLOR_SATURATION, saturation], 164 | [TAB_STYLE_PROPERTIES.ACCENT_COLOR_LIGHTNESS, lightness], 165 | [TAB_STYLE_PROPERTIES.ACCENT_COLOR_ALPHA, alpha], 166 | ].forEach(([key, value]) => { 167 | tab.element.style.setProperty(key, value); 168 | }); 169 | } 170 | 171 | tab.once("webview-dom-ready", () => { 172 | tab.on("active", () => requestTabTheme(tab)); 173 | }); 174 | tab.element.addEventListener("contextmenu", (event) => { 175 | event.preventDefault(); 176 | window.api.send("openTabMenu", tab.id); 177 | }); 178 | webview.addEventListener("ipc-message", async (event) => { 179 | const isError = event.channel === "error"; 180 | if (isError) { 181 | const [{ heading, message }] = event.args; 182 | 183 | showAlert( 184 | "danger", 185 | { 186 | heading, 187 | message, 188 | }, 189 | { 190 | closable: true, 191 | }, 192 | ); 193 | 194 | return; 195 | } 196 | 197 | const isThemeUpdate = event.channel === THEME_TAB_EVENTS.UPDATE; 198 | if (isThemeUpdate) { 199 | const [theme] = event.args; 200 | 201 | handleInTabThemeUpdate(theme); 202 | } 203 | 204 | const isFileChange = event.channel === FILE_EVENTS.CHANGE; 205 | const isAutoReloadEnabled = await window.api.getSetting("enableAutoReload"); 206 | if (isFileChange && isAutoReloadEnabled) { 207 | const [fileId] = event.args; 208 | if (!fileId) { 209 | return; 210 | } 211 | 212 | window.api.file.change(fileId); 213 | 214 | const tabGroup = await getTabGroup(); 215 | const tabs = tabGroup?.getTabs() || []; 216 | const viewModeTab = tabs.find((tab) => { 217 | const webview = /** @type {WebviewTag} */ (tab.webview); 218 | const tabUrl = new URL(webview.src); 219 | const isViewModeTab = isViewModeUrl(tabUrl, fileId); 220 | 221 | return isViewModeTab; 222 | }); 223 | 224 | if (viewModeTab) { 225 | /** @type {WebviewTag} */ (viewModeTab.webview).reload(); 226 | } 227 | } 228 | 229 | const isFileExport = event.channel === FILE_EVENTS.EXPORT; 230 | if (isFileExport) { 231 | const [files, failedExports] = event.args; 232 | 233 | await handleFileExport(files, failedExports); 234 | 235 | webview.send("file:export-finish"); 236 | } 237 | }); 238 | webview.addEventListener("page-title-updated", () => { 239 | const newTitle = webview.getTitle(); 240 | tab.setTitle(newTitle); 241 | }); 242 | } 243 | 244 | /** 245 | * Calls a tab and requests a theme update send-out. 246 | * If no tab is provided, calls the active tab. 247 | * 248 | * @param {Tab =} tab 249 | */ 250 | export async function requestTabTheme(tab) { 251 | tab = tab || (await getActiveTab()); 252 | 253 | if (tab) { 254 | const webview = /** @type {WebviewTag} */ (tab.webview); 255 | webview?.send(THEME_TAB_EVENTS.REQUEST_UPDATE); 256 | } 257 | } 258 | 259 | async function getActiveTab() { 260 | const tabGroup = await getTabGroup(); 261 | return tabGroup?.getActiveTab(); 262 | } 263 | 264 | async function handleNoTabs() { 265 | const tabGroup = await getTabGroup(); 266 | const tabs = tabGroup?.getTabs(); 267 | const hasTabs = !!tabs?.length; 268 | 269 | const noTabsExistPage = typedQuerySelector(".no-tabs-exist", HTMLElement); 270 | if (noTabsExistPage) { 271 | noTabsExistPage.style.display = hasTabs ? "none" : "inherit"; 272 | } 273 | } 274 | 275 | export async function getTabGroup() { 276 | return /** @type {TabGroup | null} */ ( 277 | await getIncludedElement("tab-group", "#include-tabs") 278 | ); 279 | } 280 | 281 | /** 282 | * Handles action from a tab menu interaction. 283 | * 284 | * @param {{command: string, tabId: number}} action 285 | */ 286 | async function handleTabMenuAction({ command, tabId }) { 287 | const tabGroup = await getTabGroup(); 288 | const tab = tabGroup?.getTab(tabId); 289 | 290 | if (!tab) { 291 | return; 292 | } 293 | 294 | if (command === "reload-tab") { 295 | /** @type {WebviewTag} */ (tab.webview).reload(); 296 | } 297 | 298 | if (command === "duplicate-tab") { 299 | const { url, accentColor, partition } = getTabProperties(tab); 300 | 301 | openTab(url, { 302 | accentColor, 303 | partition, 304 | }); 305 | } 306 | 307 | if (command.startsWith("close-tabs-")) { 308 | const pivotPosition = tab.getPosition(); 309 | 310 | /** @type {-1 | 0| 1} */ 311 | let direction; 312 | switch (command) { 313 | case "close-tabs-right": 314 | direction = 1; 315 | break; 316 | case "close-tabs-left": 317 | direction = -1; 318 | break; 319 | case "close-tabs-other": 320 | default: 321 | direction = 0; 322 | } 323 | 324 | if (tabGroup) { 325 | closeTabs(tabGroup, pivotPosition, direction); 326 | } 327 | } 328 | } 329 | 330 | /** 331 | * Close tabs from the given tab's position. 332 | * 333 | * @param {TabGroup} tabs 334 | * @param {number} from - Position of the pivot tab. 335 | * @param {-1 | 0 | 1} direction - Direction of the closing. 1 for higher position, 0 any other position, -1 for lower position. 336 | */ 337 | function closeTabs(tabs, from, direction) { 338 | tabs.eachTab((tab) => { 339 | const position = tab.getPosition(); 340 | 341 | const isMatchingPosition = position === from; 342 | const isLowerPosition = position < from; 343 | const isHigherPosition = position > from; 344 | const isOtherDirection = direction === 0; 345 | const isLowerDirection = direction === -1; 346 | const isHigherDirection = direction === 1; 347 | 348 | const isOtherClose = isOtherDirection && !isMatchingPosition; 349 | const isHigherClose = isLowerDirection && isLowerPosition; 350 | const isLowerClose = isHigherDirection && isHigherPosition; 351 | 352 | const isClose = isOtherClose || isHigherClose || isLowerClose; 353 | 354 | if (isClose) { 355 | tab.close(true); 356 | } 357 | }); 358 | } 359 | 360 | /** 361 | * @param {Tab} tab 362 | * 363 | * @returns {Required} 364 | */ 365 | function getTabProperties(tab) { 366 | const webview = /** @type {WebviewTag} */ (tab.webview); 367 | const url = webview.getURL(); 368 | const { partition } = webview; 369 | const [, id] = partition.split(":"); 370 | const accentColor = tab.element.style.getPropertyValue( 371 | TAB_STYLE_PROPERTIES.ACCENT_COLOR, 372 | ); 373 | 374 | return { 375 | url, 376 | accentColor, 377 | partition: id, 378 | }; 379 | } 380 | -------------------------------------------------------------------------------- /src/base/scripts/instance.js: -------------------------------------------------------------------------------- 1 | import { getIncludedElement, typedQuerySelector } from "./dom.js"; 2 | import { openTab, setDefaultTab } from "./electron-tabs.js"; 3 | import { 4 | SlButton, 5 | SlDialog, 6 | SlIconButton, 7 | } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 8 | import { isNonNull } from "../../tools/value.js"; 9 | import { isParentNode } from "../../tools/element.js"; 10 | import { hideContextMenu, showContextMenu } from "./contextMenu.js"; 11 | import { 12 | disableSettingsFocusTrap, 13 | enableSettingsFocusTrap, 14 | } from "./settings.js"; 15 | import { createAlert, showAlert } from "./alert.js"; 16 | import { CONTAINER_SOLUTIONS } from "../../shared/platform.js"; 17 | import { 18 | INSTANCE_CREATOR_EVENTS, 19 | InstanceCreator, 20 | } from "../components/instanceCreator.js"; 21 | 22 | /** 23 | * @typedef {Awaited>>} Instances 24 | * @typedef {Awaited>} AllInstances 25 | * @typedef {CustomEvent} InstanceCreationEvent 26 | * @typedef {CustomEvent} InstanceUpdateEvent 27 | * @typedef {CustomEvent<{id?: string}>} InstanceDeleteEvent 28 | */ 29 | 30 | export async function initInstance() { 31 | const instances = await window.api.instance.getAll(); 32 | 33 | const { id, origin, color } = 34 | instances.find(({ isDefault }) => isDefault) || instances[0]; 35 | 36 | await setDefaultTab(origin, { 37 | accentColor: color, 38 | partition: id, 39 | }); 40 | openTab(origin, { 41 | accentColor: color, 42 | partition: id, 43 | }); 44 | 45 | updateInstanceList(); 46 | prepareInstanceControls(); 47 | prepareInstanceCreator(); 48 | } 49 | 50 | async function prepareInstanceControls() { 51 | const { instanceButtonAdd, instanceButtonOpenCreator } = 52 | await getInstanceSettingsElements(); 53 | 54 | instanceButtonAdd?.addEventListener("click", addInstance); 55 | instanceButtonOpenCreator?.addEventListener("click", openInstanceCreator); 56 | } 57 | 58 | async function prepareInstanceCreator() { 59 | const { instanceCreatorDialog, instanceCreator } = 60 | await getInstanceCreatorElements(); 61 | 62 | if (!instanceCreatorDialog || !instanceCreator) { 63 | return; 64 | } 65 | 66 | const { dockerTags } = await window.api.instance.getSetupInfo(); 67 | 68 | instanceCreator.dockerTags = dockerTags; 69 | 70 | instanceCreator?.addEventListener(INSTANCE_CREATOR_EVENTS.CREATE, (event) => { 71 | const customEvent = /** @type {InstanceCreationEvent} */ (event); 72 | handleInstanceCreation(customEvent, instanceCreator); 73 | }); 74 | instanceCreator?.addEventListener(INSTANCE_CREATOR_EVENTS.UPDATE, (event) => { 75 | const customEvent = /** @type {InstanceUpdateEvent} */ (event); 76 | handleInstanceUpdate(customEvent, instanceCreator); 77 | }); 78 | instanceCreator.addEventListener(INSTANCE_CREATOR_EVENTS.CLOSE, () => 79 | instanceCreatorDialog.hide(), 80 | ); 81 | instanceCreator.addEventListener(INSTANCE_CREATOR_EVENTS.DELETE, (event) => { 82 | const { 83 | detail: { id }, 84 | } = /** @type {InstanceDeleteEvent} */ (event); 85 | if (id) { 86 | window.api.instance.remove(id); 87 | updateInstanceList(); 88 | instanceCreatorDialog.hide(); 89 | } 90 | }); 91 | } 92 | 93 | /** 94 | * @param {Event} event 95 | */ 96 | async function addInstance(event) { 97 | event.preventDefault(); 98 | 99 | try { 100 | await window.api.instance.create(); 101 | 102 | updateInstanceList(); 103 | } catch (error) { 104 | if (error instanceof Error) { 105 | showAlert( 106 | "danger", 107 | { 108 | heading: "Failed to add an instance", 109 | message: error.message, 110 | }, 111 | { 112 | closable: true, 113 | }, 114 | ); 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Opens the instance creator dialog. 121 | * 122 | * @param {Event | null} [event] 123 | * @param {string} [id] 124 | */ 125 | async function openInstanceCreator(event, id) { 126 | event?.preventDefault(); 127 | 128 | const { alertsHolder, instanceCreatorDialog, instanceCreator } = 129 | await getInstanceCreatorElements(); 130 | 131 | if (!instanceCreatorDialog || !instanceCreator) { 132 | return; 133 | } 134 | 135 | const alert = await getCreatorAlert(); 136 | const instanceConfig = id ? await window.api.instance.getConfig(id) : null; 137 | const isLocalInstanceCreator = 138 | !instanceConfig?.id || instanceConfig?.localInstance; 139 | 140 | alertsHolder?.replaceChildren(); 141 | if (alert && isLocalInstanceCreator) { 142 | alertsHolder?.append(alert); 143 | } 144 | 145 | instanceCreator.instance = instanceConfig; 146 | 147 | instanceCreatorDialog.label = !instanceConfig?.id 148 | ? "Instance creator" 149 | : "Instance settings"; 150 | instanceCreatorDialog.style = `--width: ${isLocalInstanceCreator ? "75" : "30"}vw;`; 151 | instanceCreatorDialog.show(); 152 | } 153 | 154 | async function getCreatorAlert() { 155 | /** @type { Record }*/ 156 | const alertConfigurations = { 157 | docker: { 158 | variant: "warning", 159 | content: { 160 | heading: "Docker is required for local instance", 161 | message: 162 | "To run a self-hosted, local instance of the app, Docker is required. Please install Docker by following the steps outlined in the official documentation. You can choose between installing Docker Desktop for a user-friendly experience or Docker Engine for a more customizable setup.", 163 | links: [ 164 | ["Get Docker", "https://docs.docker.com/get-started/get-docker/"], 165 | ], 166 | }, 167 | }, 168 | flatpak: { 169 | variant: "primary", 170 | content: { 171 | heading: "Isolated environment", 172 | message: 173 | "Penpot Desktop is running in a Flatpak container which is isolated from other applications and has limited access to the operating system. For that reason, it is unable to create a local instance in Docker.", 174 | }, 175 | }, 176 | }; 177 | 178 | const getAlertConfiguration = async () => { 179 | const { isDockerAvailable, containerSolution } = 180 | await window.api.instance.getSetupInfo(); 181 | const isFlatpak = containerSolution === CONTAINER_SOLUTIONS.FLATPAK; 182 | 183 | if (isFlatpak) { 184 | return alertConfigurations.flatpak; 185 | } 186 | if (!isDockerAvailable) { 187 | return alertConfigurations.docker; 188 | } 189 | }; 190 | const alertConfiguration = await getAlertConfiguration(); 191 | 192 | if (alertConfiguration) { 193 | const { variant, content, options } = alertConfiguration; 194 | return await createAlert( 195 | variant, 196 | content, 197 | options || { 198 | closable: false, 199 | open: true, 200 | }, 201 | ); 202 | } 203 | } 204 | 205 | /** 206 | * Handles instance creation from form submission. 207 | * 208 | * @param {InstanceCreationEvent} event 209 | * @param {InstanceCreator} instanceCreator 210 | */ 211 | async function handleInstanceCreation(event, instanceCreator) { 212 | event.preventDefault(); 213 | 214 | instanceCreator.loading = true; 215 | 216 | try { 217 | const instance = event.detail; 218 | await window.api.instance.create(instance); 219 | 220 | showAlert( 221 | "success", 222 | { 223 | heading: "Instance created", 224 | message: "Local instance has been created successfully.", 225 | }, 226 | { 227 | duration: 3000, 228 | }, 229 | ); 230 | updateInstanceList(); 231 | } catch (error) { 232 | if (error instanceof Error) { 233 | showAlert( 234 | "danger", 235 | { 236 | heading: "Failed to create an instance", 237 | message: error.message, 238 | }, 239 | { 240 | closable: true, 241 | }, 242 | ); 243 | } 244 | } 245 | 246 | instanceCreator.loading = false; 247 | } 248 | 249 | /** 250 | * Handles instance update. 251 | * 252 | * @param {InstanceUpdateEvent} event 253 | * @param {InstanceCreator} instanceCreator 254 | */ 255 | async function handleInstanceUpdate(event, instanceCreator) { 256 | event.preventDefault(); 257 | 258 | instanceCreator.loading = true; 259 | 260 | const { id, ...detail } = event.detail; 261 | try { 262 | await window.api.instance.update(id, detail); 263 | updateInstanceList(); 264 | 265 | showAlert( 266 | "success", 267 | { 268 | heading: "Instance updated", 269 | message: "Instance has been updated successfully.", 270 | }, 271 | { 272 | duration: 3000, 273 | }, 274 | ); 275 | } catch (error) { 276 | if (error instanceof Error) { 277 | showAlert( 278 | "danger", 279 | { 280 | heading: "Failed to update an instance", 281 | message: error.message, 282 | }, 283 | { 284 | closable: true, 285 | }, 286 | ); 287 | } 288 | } 289 | 290 | instanceCreator.loading = false; 291 | } 292 | 293 | /** 294 | * Fill instance list with instance items. 295 | */ 296 | async function updateInstanceList() { 297 | const { instanceList, instancePanelTemplate } = 298 | await getInstanceSettingsElements(); 299 | 300 | if (!instanceList || !instancePanelTemplate) { 301 | return; 302 | } 303 | 304 | const instances = await window.api.instance.getAll(); 305 | const instancePanels = instances 306 | .map((instance) => createInstancePanel(instance, instancePanelTemplate)) 307 | .filter(isNonNull); 308 | 309 | instanceList?.replaceChildren(...instancePanels); 310 | } 311 | 312 | /** 313 | * Creates an instance panel element. 314 | * 315 | * @param {AllInstances[number]} instance 316 | * @param {HTMLTemplateElement} template 317 | */ 318 | function createInstancePanel(instance, template) { 319 | const { id, origin, label, color } = { ...instance }; 320 | const instancePanel = document.importNode(template.content, true); 321 | 322 | if (!instancePanel || !isParentNode(instancePanel)) { 323 | return; 324 | } 325 | 326 | const colorEl = typedQuerySelector(".color", HTMLDivElement, instancePanel); 327 | if (colorEl) { 328 | colorEl.style.backgroundColor = color; 329 | } 330 | 331 | const labelEl = typedQuerySelector(".label", HTMLSpanElement, instancePanel); 332 | if (labelEl) { 333 | labelEl.innerText = label || ""; 334 | } 335 | 336 | const hintEl = typedQuerySelector(".hint", HTMLSpanElement, instancePanel); 337 | if (hintEl) { 338 | hintEl.innerText = origin; 339 | } 340 | 341 | const buttonSettingsEl = typedQuerySelector( 342 | "sl-icon-button", 343 | SlIconButton, 344 | instancePanel, 345 | ); 346 | if (buttonSettingsEl) { 347 | buttonSettingsEl.addEventListener("click", () => { 348 | openInstanceCreator(null, id); 349 | }); 350 | } 351 | 352 | const panelElement = typedQuerySelector(".panel", HTMLElement, instancePanel); 353 | if (panelElement) { 354 | panelElement.addEventListener("contextmenu", async () => { 355 | const { id, origin, color } = instance; 356 | 357 | await disableSettingsFocusTrap(); 358 | 359 | showContextMenu(panelElement, [ 360 | { 361 | label: "Set as default", 362 | onClick: () => { 363 | setDefaultTab(origin, { 364 | accentColor: color, 365 | partition: id, 366 | }); 367 | window.api.instance.setDefault(id); 368 | hideContextMenu(); 369 | updateInstanceList(); 370 | enableSettingsFocusTrap(); 371 | }, 372 | }, 373 | ]); 374 | }); 375 | } 376 | 377 | return instancePanel; 378 | } 379 | 380 | async function getInstanceSettingsElements() { 381 | const instanceList = await getIncludedElement( 382 | "#instance-list", 383 | "#include-settings", 384 | HTMLDivElement, 385 | ); 386 | const instancePanelTemplate = await getIncludedElement( 387 | "#template-instance-panel", 388 | "#include-settings", 389 | HTMLTemplateElement, 390 | ); 391 | const instanceButtonAdd = await getIncludedElement( 392 | "#instance-add", 393 | "#include-settings", 394 | SlButton, 395 | ); 396 | const instanceButtonOpenCreator = await getIncludedElement( 397 | "#instance-open-creator", 398 | "#include-settings", 399 | SlButton, 400 | ); 401 | 402 | return { 403 | instanceList, 404 | instancePanelTemplate, 405 | instanceButtonAdd, 406 | instanceButtonOpenCreator, 407 | }; 408 | } 409 | 410 | async function getInstanceCreatorElements() { 411 | const alertsHolder = typedQuerySelector( 412 | "#instance-creator-dialog alerts-holder", 413 | HTMLElement, 414 | ); 415 | const instanceCreatorDialog = await getIncludedElement( 416 | "#instance-creator-dialog", 417 | ["#include-settings"], 418 | SlDialog, 419 | ); 420 | const instanceCreator = await getIncludedElement( 421 | "instance-creator", 422 | ["#include-settings"], 423 | InstanceCreator, 424 | ); 425 | 426 | return { alertsHolder, instanceCreatorDialog, instanceCreator }; 427 | } 428 | -------------------------------------------------------------------------------- /src/base/components/instanceCreator.js: -------------------------------------------------------------------------------- 1 | import { 2 | SlButton, 3 | SlCheckbox, 4 | SlColorPicker, 5 | SlInput, 6 | } from "../../../node_modules/@shoelace-style/shoelace/cdn/shoelace.js"; 7 | import { typedQuerySelector } from "../scripts/dom.js"; 8 | 9 | /** 10 | * @typedef {string} DockerTag 11 | * 12 | * @typedef {Object} InstanceCreationDetails 13 | * @property {string} [label] 14 | * @property {string} [origin] 15 | * @property {string} [color] 16 | * @property {string} [tag] 17 | * @property {string} [enableInstanceTelemetry] 18 | * @property {string} [enableElevatedAccess] 19 | * @property {string} [runContainerUpdate] 20 | * 21 | * @typedef {Object} ExistingInstanceDetails 22 | * @property {string} id 23 | * @property {InstanceCreationDetails["label"]} label 24 | * @property {InstanceCreationDetails["origin"]} origin 25 | * @property {InstanceCreationDetails["color"]} color 26 | * @property {boolean} isDefault 27 | * @property {ExistingLocalInstanceDetails} [localInstance] 28 | * 29 | * @typedef {Object} ExistingLocalInstanceDetails 30 | * @property {InstanceCreationDetails["tag"]} tag 31 | * @property {boolean} isInstanceTelemetryEnabled 32 | */ 33 | 34 | export const INSTANCE_CREATOR_EVENTS = Object.freeze({ 35 | CREATE: "instance-creator:create", 36 | UPDATE: "instance-creator:update", 37 | CLOSE: "instance-creator:close", 38 | DELETE: "instance-creator:delete", 39 | }); 40 | 41 | export class InstanceCreator extends HTMLElement { 42 | constructor() { 43 | super(); 44 | 45 | /** @type {DockerTag[] | null} */ 46 | this._dockerTags = null; 47 | /** @type { ExistingInstanceDetails | null} */ 48 | this._instance = null; 49 | /** @type {HTMLFormElement | null} */ 50 | this._form = null; 51 | /** @type {SlInput | null} */ 52 | this._tagInput = null; 53 | /** @type {SlButton | null} */ 54 | this._submitButton = null; 55 | /** @type {SlButton | null} */ 56 | this._closeButton = null; 57 | /** @type {SlButton | null} */ 58 | this._deleteButton = null; 59 | 60 | this.attachShadow({ mode: "open" }); 61 | 62 | this.render(); 63 | } 64 | 65 | get dockerTags() { 66 | return this._dockerTags; 67 | } 68 | 69 | set dockerTags(tags) { 70 | this._dockerTags = tags; 71 | 72 | this.prepareTagInput(this._tagInput, this._dockerTags); 73 | } 74 | 75 | get instance() { 76 | return this._instance; 77 | } 78 | 79 | set instance(instance) { 80 | this._instance = instance; 81 | 82 | this.render(); 83 | } 84 | 85 | get loading() { 86 | return this._submitButton?.hasAttribute("loading") || false; 87 | } 88 | 89 | set loading(isLoading) { 90 | if (isLoading) { 91 | this._submitButton?.setAttribute("loading", "true"); 92 | } else { 93 | this._submitButton?.removeAttribute("loading"); 94 | } 95 | } 96 | 97 | async render() { 98 | if (!this.shadowRoot) { 99 | return; 100 | } 101 | 102 | const isLocalInstanceCreator = 103 | !this._instance?.id || this._instance?.localInstance; 104 | 105 | // Wait for controls to be defined. https://shoelace.style/getting-started/form-controls#required-fields 106 | await Promise.all([ 107 | customElements.whenDefined("sl-input"), 108 | customElements.whenDefined("sl-checkbox"), 109 | customElements.whenDefined("sl-button"), 110 | customElements.whenDefined("sl-details"), 111 | ]); 112 | 113 | const infoSection = 114 | (isLocalInstanceCreator && 115 | `
116 |

117 | This is an experimental feature. For production-critical work, please use the existing self-hosting setup guide. Refer to the "Info" section in the Settings panel for more details. 118 |

119 |

120 | The creator will set up a local Penpot instance using the official Docker method for self-hosting Penpot, and your computer as the host. 121 |

122 |

123 | The process may take anywhere from a few seconds to a few minutes, depending on the availability of Docker images, your internet connection (for downloading images), and your computer's performance. 124 |

125 |
`) || 126 | ""; 127 | 128 | this.shadowRoot.innerHTML = ` 129 | 241 |
242 |
243 | ${infoSection} 244 |
245 |
246 | 247 |
248 | 249 | 250 |
251 | ${!isLocalInstanceCreator ? `` : ""} 252 |
253 | 254 | ${ 255 | isLocalInstanceCreator 256 | ? ` 257 | 258 | 259 | Enable instance telemetry 260 | 261 | 262 | 263 | Enable elevated access 264 | 265 | 266 | ` 267 | : "" 268 | } 269 | 270 | 271 | ${ 272 | this._instance?.localInstance 273 | ? ` 274 | Run container update 275 | ` 276 | : "" 277 | } 278 | 279 |
280 | 297 |
298 |
299 |
300 | `; 301 | 302 | this._form = typedQuerySelector("form", HTMLFormElement, this.shadowRoot); 303 | this._tagInput = typedQuerySelector( 304 | "sl-input[name='tag']", 305 | SlInput, 306 | this.shadowRoot, 307 | ); 308 | this._submitButton = typedQuerySelector( 309 | "sl-button[type='submit']", 310 | SlButton, 311 | this.shadowRoot, 312 | ); 313 | this._closeButton = typedQuerySelector( 314 | "sl-button#close", 315 | SlButton, 316 | this.shadowRoot, 317 | ); 318 | this._deleteButton = typedQuerySelector( 319 | "sl-button#delete", 320 | SlButton, 321 | this.shadowRoot, 322 | ); 323 | 324 | if (this._instance) { 325 | const labelInput = typedQuerySelector( 326 | "sl-input[name='label']", 327 | SlInput, 328 | this.shadowRoot, 329 | ); 330 | const colorPicker = typedQuerySelector( 331 | "sl-color-picker", 332 | SlColorPicker, 333 | this.shadowRoot, 334 | ); 335 | const originInput = typedQuerySelector( 336 | "sl-input[name='origin']", 337 | SlInput, 338 | this.shadowRoot, 339 | ); 340 | const telemetryCheckbox = typedQuerySelector( 341 | "sl-checkbox[name='enableInstanceTelemetry']", 342 | SlCheckbox, 343 | this.shadowRoot, 344 | ); 345 | 346 | if (labelInput && this._instance.label) { 347 | labelInput.value = this._instance.label; 348 | } 349 | if (colorPicker && this._instance.color) { 350 | colorPicker.value = this._instance.color; 351 | } 352 | if (originInput && this._instance.origin) { 353 | originInput.value = this._instance.origin; 354 | if (this._instance.localInstance) { 355 | originInput.disabled = true; 356 | } 357 | } 358 | if (this._tagInput && this._instance.localInstance?.tag) { 359 | this._tagInput.value = this._instance.localInstance.tag; 360 | } 361 | if ( 362 | telemetryCheckbox && 363 | this._instance.localInstance && 364 | Object.hasOwn( 365 | this._instance.localInstance, 366 | "isInstanceTelemetryEnabled", 367 | ) 368 | ) { 369 | telemetryCheckbox.checked = 370 | this._instance.localInstance.isInstanceTelemetryEnabled; 371 | } 372 | 373 | if (this._submitButton) { 374 | this._submitButton.textContent = "Update"; 375 | } 376 | } 377 | 378 | this._form?.addEventListener("submit", this); 379 | this._closeButton?.addEventListener("click", this); 380 | this._deleteButton?.addEventListener("click", this); 381 | 382 | this.prepareTagInput(this._tagInput, this._dockerTags); 383 | } 384 | 385 | /** 386 | * Prepares the tag input with datalist options. 387 | * 388 | * @param {SlInput | null} tagInput 389 | * @param {DockerTag[] | null} tags 390 | */ 391 | async prepareTagInput(tagInput, tags) { 392 | await tagInput?.updateComplete; 393 | 394 | tagInput?.shadowRoot?.querySelector("datalist")?.remove(); 395 | 396 | const tagOptionElements = 397 | tags?.map((tag) => { 398 | const option = document.createElement("option"); 399 | option.value = tag; 400 | option.textContent = tag; 401 | 402 | return option; 403 | }) || []; 404 | const dataListElement = document.createElement("datalist"); 405 | 406 | dataListElement.id = "tags"; 407 | dataListElement.replaceChildren(...tagOptionElements); 408 | 409 | tagInput?.shadowRoot 410 | ?.querySelector('[part="base"]') 411 | ?.appendChild(dataListElement); 412 | tagInput?.shadowRoot 413 | ?.querySelector('[part="input"]') 414 | ?.setAttribute("list", "tags"); 415 | } 416 | 417 | /** 418 | * @param {Event} event 419 | */ 420 | handleEvent(event) { 421 | const isSubmitEvent = event.type === "submit"; 422 | const isCloseEvent = 423 | event.type === "click" && event.target === this._closeButton; 424 | const isDeleteEvent = 425 | event.type === "click" && event.target === this._deleteButton; 426 | 427 | if (isSubmitEvent && this._form) { 428 | this.handleSubmit(event, this._form); 429 | return; 430 | } 431 | 432 | if (isCloseEvent) { 433 | this.handleClose(); 434 | return; 435 | } 436 | 437 | if (isDeleteEvent) { 438 | this.handleDelete(); 439 | return; 440 | } 441 | } 442 | 443 | /** 444 | * @param {Event} event 445 | * @param {HTMLFormElement} form 446 | */ 447 | handleSubmit(event, form) { 448 | event.preventDefault(); 449 | 450 | const formData = new FormData(form); 451 | /** @type {InstanceCreationDetails} */ 452 | const { 453 | tag, 454 | enableInstanceTelemetry, 455 | enableElevatedAccess, 456 | runContainerUpdate, 457 | ...instance 458 | } = Object.fromEntries(formData.entries()); 459 | const { id: instanceId } = this._instance || {}; 460 | const isLocalInstanceCreator = !instanceId || this._instance?.localInstance; 461 | const eventName = instanceId 462 | ? INSTANCE_CREATOR_EVENTS.UPDATE 463 | : INSTANCE_CREATOR_EVENTS.CREATE; 464 | 465 | this.dispatchEvent( 466 | new CustomEvent(eventName, { 467 | detail: { 468 | ...instance, 469 | ...(isLocalInstanceCreator && { 470 | localInstance: { 471 | tag, 472 | enableInstanceTelemetry, 473 | enableElevatedAccess, 474 | runContainerUpdate, 475 | }, 476 | }), 477 | ...(instanceId && { id: instanceId }), 478 | }, 479 | bubbles: true, 480 | composed: true, 481 | }), 482 | ); 483 | } 484 | 485 | handleClose() { 486 | this.dispatchEvent( 487 | new CustomEvent(INSTANCE_CREATOR_EVENTS.CLOSE, { 488 | bubbles: true, 489 | composed: true, 490 | }), 491 | ); 492 | 493 | this._instance = null; 494 | } 495 | 496 | handleDelete() { 497 | this.dispatchEvent( 498 | new CustomEvent(INSTANCE_CREATOR_EVENTS.DELETE, { 499 | bubbles: true, 500 | composed: true, 501 | detail: { id: this._instance?.id }, 502 | }), 503 | ); 504 | 505 | this._instance = null; 506 | } 507 | } 508 | 509 | customElements.define("instance-creator", InstanceCreator); 510 | --------------------------------------------------------------------------------