Skip to content

Commit 9be7565

Browse files
committed
Add as_omarchy_theme= to aether:// for one-click Omarchy theme installs
aether://apply?colors=URL&wallpaper=URL&as_omarchy_theme=nord renders the imported palette + wallpaper into ~/.config/omarchy/themes/nord/ as a full theme bundle (colors.toml, backgrounds/, per-app templates) and runs omarchy-theme-set nord. Theme persists in the Omarchy picker. Name is validated against [A-Za-z0-9][A-Za-z0-9_.-]* so it's safe as both a filesystem path and an argv argument. Errors out if omarchy-theme-set isn't on PATH. Always silent — installing into the system themes dir is the publisher's consent action.
1 parent ea92046 commit 9be7565

4 files changed

Lines changed: 147 additions & 7 deletions

File tree

cli/cli.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ Import commands:
156156
aether://apply?colors=URL
157157
aether://apply?wallpaper=URL
158158
aether://apply?mode=light|dark
159+
aether://apply?silent=true
160+
aether://apply?as_omarchy_theme=NAME
159161
160162
Color utilities:
161163
aether --color-info <hex> Show all color representations

cli/url_handler.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/exec"
99
"path/filepath"
10+
"regexp"
1011
"strings"
1112
"time"
1213

@@ -19,6 +20,11 @@ import (
1920
"aether/ipc"
2021
)
2122

