From 607547736a537675b3d8ac60b3ed417de85adaac Mon Sep 17 00:00:00 2001 From: pomdtr Date: Fri, 1 Dec 2023 18:55:39 +0100 Subject: [PATCH] add support for themes and profiles --- .vscode/launch.json | 15 +++ .vscode/tasks.json | 13 +++ README.md | 47 ++++++-- extension/src/background.ts | 182 ++++++++++++++++------------- extension/src/config.ts | 11 ++ extension/src/manifest.ts | 12 +- extension/src/popup.html | 25 ---- extension/src/terminal.ts | 42 ++++--- go.mod | 1 + go.sum | 2 + internal/cmd/init.go | 84 +++++-------- internal/cmd/manifest.json | 2 +- internal/cmd/serve.go | 47 ++++++++ internal/config/config.go | 93 ++++++++++----- internal/config/config.schema.json | 53 ++++++--- internal/server/server.go | 16 +-- popcorn.json | 22 ++-- 17 files changed, 387 insertions(+), 280 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 extension/src/config.ts delete mode 100644 extension/src/popup.html diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..82bdb4f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Package", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}", + "args": [ + "serve" + ] + } + ], +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..457fc6f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Build Extension", + "type": "shell", + "command": "npm run build", + "options": { + "cwd": "${workspaceFolder}/extension" + } + } + ] +} diff --git a/README.md b/README.md index c8dc680..5da1495 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ An integrated terminal for your browser. -![screenshot](./medias/demo.png) - Check out a demo of the extension running from the arc browser here: . ## Installation @@ -25,20 +23,51 @@ Download the extension from the [releases page](https://github.com/pomdtr/popcor Then go to the `chrome://extensions` page, activate the Developer mode and click on the `Load unpacked` button. You will need to select the `dist` folder you just extracted using the file picker. -![Extension Page](./medias/extensions.png) - Once you have installed the extension, copy the extension id, and run the following command: ```bash -popcorn init --browser chrome --extension-id +popcorn init ``` +Alternatively, you can right click on the extension icon and select `Copy Installation Command`. + ## Usage -## CLI +Click on the extension icon to open your default profile in a new tab. + +Right click on the extension icon to open a new tab with a specific profile. +Each profile gets it's own unique url, so you can bookmark them. -You can use the wesh cli to control your browser from the command line. -It will only work from a terminal started from the extension. +From the terminal tab, you can manipulate your browser tabs, windows, bookmarks, etc... + +```sh +popcorn tab list +``` + +## Configuration + +The configuration file is located in `~/.config/popcorn/popcorn.json` or `~/.config/popcorn/popcorn.jsonc` if you want to use comments. + +You can customize the configuration dir by setting the `XDG_CONFIG_HOME` environment variable. + +Example Config: + +```json +{ + "theme": "Tomorrow", + "themeDark": "Tomorrow Night", + "defaultProfile": "zsh", + "profiles": { + "zsh": { + "command": "zsh", + "args": ["-l"] + }, + "htop": { + "command": "/opt/homebrew/bin/htop" + } + } +} +``` ## How does it work? @@ -50,7 +79,7 @@ popcorn is composed of two parts: When the chrome extension is loaded, it will use the native messaging API to communicate with the host binary. An instance of an HTTP server will be started on a random free http port. -When the popup is opened, the embedded terminal (xterm.js) will connect to the HTTP server and will be able to send and receive data through a websocket. +The embedded terminal (xterm.js) will connect to the HTTP server and will be able to send and receive data through a websocket. When you use the popcorn cli, the message is sent to the http server, and then piped to the chrome extension. diff --git a/extension/src/background.ts b/extension/src/background.ts index bd25d0c..82c06d4 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,3 +1,5 @@ +import { Config } from "./config"; + type Message = { id: string; payload?: { @@ -8,59 +10,71 @@ type Message = { }; enum ContextMenuID { - OPEN_TERMINAL_TAB = "open-terminal-tab", - COPY_EXTENSION_ID = "copy-extension-id", + OPEN_PROFILE_DEFAUlT = "open-profile-default", + OPEN_PROFILE = "open-profile", + COPY_INSTALLATION_COMMAND = "copy-installation-command", } -// activate when installed or updated chrome.runtime.onInstalled.addListener(() => { console.log("Extension installed or updated"); chrome.contextMenus.create({ - id: ContextMenuID.OPEN_TERMINAL_TAB, - title: "Open Terminal in New Tab", + title: "Copy Installation Command", + id: ContextMenuID.COPY_INSTALLATION_COMMAND, contexts: ["action"], }); - chrome.contextMenus.create({ - title: "Copy Extension ID", - id: ContextMenuID.COPY_EXTENSION_ID, - contexts: ["action"], - }); -}); - -// activate when chrome starts -chrome.runtime.onStartup.addListener(() => { - console.log("Browser started"); -}); - - -const nativePort = chrome.runtime.connectNative("com.pomdtr.popcorn"); -nativePort.onMessage.addListener(async (msg: Message) => { - console.log("Received message", msg); - try { - const res = await handleMessage(msg.payload); - nativePort.postMessage({ - id: msg.id, - payload: res, - }); - } catch (e: any) { - nativePort.postMessage({ - id: msg.id, - error: e.message, - }); - } }); -chrome.storage.session.setAccessLevel({ - accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS", -}); +let nativePort: chrome.runtime.Port; +try { + nativePort = chrome.runtime.connectNative("com.pomdtr.popcorn"); + nativePort.onMessage.addListener(async (msg: Message) => { + console.log("Received message", msg); + try { + const res = await handleMessage(msg.payload); + nativePort.postMessage({ + id: msg.id, + payload: res, + }); + } catch (e: any) { + nativePort.postMessage({ + id: msg.id, + error: e.message, + }); + } + }); +} catch (e) { + console.log(`Native messaging host not found: ${e}`); +} async function handleMessage(payload: any): Promise { switch (payload.command) { case "init": { + const { port, token, config } = payload as { port: number, token: string, config: Config } await chrome.storage.session.set({ - port: payload.port, - token: payload.token, + port, token, config + }); + + chrome.contextMenus.remove(ContextMenuID.COPY_INSTALLATION_COMMAND) + chrome.contextMenus.create({ + id: ContextMenuID.OPEN_PROFILE_DEFAUlT, + title: "Open Default Profile", + contexts: ["action"], }); + + chrome.contextMenus.create({ + id: ContextMenuID.OPEN_PROFILE, + title: "Open Profile", + contexts: ["action"], + }); + for (const profile of Object.keys(config.profiles)) { + chrome.contextMenus.create({ + id: `${ContextMenuID.OPEN_PROFILE}:${profile}`, + parentId: ContextMenuID.OPEN_PROFILE, + title: profile, + contexts: ["action"], + }); + } + return "ok"; } case "tab.list": { @@ -294,60 +308,62 @@ async function getActiveTabId() { chrome.contextMenus.onClicked.addListener(async (info) => { const mainPage = "/src/terminal.html"; - switch (info.menuItemId) { - case ContextMenuID.OPEN_TERMINAL_TAB: { - await chrome.tabs.create({ url: mainPage }); - break; - } - case ContextMenuID.COPY_EXTENSION_ID: { - await addToClipboard(chrome.runtime.id); - break; - } - default: { - throw new Error(`Unknown menu item: ${info.menuItemId}`); - } + + const menuItemID = info.menuItemId; + if (typeof menuItemID !== "string") { + throw new Error(`Unknown menu item: ${menuItemID}`); } -}); -chrome.commands.onCommand.addListener(async (command) => { - switch (command) { - case "open-terminal-tab": { - const tab = await chrome.tabs.create({ url: "/src/terminal.html" }); - await chrome.windows.update(tab.windowId, { focused: true }); - break; - } - default: { - throw new Error(`Unknown command: ${command}`); + if (menuItemID == ContextMenuID.OPEN_PROFILE_DEFAUlT) { + await chrome.tabs.create({ url: mainPage }); + } else if (typeof menuItemID.startsWith(ContextMenuID.OPEN_PROFILE)) { + const profile = menuItemID.split(":")[1]; + if (!profile) { + throw new Error(`Unknown menu item: ${menuItemID}`); } + await chrome.tabs.create({ + url: `${mainPage}?profile=${profile}`, + }); + } else if (menuItemID == ContextMenuID.COPY_INSTALLATION_COMMAND) { + throw new Error(`Unknown menu item: ${menuItemID}`); + } else { + await addToClipboard(`popcorn init ${chrome.runtime.id}`); } -}) - -chrome.omnibox.onInputStarted.addListener(async () => { - chrome.omnibox.setDefaultSuggestion({ - description: "Run command", - }); }); -chrome.omnibox.onInputChanged.addListener(async (text) => { - chrome.omnibox.setDefaultSuggestion({ - description: `Run: ${text}`, - }); -}); - -chrome.omnibox.onInputEntered.addListener(async (disposition) => { - const url = `/src/terminal.html`; - switch (disposition) { - case "currentTab": - await chrome.tabs.update({ url }); - break; - case "newForegroundTab": - await chrome.tabs.create({ url }); - break; - case "newBackgroundTab": - await chrome.tabs.create({ url, active: false }); +chrome.action.onClicked.addListener(async () => { + if (nativePort === undefined) { + return; } + await chrome.tabs.create({ url: "/src/terminal.html" }); }); +// chrome.omnibox.onInputStarted.addListener(async () => { +// chrome.omnibox.setDefaultSuggestion({ +// description: "Run command", +// }); +// }); + +// chrome.omnibox.onInputChanged.addListener(async (text) => { +// chrome.omnibox.setDefaultSuggestion({ +// description: `Run: ${text}`, +// }); +// }); + +// chrome.omnibox.onInputEntered.addListener(async (disposition) => { +// const url = `/src/terminal.html`; +// switch (disposition) { +// case "currentTab": +// await chrome.tabs.update({ url }); +// break; +// case "newForegroundTab": +// await chrome.tabs.create({ url }); +// break; +// case "newBackgroundTab": +// await chrome.tabs.create({ url, active: false }); +// } +// }); + async function addToClipboard(value: string) { await chrome.offscreen.createDocument({ url: 'src/offscreen.html', diff --git a/extension/src/config.ts b/extension/src/config.ts new file mode 100644 index 0000000..5c2a570 --- /dev/null +++ b/extension/src/config.ts @@ -0,0 +1,11 @@ +export type Config = { + theme?: string + themeDark?: string + env: Record + defaultProfile: string + profiles: Record + }> +} diff --git a/extension/src/manifest.ts b/extension/src/manifest.ts index 38f3618..740b695 100644 --- a/extension/src/manifest.ts +++ b/extension/src/manifest.ts @@ -11,7 +11,6 @@ export const manifest: chrome.runtime.ManifestV3 = { default_icon: { 48: "icons/48.png", }, - default_popup: "src/popup.html#popup", default_title: "Open Terminal", }, background: { @@ -21,18 +20,11 @@ export const manifest: chrome.runtime.ManifestV3 = { keyword: "tty", }, commands: { - "_execute_action": { - description: "Show terminal popup", - suggested_key: { - default: "Ctrl+E", - mac: "Command+E", - } - }, "open-terminal-tab": { description: "Create a new terminal tab", suggested_key: { - default: "Ctrl+Shift+E", - mac: "Command+Shift+E", + default: "Ctrl+E", + mac: "Command+E", } } }, diff --git a/extension/src/popup.html b/extension/src/popup.html deleted file mode 100644 index ab1e8df..0000000 --- a/extension/src/popup.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - Hello World - - - - - - - - -
- - - diff --git a/extension/src/terminal.ts b/extension/src/terminal.ts index 9d5a7e9..fa0bba0 100644 --- a/extension/src/terminal.ts +++ b/extension/src/terminal.ts @@ -1,20 +1,26 @@ -import { Terminal } from "xterm"; +import { ITheme, Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { WebglAddon } from "xterm-addon-webgl"; import { WebLinksAddon } from "xterm-addon-web-links"; import { AttachAddon } from "xterm-addon-attach"; import { nanoid } from "nanoid"; +import { Config } from "./config"; -const imports = import.meta.glob("./themes/*.json") -const themes: Record = {} -for (const [key, value] of Object.entries(imports)) { - const name = key.slice("./themes/".length, -".json".length) - themes[name] = await value() as any +const themeModules = import.meta.glob("./themes/*.json") +function importTheme(name: string) { + const module = themeModules[`./themes/${name}.json`] + if (!module) { + throw new Error(`Theme ${name} not found`) + } + return module() as Promise } async function main() { - const lightTheme = themes["Tomorrow"]; - const darkTheme = themes["Tomorrow Night"]; + const { port, token, config } = + await chrome.storage.session.get(["port", "token", "config"]) as { port: number, token: string, config: Config } + console.log(port, token, config) + const lightTheme = await importTheme(config.theme || "Tomorrow") + const darkTheme = await importTheme(config.themeDark || config.theme || "Tomorrow Night") const terminal = new Terminal({ cursorBlink: true, allowProposedApi: true, @@ -37,14 +43,11 @@ async function main() { terminal.open(document.getElementById("terminal")!); fitAddon.fit(); - const { port: popcornPort, token: popcornToken } = - await chrome.storage.session.get(["port", "token"]); - // check if popcorn server is running let ready = false; while (!ready) { try { - const res = await fetch(`http://localhost:${popcornPort}/ready`); + const res = await fetch(`http://localhost:${port}/ready`); if (res.status !== 200) { throw new Error("not ready"); } @@ -55,19 +58,14 @@ async function main() { } const terminalID = nanoid(); - const websocketUrl = new URL(`ws://localhost:${popcornPort}/pty/${terminalID}`) - websocketUrl.searchParams.set("token", popcornToken) + const websocketUrl = new URL(`ws://localhost:${port}/pty/${terminalID}`) + websocketUrl.searchParams.set("token", token) websocketUrl.searchParams.set("cols", terminal.cols.toString()) websocketUrl.searchParams.set("rows", terminal.rows.toString()) - if (window.location.hash === "#popup") { - websocketUrl.searchParams.set("popup", "1") - } const params = new URLSearchParams(window.location.search); - const profile = params.get("profile"); - if (profile) { - websocketUrl.searchParams.set("profile", profile); - } + const profile = params.get("profile") || config.defaultProfile; + websocketUrl.searchParams.set("profile", profile); const ws = new WebSocket(websocketUrl); ws.onclose = () => { @@ -80,7 +78,7 @@ async function main() { terminal.onResize((size) => { const { cols, rows } = size; - const url = `http://localhost:${popcornPort}/${terminalID}/resize?cols=${cols}&rows=${rows}`; + const url = `http://localhost:${port}/resize/${terminalID}?cols=${cols}&rows=${rows}`; fetch(url, { method: "POST", }); diff --git a/go.mod b/go.mod index 278e021..ede9f7f 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/go.sum b/go.sum index 0a95f58..c081fd9 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 87df992..e5f9b66 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -12,8 +12,6 @@ import ( "github.com/spf13/cobra" ) -const manifestName = "com.pomdtr.popcorn.json" - var ( //go:embed manifest.json manifest []byte @@ -24,25 +22,19 @@ var ( var ( manifestTmpl = template.Must(template.New("manifest").Parse(string(manifest))) entrypointTmpl = template.Must(template.New("entrypoint").Parse(string(entrypoint))) - manifestPaths = map[string]string{ - "chrome": filepath.Join(xdg.DataHome, "Google", "Chrome", "NativeMessagingHosts", manifestName), - "chrome-beta": filepath.Join(xdg.DataHome, "Google", "Chrome Beta", "NativeMessagingHosts", manifestName), - "edge": filepath.Join(xdg.DataHome, "microsoft", "edge", "NativeMessagingHosts", manifestName), - "brave": filepath.Join(xdg.DataHome, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts", manifestName), - "vivaldi": filepath.Join(xdg.DataHome, "vivaldi", "NativeMessagingHosts", manifestName), - "arc": filepath.Join(xdg.DataHome, "Google", "Chrome", "NativeMessagingHosts", manifestName), + manifestDirs = []string{ + filepath.Join(xdg.DataHome, "Google", "Chrome", "NativeMessagingHosts"), + filepath.Join(xdg.DataHome, "Google", "Chrome Beta", "NativeMessagingHosts"), + filepath.Join(xdg.DataHome, "microsoft", "edge", "NativeMessagingHosts"), + filepath.Join(xdg.DataHome, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts"), + filepath.Join(xdg.DataHome, "vivaldi", "NativeMessagingHosts"), + filepath.Join(xdg.DataHome, "Orion", "NativeMessagingHosts"), } ) func NewCmdInit() *cobra.Command { - flags := struct { - Browser string - ExtensionID string - ProfileDirectory string - }{} - cmd := &cobra.Command{ - Use: "init", + Use: "init ", Short: "Init configuration for a browser", RunE: func(cmd *cobra.Command, args []string) error { homeDir, err := os.UserHomeDir() @@ -50,33 +42,26 @@ func NewCmdInit() *cobra.Command { return fmt.Errorf("unable to get user home directory: %w", err) } - manifestPath, ok := manifestPaths[flags.Browser] - if !ok { - return fmt.Errorf("invalid browser: %s", flags.Browser) - } - - if flags.ProfileDirectory != "" { - manifestPath = filepath.Join(manifestPath, flags.ProfileDirectory) + for _, manifestDir := range manifestDirs { + if _, err := os.Stat(manifestDir); err != nil { + continue + } + + manifestBuffer := bytes.Buffer{} + if err := manifestTmpl.Execute(&manifestBuffer, map[string]string{ + "homeDir": homeDir, + "extensionID": args[0], + }); err != nil { + return fmt.Errorf("unable to execute manifest template: %w", err) + } + + manifestPath := filepath.Join(manifestDir, "com.pomdtr.popcorn.json") + if err := os.WriteFile(manifestPath, manifestBuffer.Bytes(), 0644); err != nil { + return fmt.Errorf("unable to write manifest file: %w", err) + } + cmd.Printf("Manifest file written successfully to %s\n", manifestPath) } - cmd.Printf("Writing manifest file to %s\n", manifestPath) - if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil { - return fmt.Errorf("unable to create manifest directory: %w", err) - } - - manifestBuffer := bytes.Buffer{} - if err := manifestTmpl.Execute(&manifestBuffer, map[string]string{ - "homeDir": homeDir, - "extensionID": flags.ExtensionID, - }); err != nil { - return fmt.Errorf("unable to execute manifest template: %w", err) - } - - if err := os.WriteFile(manifestPath, manifestBuffer.Bytes(), 0644); err != nil { - return fmt.Errorf("unable to write manifest file: %w", err) - } - cmd.Printf("Manifest file written successfully\n") - if err := os.MkdirAll(filepath.Join(homeDir, ".local", "bin"), 0755); err != nil { return fmt.Errorf("unable to create entrypoint directory: %w", err) } @@ -88,7 +73,6 @@ func NewCmdInit() *cobra.Command { } if err := entrypointTmpl.Execute(&entrypointBuffer, map[string]string{ "popcornBin": execPath, - "browser": flags.Browser, }); err != nil { return fmt.Errorf("unable to execute entrypoint template: %w", err) } @@ -104,21 +88,5 @@ func NewCmdInit() *cobra.Command { }, } - cmd.Flags().StringVar(&flags.Browser, "browser", "", "Browser to install the extension for") - cmd.MarkFlagRequired("browser") - cmd.RegisterFlagCompletionFunc("browser", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - var completions []string - for browser := range manifestPaths { - completions = append(completions, browser) - } - - return completions, cobra.ShellCompDirectiveNoFileComp - }) - - cmd.Flags().StringVar(&flags.ExtensionID, "extension-id", "", "Extension ID to install") - cmd.MarkFlagRequired("extension-id") - - cmd.Flags().StringVar(&flags.ProfileDirectory, "profile-directory", "", "Profile Directory") - return cmd } diff --git a/internal/cmd/manifest.json b/internal/cmd/manifest.json index e893f10..021edb5 100644 --- a/internal/cmd/manifest.json +++ b/internal/cmd/manifest.json @@ -1,6 +1,6 @@ { "name": "com.pomdtr.popcorn", - "description": "A simple popup terminal for the web", + "description": "An integrated terminal for your web browser", "path": "{{ .homeDir }}/.local/bin/popcorn.sh", "type": "stdio", "allowed_origins": [ diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 42061c7..7dc6d82 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -2,11 +2,14 @@ package cmd import ( "fmt" + "log" "os" "path/filepath" "github.com/adrg/xdg" + "github.com/fsnotify/fsnotify" "github.com/phayes/freeport" + "github.com/pomdtr/popcorn/internal/config" "github.com/pomdtr/popcorn/internal/server" "github.com/sethvargo/go-password/password" "github.com/spf13/cobra" @@ -37,14 +40,58 @@ func NewCmdServe() *cobra.Command { return fmt.Errorf("could not generate secret %w", err) } + cfg, err := config.Load(config.Path) + if err != nil { + return fmt.Errorf("could not load config %w", err) + } + messageHandler := server.NewMessageHandler() go messageHandler.Loop() messageHandler.SendMessage(map[string]any{ "command": "init", "port": port, + "config": cfg, "token": token, }) + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("could not create watcher %w", err) + } + defer watcher.Close() + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + cfg, err := config.Load(config.Path) + if err != nil { + log.Println("could not load config", err) + continue + } + + messageHandler.SendMessage(map[string]any{ + "command": "config", + "config": cfg, + }) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Println("error:", err) + } + } + }() + + if err := watcher.Add(config.Path); err != nil { + return fmt.Errorf("could not watch config file %w", err) + } + if err := server.Serve(messageHandler, port, token); err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 6c017eb..802bf33 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,19 +12,37 @@ import ( "github.com/tailscale/hujson" ) +type Config struct { + Theme string `json:"theme"` + ThemeDark string `json:"themeDark"` + Env map[string]string `json:"env,omitempty"` + DefaultProfile string `json:"defaultProfile"` + Profiles map[string]Profile `json:"profiles"` +} + +type Profile struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +var DefaultConfig = Config{ + Theme: "Tomorrow", + ThemeDark: "Tomorrow Night", + DefaultProfile: "default", + Profiles: map[string]Profile{ + "default": { + Command: defaultShell(), + Args: []string{"-l"}, + }, + }, +} + var schemaBytes, _ = json.MarshalIndent(DefaultConfig, "", " ") -var Path string +var Path string = FindConfigPath() var schema *jsonschema.Schema func init() { - if env, ok := os.LookupEnv("POPCORN_CONFIG"); ok { - Path = env - } else if env, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok { - Path = filepath.Join(env, "popcorn", "popcorn.json") - } else { - Path = filepath.Join(os.Getenv("HOME"), ".config", "popcorn", "popcorn.json") - } - compiler := jsonschema.NewCompiler() compiler.Draft = jsonschema.Draft7 @@ -32,14 +50,22 @@ func init() { schema = compiler.MustCompile("schema.json") } -type Config struct { - Profiles map[string]Profile `json:"profiles"` -} +func FindConfigPath() string { + if env, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok { + if _, err := os.Stat(filepath.Join(env, "popcorn", "popcorn.jsonc")); err == nil { + return filepath.Join(env, "popcorn", "popcorn.jsonc") + } -type Profile struct { - Command string `json:"command"` - Args []string `json:"args"` - Env map[string]string `json:"env"` + if _, err := os.Stat(filepath.Join(env, "popcorn", "popcorn.json")); err == nil { + return filepath.Join(env, "popcorn", "popcorn.json") + } + } + + if _, err := os.Stat(filepath.Join(os.Getenv("HOME"), ".config", "popcorn", "popcorn.jsonc")); err == nil { + return filepath.Join(os.Getenv("HOME"), ".config", "popcorn", "popcorn.jsonc") + } + + return filepath.Join(os.Getenv("HOME"), ".config", "popcorn", "popcorn.json") } func defaultShell() string { @@ -56,30 +82,37 @@ func defaultShell() string { } } -var DefaultConfig = Config{ - Profiles: map[string]Profile{ - "default": { - Command: defaultShell(), - Args: []string{"-li"}, - }, - }, -} - func Load(Path string) (Config, error) { configBytes, err := os.ReadFile(Path) if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(Path), 0755); err != nil { + return Config{}, err + } + + jsonBytes, err := json.MarshalIndent(DefaultConfig, "", " ") + if err != nil { + return Config{}, err + } + + if err := os.WriteFile(Path, jsonBytes, 0644); err != nil { + return Config{}, err + } + return DefaultConfig, nil } else if err != nil { return Config{}, err } - jsonBytes, err := hujson.Standardize(configBytes) - if err != nil { - return Config{}, err + if filepath.Ext(Path) == ".jsonc" { + jsonBytes, err := hujson.Standardize(configBytes) + if err != nil { + return Config{}, err + } + configBytes = jsonBytes } var v any - if err := json.Unmarshal(jsonBytes, &v); err != nil { + if err := json.Unmarshal(configBytes, &v); err != nil { return Config{}, err } @@ -88,7 +121,7 @@ func Load(Path string) (Config, error) { } var config Config - if err := json.Unmarshal(jsonBytes, &config); err != nil { + if err := json.Unmarshal(configBytes, &config); err != nil { return Config{}, err } diff --git a/internal/config/config.schema.json b/internal/config/config.schema.json index 05a4ad1..4e52a6d 100644 --- a/internal/config/config.schema.json +++ b/internal/config/config.schema.json @@ -2,16 +2,25 @@ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { - "profiles": { + "theme": { + "type": "string" + }, + "themeDark": { + "type": "string" + }, + "env": { "type": "object", - "required": [ - "default" - ], - "properties": { - "default": { - "$ref": "#/definitions/profile" + "patternProperties": { + "^[a-zA-Z_]+[a-zA-Z0-9_]*$": { + "type": "string" } - }, + } + }, + "defaultProfile": { + "type": "string" + }, + "profiles": { + "type": "object", "additionalProperties": { "$ref": "#/definitions/profile" } @@ -20,20 +29,30 @@ "definitions": { "profile": { "type": "object", + "required": [ + "path" + ], "properties": { - "shell": { - "required": [ - "command" - ], - "command": { + "path": { + "type": "string" + }, + "args": { + "type": "array", + "items": { "type": "string" - }, - "args": { - "type": "array", - "items": { + } + }, + "env": { + "type": "object", + "patternProperties": { + "^[a-zA-Z_]+[a-zA-Z0-9_]*$": { "type": "string" } } + }, + "icon": { + "type": "string", + "maxLength": 1 } } } diff --git a/internal/server/server.go b/internal/server/server.go index db2fe28..af3108f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -138,7 +138,7 @@ func Serve(m *MessageHandler, port int, token string) error { } w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) + w.Write([]byte("Resized")) }) log.Println("Listening on port", port) @@ -310,19 +310,7 @@ func WebSocketHandler(opts HandlerOpts) func(http.ResponseWriter, *http.Request) return } - var profileName string - if name := r.URL.Query().Get("profile"); name != "" { - profileName = name - } else if r.URL.Query().Has("popup") { - if _, ok := cfg.Profiles["popup"]; ok { - profileName = "popup" - } else { - profileName = "default" - } - } else { - profileName = "default" - } - + profileName := r.URL.Query().Get("profile") profile, ok := cfg.Profiles[profileName] if !ok { log.Println("invalid profile name:", profileName) diff --git a/popcorn.json b/popcorn.json index 53d677f..17e6792 100644 --- a/popcorn.json +++ b/popcorn.json @@ -1,23 +1,23 @@ { + "theme": "Tomorrow", + "themeDark": "Tomorrow Night", + "defaultProfile": "fish", "profiles": { - "default": { + "fish": { "command": "/opt/homebrew/bin/fish" }, - "zsh": { - "command": "/bin/zsh", - "args": [ - "-l" - ] - }, - "popup": { - "env": { - "EDITOR": "kak" - }, + "sunbeam": { "command": "/opt/homebrew/bin/fish", "args": [ "-c", "sunbeam" ] + }, + "zsh": { + "command": "/bin/zsh", + "args": [ + "-l" + ] } } }