fix(popup): per-process stylesheet, printable font names
The stylesheet is popup-<pid>.css and is removed with the bar (callers use CSSPath/RemoveCSS); the font name is quoted literally and non-printable runes are dropped.
- commit
- 96202fffecf8303b4e4038e297eccc9deeb8f43f
- author
- brice · · 2026-10-09 06:35 UTC
- parent
- 2742b07
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +51 -10
M internal/popup/css.go +28 -9
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strconv" | |
| 8 | 8 | "strings" | |
| 9 | + | "unicode" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // Inset is the space the popup stylesheet puts between the surface edge and | |
| @@ -21,11 +22,13 @@ type Style struct { | |||
| 21 | 22 | ||
| 22 | 23 | func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) } | |
| 23 | 24 | ||
| 24 | - | // cssString makes a font name safe inside a quoted CSS string. | |
| 25 | + | // cssString makes a font name safe inside a quoted CSS string: it drops the | |
| 26 | + | // characters that could end the string or the declaration, and everything that | |
| 27 | + | // does not print (control characters, bidi marks, unassigned runes). | |
| 25 | 28 | func cssString(s string) string { | |
| 26 | 29 | return strings.Map(func(r rune) rune { | |
| 27 | 30 | switch { | |
| 28 | - | case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f: | |
| 31 | + | case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || !unicode.IsPrint(r): | |
| 29 | 32 | return -1 | |
| 30 | 33 | } | |
| 31 | 34 | return r | |
| @@ -37,7 +40,7 @@ func CSS(s Style) string { | |||
| 37 | 40 | fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent) | |
| 38 | 41 | size := strconv.FormatFloat(s.Size, 'f', -1, 64) | |
| 39 | 42 | var b strings.Builder | |
| 40 | - | fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n", | |
| 43 | + | fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: \"%s\"; font-size: %spx; padding: 4px; border: 1px solid %s; }\n", | |
| 41 | 44 | bg, fg, cssString(s.Font), size, ac) | |
| 42 | 45 | b.WriteString(".title { font-weight: bold; }\n") | |
| 43 | 46 | b.WriteString(".body { opacity: 0.85; }\n") | |
| @@ -49,15 +52,32 @@ func CSS(s Style) string { | |||
| 49 | 52 | return b.String() | |
| 50 | 53 | } | |
| 51 | 54 | ||
| 52 | - | // WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory | |
| 53 | - | // 0700, file 0600, replaced atomically) and returns the path. nefergui reads | |
| 54 | - | // its stylesheet from a path. | |
| 55 | - | func WriteCSS(css string) (string, error) { | |
| 55 | + | // CSSPath is the stylesheet of this process, popup-<pid>.css in | |
| 56 | + | // $XDG_RUNTIME_DIR/neferbar: two bars never overwrite each other's file. | |
| 57 | + | func CSSPath() (string, error) { | |
| 56 | 58 | rt := os.Getenv("XDG_RUNTIME_DIR") | |
| 57 | 59 | if rt == "" { | |
| 58 | 60 | return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set") | |
| 59 | 61 | } | |
| 60 | - | dir := filepath.Join(rt, "neferbar") | |
| 62 | + | return filepath.Join(rt, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css"), nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // RemoveCSS deletes this process's stylesheet, if it was written. | |
| 66 | + | func RemoveCSS() { | |
| 67 | + | if p, err := CSSPath(); err == nil { | |
| 68 | + | _ = os.Remove(p) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // WriteCSS stores css as CSSPath (directory 0700, file 0600, replaced | |
| 73 | + | // atomically) and returns the path. nefergui reads its stylesheet from a path. | |
| 74 | + | // Call RemoveCSS when done. | |
| 75 | + | func WriteCSS(css string) (string, error) { | |
| 76 | + | path, err := CSSPath() | |
| 77 | + | if err != nil { | |
| 78 | + | return "", err | |
| 79 | + | } | |
| 80 | + | dir := filepath.Dir(path) | |
| 61 | 81 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 62 | 82 | return "", fmt.Errorf("popup: %w", err) | |
| 63 | 83 | } | |
| @@ -76,7 +96,6 @@ func WriteCSS(css string) (string, error) { | |||
| 76 | 96 | if err == nil { | |
| 77 | 97 | err = os.Chmod(tmp, 0o600) | |
| 78 | 98 | } | |
| 79 | - | path := filepath.Join(dir, "popup.css") | |
| 80 | 99 | if err == nil { | |
| 81 | 100 | err = os.Rename(tmp, path) | |
| 82 | 101 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | "strconv" | 7 | "strconv" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | + | "unicode" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | // Inset is the space the popup stylesheet puts between the surface edge and | 12 | // Inset is the space the popup stylesheet puts between the surface edge and | ||
| @@ -21,11 +22,13 @@ type Style struct { | |||||
| 21 | 22 | ||||
| 22 | func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) } | 23 | func hex(c [3]uint8) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) } | ||
| 23 | 24 | ||||
| 24 | - | // cssString makes a font name safe inside a quoted CSS string. | 25 | + | // cssString makes a font name safe inside a quoted CSS string: it drops the |
| 26 | + | // characters that could end the string or the declaration, and everything that | |||
| 27 | + | // does not print (control characters, bidi marks, unassigned runes). | |||
| 25 | func cssString(s string) string { | 28 | func cssString(s string) string { | ||
| 26 | return strings.Map(func(r rune) rune { | 29 | return strings.Map(func(r rune) rune { | ||
| 27 | switch { | 30 | switch { | ||
| 28 | - | case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || r < ' ' || r == 0x7f: | 31 | + | case r == '"' || r == '\\' || r == '{' || r == '}' || r == ';' || !unicode.IsPrint(r): |
| 29 | return -1 | 32 | return -1 | ||
| 30 | } | 33 | } | ||
| 31 | return r | 34 | return r | ||
| @@ -37,7 +40,7 @@ func CSS(s Style) string { | |||||
| 37 | fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent) | 40 | fg, bg, ac := hex(s.FG), hex(s.BG), hex(s.Accent) | ||
| 38 | size := strconv.FormatFloat(s.Size, 'f', -1, 64) | 41 | size := strconv.FormatFloat(s.Size, 'f', -1, 64) | ||
| 39 | var b strings.Builder | 42 | var b strings.Builder | ||
| 40 | - | fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: %q; font-size: %spx; padding: 4px; border: 1px solid %s; }\n", | 43 | + | fmt.Fprintf(&b, "app { background-color: %s; color: %s; font-family: \"%s\"; font-size: %spx; padding: 4px; border: 1px solid %s; }\n", |
| 41 | bg, fg, cssString(s.Font), size, ac) | 44 | bg, fg, cssString(s.Font), size, ac) | ||
| 42 | b.WriteString(".title { font-weight: bold; }\n") | 45 | b.WriteString(".title { font-weight: bold; }\n") | ||
| 43 | b.WriteString(".body { opacity: 0.85; }\n") | 46 | b.WriteString(".body { opacity: 0.85; }\n") | ||
| @@ -49,15 +52,32 @@ func CSS(s Style) string { | |||||
| 49 | return b.String() | 52 | return b.String() | ||
| 50 | } | 53 | } | ||
| 51 | 54 | ||||
| 52 | - | // WriteCSS stores css as popup.css in $XDG_RUNTIME_DIR/neferbar (directory | 55 | + | // CSSPath is the stylesheet of this process, popup-<pid>.css in |
| 53 | - | // 0700, file 0600, replaced atomically) and returns the path. nefergui reads | 56 | + | // $XDG_RUNTIME_DIR/neferbar: two bars never overwrite each other's file. |
| 54 | - | // its stylesheet from a path. | 57 | + | func CSSPath() (string, error) { |
| 55 | - | func WriteCSS(css string) (string, error) { | |||
| 56 | rt := os.Getenv("XDG_RUNTIME_DIR") | 58 | rt := os.Getenv("XDG_RUNTIME_DIR") | ||
| 57 | if rt == "" { | 59 | if rt == "" { | ||
| 58 | return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set") | 60 | return "", fmt.Errorf("popup: XDG_RUNTIME_DIR is not set") | ||
| 59 | } | 61 | } | ||
| 60 | - | dir := filepath.Join(rt, "neferbar") | 62 | + | return filepath.Join(rt, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css"), nil |
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // RemoveCSS deletes this process's stylesheet, if it was written. | |||
| 66 | + | func RemoveCSS() { | |||
| 67 | + | if p, err := CSSPath(); err == nil { | |||
| 68 | + | _ = os.Remove(p) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // WriteCSS stores css as CSSPath (directory 0700, file 0600, replaced | |||
| 73 | + | // atomically) and returns the path. nefergui reads its stylesheet from a path. | |||
| 74 | + | // Call RemoveCSS when done. | |||
| 75 | + | func WriteCSS(css string) (string, error) { | |||
| 76 | + | path, err := CSSPath() | |||
| 77 | + | if err != nil { | |||
| 78 | + | return "", err | |||
| 79 | + | } | |||
| 80 | + | dir := filepath.Dir(path) | |||
| 61 | if err := os.MkdirAll(dir, 0o700); err != nil { | 81 | if err := os.MkdirAll(dir, 0o700); err != nil { | ||
| 62 | return "", fmt.Errorf("popup: %w", err) | 82 | return "", fmt.Errorf("popup: %w", err) | ||
| 63 | } | 83 | } | ||
| @@ -76,7 +96,6 @@ func WriteCSS(css string) (string, error) { | |||||
| 76 | if err == nil { | 96 | if err == nil { | ||
| 77 | err = os.Chmod(tmp, 0o600) | 97 | err = os.Chmod(tmp, 0o600) | ||
| 78 | } | 98 | } | ||
| 79 | - | path := filepath.Join(dir, "popup.css") | |||
| 80 | if err == nil { | 99 | if err == nil { | ||
| 81 | err = os.Rename(tmp, path) | 100 | err = os.Rename(tmp, path) | ||
| 82 | } | 101 | } | ||
M internal/popup/popup_test.go +23 -1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "slices" | |
| 7 | + | "strconv" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| @@ -95,6 +96,14 @@ separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; } | |||
| 95 | 96 | if got != want { | |
| 96 | 97 | t.Fatalf("CSS =\n%s\nwant\n%s", got, want) | |
| 97 | 98 | } | |
| 99 | + | // Non-ASCII names stay as they are: %q would have turned them into escapes. | |
| 100 | + | if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) { | |
| 101 | + | t.Fatalf("font-family changed:\n%s", got) | |
| 102 | + | } | |
| 103 | + | // Runes that do not print are dropped: controls, bidi overrides, DEL, BOM. | |
| 104 | + | if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" { | |
| 105 | + | t.Fatalf("cssString = %q", got) | |
| 106 | + | } | |
| 98 | 107 | // A font name cannot break out of the declaration. | |
| 99 | 108 | evil := CSS(Style{Font: `x"; } body { `, Size: 10}) | |
| 100 | 109 | if strings.Count(evil, "{") != strings.Count(want, "{") { | |
| @@ -109,7 +118,7 @@ func TestWriteCSSPermissions(t *testing.T) { | |||
| 109 | 118 | if err != nil { | |
| 110 | 119 | t.Fatal(err) | |
| 111 | 120 | } | |
| 112 | - | if path != filepath.Join(dir, "neferbar", "popup.css") { | |
| 121 | + | if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") { | |
| 113 | 122 | t.Fatalf("path = %s", path) | |
| 114 | 123 | } | |
| 115 | 124 | for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} { | |
| @@ -128,6 +137,19 @@ func TestWriteCSSPermissions(t *testing.T) { | |||
| 128 | 137 | if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 { | |
| 129 | 138 | t.Fatalf("directory holds %d entries, want 1", len(ents)) | |
| 130 | 139 | } | |
| 140 | + | // Another bar's file is left alone; ours is removed. | |
| 141 | + | other := filepath.Join(filepath.Dir(path), "popup-1.css") | |
| 142 | + | if err = os.WriteFile(other, []byte("x"), 0o600); err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | RemoveCSS() | |
| 146 | + | if _, err = os.Stat(path); !os.IsNotExist(err) { | |
| 147 | + | t.Fatalf("RemoveCSS left %s (%v)", path, err) | |
| 148 | + | } | |
| 149 | + | if _, err = os.Stat(other); err != nil { | |
| 150 | + | t.Fatalf("RemoveCSS touched another bar's file: %v", err) | |
| 151 | + | } | |
| 152 | + | RemoveCSS() // twice is fine | |
| 131 | 153 | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 132 | 154 | if _, err = WriteCSS("x"); err == nil { | |
| 133 | 155 | t.Fatal("no runtime dir must fail") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | + | "strconv" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| @@ -95,6 +96,14 @@ separator { background-color: #cccccc; opacity: 0.3; margin: 4px 0; } | |||||
| 95 | if got != want { | 96 | if got != want { | ||
| 96 | t.Fatalf("CSS =\n%s\nwant\n%s", got, want) | 97 | t.Fatalf("CSS =\n%s\nwant\n%s", got, want) | ||
| 97 | } | 98 | } | ||
| 99 | + | // Non-ASCII names stay as they are: %q would have turned them into escapes. | |||
| 100 | + | if got := CSS(Style{Font: "JetBrainsMono Nerd Font Mono é", Size: 10}); !strings.Contains(got, `font-family: "JetBrainsMono Nerd Font Mono é";`) { | |||
| 101 | + | t.Fatalf("font-family changed:\n%s", got) | |||
| 102 | + | } | |||
| 103 | + | // Runes that do not print are dropped: controls, bidi overrides, DEL, BOM. | |||
| 104 | + | if got := cssString("a\u202eb\u0000c\x7fd\ufeffe\u200bf\n"); got != "abcdef" { | |||
| 105 | + | t.Fatalf("cssString = %q", got) | |||
| 106 | + | } | |||
| 98 | // A font name cannot break out of the declaration. | 107 | // A font name cannot break out of the declaration. | ||
| 99 | evil := CSS(Style{Font: `x"; } body { `, Size: 10}) | 108 | evil := CSS(Style{Font: `x"; } body { `, Size: 10}) | ||
| 100 | if strings.Count(evil, "{") != strings.Count(want, "{") { | 109 | if strings.Count(evil, "{") != strings.Count(want, "{") { | ||
| @@ -109,7 +118,7 @@ func TestWriteCSSPermissions(t *testing.T) { | |||||
| 109 | if err != nil { | 118 | if err != nil { | ||
| 110 | t.Fatal(err) | 119 | t.Fatal(err) | ||
| 111 | } | 120 | } | ||
| 112 | - | if path != filepath.Join(dir, "neferbar", "popup.css") { | 121 | + | if path != filepath.Join(dir, "neferbar", "popup-"+strconv.Itoa(os.Getpid())+".css") { |
| 113 | t.Fatalf("path = %s", path) | 122 | t.Fatalf("path = %s", path) | ||
| 114 | } | 123 | } | ||
| 115 | for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} { | 124 | for p, want := range map[string]os.FileMode{filepath.Dir(path): 0o700, path: 0o600} { | ||
| @@ -128,6 +137,19 @@ func TestWriteCSSPermissions(t *testing.T) { | |||||
| 128 | if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 { | 137 | if ents, _ := os.ReadDir(filepath.Dir(path)); len(ents) != 1 { | ||
| 129 | t.Fatalf("directory holds %d entries, want 1", len(ents)) | 138 | t.Fatalf("directory holds %d entries, want 1", len(ents)) | ||
| 130 | } | 139 | } | ||
| 140 | + | // Another bar's file is left alone; ours is removed. | |||
| 141 | + | other := filepath.Join(filepath.Dir(path), "popup-1.css") | |||
| 142 | + | if err = os.WriteFile(other, []byte("x"), 0o600); err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | RemoveCSS() | |||
| 146 | + | if _, err = os.Stat(path); !os.IsNotExist(err) { | |||
| 147 | + | t.Fatalf("RemoveCSS left %s (%v)", path, err) | |||
| 148 | + | } | |||
| 149 | + | if _, err = os.Stat(other); err != nil { | |||
| 150 | + | t.Fatalf("RemoveCSS touched another bar's file: %v", err) | |||
| 151 | + | } | |||
| 152 | + | RemoveCSS() // twice is fine | |||
| 131 | t.Setenv("XDG_RUNTIME_DIR", "") | 153 | t.Setenv("XDG_RUNTIME_DIR", "") | ||
| 132 | if _, err = WriteCSS("x"); err == nil { | 154 | if _, err = WriteCSS("x"); err == nil { | ||
| 133 | t.Fatal("no runtime dir must fail") | 155 | t.Fatal("no runtime dir must fail") | ||