23+
// safeThemeName allows only filename-friendly characters in an omarchy theme
24+
// name. Reject path separators and shell metachars; the name is used both as
25+
// a directory name and as an argument to omarchy-theme-set.
26+
var safeThemeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
27+
2228
// runHandleURL parses an aether:// URL, downloads any referenced HTTPS assets,
2329
// and either hands them to a running GUI (via IPC) or stages them in the
2430
// pending-import file and launches the GUI.
@@ -92,12 +98,26 @@ func runHandleURL(args []string, templatesFS embed.FS) int {
9298
if v := strings.ToLower(q.Get("silent")); v == "true" || v == "1" || v == "yes" {
9399
imp.Silent = true
94100
}
101+
if v := q.Get("as_omarchy_theme"); v != "" {
102+
if !safeThemeName.MatchString(v) {
103+
fmt.Fprintf(os.Stderr, "Error: as_omarchy_theme must match [A-Za-z0-9][A-Za-z0-9_.-]* (got %q)\n", v)
104+
return 1
105+
}
106+
imp.OmarchyThemeName = v
107+
}
95108

96109
if imp.ExternalTheme == "" && imp.ColorsToml == "" && imp.Wallpaper == "" {
97110
fmt.Fprintln(os.Stderr, "Error: URL has no external_theme=, colors=, or wallpaper= parameter")
98111
return 1
99112
}
100113

114+
// Omarchy install: drop files into ~/.config/omarchy/themes/<name>/ and
115+
// run omarchy-theme-set. Always silent — installing into a system
116+
// location is the consent action by the publisher.
117+
if imp.OmarchyThemeName != "" {
118+
return runOmarchyInstall(&imp, templatesFS)
119+
}
120+
101121
// Silent mode: apply directly in this process, no GUI, no dialog.
102122
// Matches `aether --import-colors-toml` semantics — first-party flows
103123
// only, since any web page can construct silent URLs.
@@ -217,3 +237,105 @@ func runSilentApply(imp *pending.Import, templatesFS embed.FS) int {
217237
_ = pending.Clear() // best-effort cleanup of any prior staged file
218238
return 0
219239
}
240+
241+
// runOmarchyInstall renders the imported theme directly into
242+
// ~/.config/omarchy/themes/<name>/ — colors.toml + backgrounds/<wp> + all
243+
// the per-app templates — and then runs `omarchy-theme-set <name>`. The
244+
// theme name has already been validated against safeThemeName, so it's safe
245+
// to use as both a filesystem path component and an argv argument.
246+
func runOmarchyInstall(imp *pending.Import, templatesFS embed.FS) int {
247+
if !theme.IsOmarchyInstalled() {
248+
fmt.Fprintln(os.Stderr, "Error: omarchy-theme-set not found in PATH; cannot install as an omarchy theme")
249+
return 1
250+
}
251+
252+
var palette [16]string
253+
var bp *blueprint.Blueprint
254+
var err error
255+
256+
switch {
257+
case imp.ExternalTheme != "":
258+
bp, err = blueprint.ImportJSON(imp.ExternalTheme)
259+
case imp.ColorsToml != "":
260+
bp, err = blueprint.ImportColorsToml(imp.ColorsToml)
261+
}
262+
if err != nil {
263+
fmt.Fprintf(os.Stderr, "Error: parse: %v\n", err)
264+
return 1
265+
}
266+
267+
extended := map[string]string{}
268+
if bp != nil {
269+
for i := 0; i < 16 && i < len(bp.Palette.Colors); i++ {
270+
palette[i] = bp.Palette.Colors[i]
271+
}
272+
for k, v := range bp.Palette.ExtendedColors {
273+
extended[k] = v
274+
}
275+
} else {
276+
// Wallpaper-only install: borrow the palette from the currently
277+
// applied colors.toml so the rendered theme isn't blank.
278+
existing := filepath.Join(platform.ThemeDir(), "colors.toml")
279+
if data, err := os.ReadFile(existing); err == nil {
280+
current, bg, fg := omarchy.ParseColorsToml(string(data))
281+
palette = current
282+
if bg != "" {
283+
extended["background"] = bg
284+
}
285+
if fg != "" {
286+
extended["foreground"] = fg
287+
}
288+
}
289+
}
290+
291+
if !paletteHasColors(palette) {
292+
fmt.Fprintln(os.Stderr, "Error: no palette to install (URL had no colors/external_theme and no existing colors.toml to borrow from)")
293+
return 1
294+
}
295+
296+
lightMode := imp.Mode == "light"
297+
298+
targetDir := filepath.Join(platform.OmarchyThemesDir(), imp.OmarchyThemeName)
299+
if err := platform.EnsureDir(targetDir); err != nil {
300+
fmt.Fprintf(os.Stderr, "Error: create theme dir: %v\n", err)
301+
return 1
302+
}
303+
304+
colorRoles := MapColorsToRoles(palette)
305+
writer := theme.NewWriter(templatesFS, "templates")
306+
state := &theme.ThemeState{
307+
Palette: palette,
308+
WallpaperPath: imp.Wallpaper,
309+
LightMode: lightMode,
310+
ColorRoles: colorRoles,
311+
ExtendedColors: extended,
312+
}
313+
314+
fmt.Printf("Installing omarchy theme %q to: %s\n", imp.OmarchyThemeName, targetDir)
315+
if err := writer.GenerateOnly(state, theme.Settings{}, targetDir); err != nil {
316+
fmt.Fprintf(os.Stderr, "Error: render templates: %v\n", err)
317+
return 1
318+
}
319+
320+
fmt.Printf("Activating: omarchy-theme-set %s\n", imp.OmarchyThemeName)
321+
if out, err := platform.RunSync("omarchy-theme-set", imp.OmarchyThemeName); err != nil {
322+
fmt.Fprintf(os.Stderr, "Error: omarchy-theme-set failed: %v\n", err)
323+
if out != "" {
324+
fmt.Fprintln(os.Stderr, out)
325+
}
326+
return 1
327+
}
328+
329+
fmt.Println("Omarchy theme installed and activated")
330+
return 0
331+
}
332+
333+
// paletteHasColors reports whether a palette has at least one non-empty slot.
334+
func paletteHasColors(p [16]string) bool {
335+
for _, c := range p {
336+
if c != "" {
337+
return true
338+
}
339+
}
340+
return false
341+
}

docs/web-handler.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ aether://apply?<param>=<https-url>[&<param>=<https-url>...]
1717
| `wallpaper` | URL to an image | Sets the wallpaper (no re-extraction, even when used alone) |
1818
| `mode` | `light` or `dark` | Forces Aether into light or dark mode before applying. Omit to keep the current setting. |
1919
| `silent` | `true` | Skips the confirm dialog and applies immediately. Use with care: any web page can construct this URL. |
20+
| `as_omarchy_theme` | theme name | Installs into `~/.config/omarchy/themes/<name>/` and runs `omarchy-theme-set <name>`. Always silent. Name must match `[A-Za-z0-9][A-Za-z0-9_.-]*`. |
2021

2122
`external_theme` and `colors` are mutually exclusive; `external_theme` wins when both are present. `wallpaper` can be combined with either, or used alone. `mode` and `silent` can be combined with any of the above.
2223

@@ -52,6 +53,12 @@ Silent apply (no dialog) — for one-click flows where the user has already opte
5253
aether://apply?colors=https://themes.example.com/nord/colors.toml&wallpaper=https://themes.example.com/nord/wp.jpg&silent=true
5354
```
5455

56+
Install as a named Omarchy theme and activate it:
57+
58+
```
59+
aether://apply?colors=https://themes.example.com/nord/colors.toml&wallpaper=https://themes.example.com/nord/wp.jpg&as_omarchy_theme=nord
60+
```
61+
5562
## HTML button
5663

5764
```html
@@ -75,6 +82,14 @@ URL-encode any values containing `&`, `?`, `=`, or spaces.
7582

7683
If Aether is closed when the link is clicked, the launch is automatic and the dialog appears once the GUI is ready.
7784

85+
### `as_omarchy_theme=NAME` — install as an Omarchy theme
86+
87+
Renders the imported palette + wallpaper into `~/.config/omarchy/themes/<name>/` as a real Omarchy theme bundle (colors.toml, backgrounds/, plus all the per-app templates Aether normally writes), then runs `omarchy-theme-set <name>` to activate it. The theme persists in the Omarchy picker and can be re-selected later.
88+
89+
Always silent — installing into the system themes directory is the publisher's consent action. The name is restricted to `[A-Za-z0-9][A-Za-z0-9_.-]*` (max 64 chars) so it can be used as both a filesystem path and an argv argument. Requires `omarchy-theme-set` on PATH; the CLI errors out otherwise.
90+
91+
Wallpaper-only `as_omarchy_theme` installs borrow the currently applied palette from `~/.config/aether/theme/colors.toml` so the rendered bundle isn't blank.
92+
7893
### `silent=true` — apply without confirming
7994

8095
`silent=true` makes the click apply immediately, no dialog. The URL handler runs the apply *itself*, in the same process — same code path as `aether --import-colors-toml URL`. No GUI is launched and no IPC happens; this works identically whether or not the Aether GUI is running. The downloaded files still land in `~/.cache/aether/web-imports/`, but the staging file and confirmation step are skipped.

internal/pending/pending.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ import (
1717
// All paths are local cache paths, populated by the URL handler after the
1818
// originating HTTPS resources have been downloaded.
1919
type Import struct {
20-
ExternalTheme string `json:"external_theme,omitempty"`
21-
ColorsToml string `json:"colors_toml,omitempty"`
22-
Wallpaper string `json:"wallpaper,omitempty"`
23-
Mode string `json:"mode,omitempty"` // "light" | "dark" — empty leaves the current setting alone
24-
Silent bool `json:"silent,omitempty"` // skip the confirm dialog and apply immediately
25-
SourceURL string `json:"source_url,omitempty"`
26-
Timestamp int64 `json:"ts,omitempty"`
20+
ExternalTheme string `json:"external_theme,omitempty"`
21+
ColorsToml string `json:"colors_toml,omitempty"`
22+
Wallpaper string `json:"wallpaper,omitempty"`
23+
Mode string `json:"mode,omitempty"` // "light" | "dark" — empty leaves the current setting alone
24+
Silent bool `json:"silent,omitempty"` // skip the confirm dialog and apply immediately
25+
OmarchyThemeName string `json:"omarchy_theme_name,omitempty"` // install as ~/.config/omarchy/themes/<name>/ and run omarchy-theme-set
26+
SourceURL string `json:"source_url,omitempty"`
27+
Timestamp int64 `json:"ts,omitempty"`
2728
}
2829

2930
// Path returns the location of the handoff file.

0 commit comments

Comments
 (0)