From 1ae09920a3340ff0c7371344257e98b89970df62 Mon Sep 17 00:00:00 2001 From: John Sterling Date: Sun, 21 Jun 2026 16:15:18 +0200 Subject: [PATCH 1/7] Convert to React, add REST API --- .gitignore | 3 +- apps/finicky/src/logger/logger.go | 10 +- apps/finicky/src/main.go | 101 +- apps/finicky/src/rules/rules.go | 16 +- apps/finicky/src/window/server.go | 234 +++++ apps/finicky/src/window/window.go | 207 +---- apps/finicky/src/window/window.h | 8 +- apps/finicky/src/window/window.m | 148 +-- packages/finicky-ui/index.html | 2 +- packages/finicky-ui/package-lock.json | 874 +++++++++++++----- packages/finicky-ui/package.json | 17 +- packages/finicky-ui/src/App.module.css | 27 + packages/finicky-ui/src/App.svelte | 287 ------ packages/finicky-ui/src/App.tsx | 74 ++ .../BrowserProfileSelector.module.css | 102 ++ .../components/BrowserProfileSelector.svelte | 248 ----- .../src/components/BrowserProfileSelector.tsx | 158 ++++ .../src/components/Footer.module.css | 73 ++ packages/finicky-ui/src/components/Footer.tsx | 64 ++ .../src/components/LogContent.module.css | 57 ++ .../src/components/LogContent.svelte | 93 -- .../finicky-ui/src/components/LogContent.tsx | 41 + .../src/components/OptionRow.module.css | 127 +++ .../src/components/OptionRow.svelte | 178 ---- .../finicky-ui/src/components/OptionRow.tsx | 59 ++ .../src/components/PageContainer.module.css | 40 + .../src/components/PageContainer.svelte | 70 -- .../src/components/PageContainer.tsx | 23 + .../src/components/TabBar.module.css | 104 +++ .../finicky-ui/src/components/TabBar.svelte | 179 ---- packages/finicky-ui/src/components/TabBar.tsx | 61 ++ .../src/components/ToastContainer.module.css | 96 ++ .../src/components/ToastContainer.svelte | 170 ---- .../src/components/ToastContainer.tsx | 39 + .../finicky-ui/src/components/Toggle.svelte | 0 .../src/components/Tooltip.module.css | 33 + .../finicky-ui/src/components/Tooltip.svelte | 54 -- .../finicky-ui/src/components/Tooltip.tsx | 17 + .../src/components/icons/About.svelte | 15 - .../finicky-ui/src/components/icons/About.tsx | 9 + .../src/components/icons/External.svelte | 15 - .../src/components/icons/External.tsx | 9 + .../src/components/icons/Info.svelte | 15 - .../finicky-ui/src/components/icons/Info.tsx | 9 + .../src/components/icons/Link.svelte | 14 - .../finicky-ui/src/components/icons/Link.tsx | 8 + .../src/components/icons/Lock.svelte | 15 - .../finicky-ui/src/components/icons/Lock.tsx | 8 + .../src/components/icons/Logs.svelte | 6 - .../finicky-ui/src/components/icons/Logs.tsx | 10 + .../src/components/icons/Preferences.svelte | 15 - .../src/components/icons/Preferences.tsx | 8 + .../src/components/icons/Rules.svelte | 13 - .../finicky-ui/src/components/icons/Rules.tsx | 9 + .../src/components/icons/Spinner.svelte | 12 - .../src/components/icons/Spinner.tsx | 7 + .../src/components/icons/Test.svelte | 5 - .../finicky-ui/src/components/icons/Test.tsx | 9 + .../src/components/icons/Warning.svelte | 15 - .../src/components/icons/Warning.tsx | 9 + .../finicky-ui/src/components/icons/X.svelte | 14 - .../finicky-ui/src/components/icons/X.tsx | 8 + packages/finicky-ui/src/lib/api.ts | 45 + packages/finicky-ui/src/lib/appStore.ts | 62 ++ packages/finicky-ui/src/lib/testUrlStore.ts | 11 - packages/finicky-ui/src/lib/toast.ts | 133 ++- .../src/lib/useDebouncedCallback.ts | 20 + packages/finicky-ui/src/lib/useLogControls.ts | 43 + packages/finicky-ui/src/lib/useRulesSave.ts | 30 + packages/finicky-ui/src/lib/useSSE.ts | 31 + packages/finicky-ui/src/main.ts | 10 - packages/finicky-ui/src/main.tsx | 6 + .../finicky-ui/src/pages/About.module.css | 73 ++ packages/finicky-ui/src/pages/About.svelte | 145 --- packages/finicky-ui/src/pages/About.tsx | 69 ++ .../finicky-ui/src/pages/LogViewer.module.css | 36 + .../finicky-ui/src/pages/LogViewer.svelte | 100 -- packages/finicky-ui/src/pages/LogViewer.tsx | 28 + .../finicky-ui/src/pages/Rules.module.css | 213 +++++ packages/finicky-ui/src/pages/Rules.svelte | 535 ----------- packages/finicky-ui/src/pages/Rules.tsx | 293 ++++++ .../finicky-ui/src/pages/StartPage.module.css | 156 ++++ .../finicky-ui/src/pages/StartPage.svelte | 363 -------- packages/finicky-ui/src/pages/StartPage.tsx | 207 +++++ .../finicky-ui/src/pages/TestUrl.module.css | 140 +++ packages/finicky-ui/src/pages/TestUrl.svelte | 285 ------ packages/finicky-ui/src/pages/TestUrl.tsx | 137 +++ packages/finicky-ui/src/types.ts | 40 +- packages/finicky-ui/src/vite-env.d.ts | 1 - packages/finicky-ui/svelte.config.js | 7 - packages/finicky-ui/tsconfig.app.json | 28 +- packages/finicky-ui/vite.config.ts | 7 +- 92 files changed, 3942 insertions(+), 3663 deletions(-) create mode 100644 apps/finicky/src/window/server.go create mode 100644 packages/finicky-ui/src/App.module.css delete mode 100644 packages/finicky-ui/src/App.svelte create mode 100644 packages/finicky-ui/src/App.tsx create mode 100644 packages/finicky-ui/src/components/BrowserProfileSelector.module.css delete mode 100644 packages/finicky-ui/src/components/BrowserProfileSelector.svelte create mode 100644 packages/finicky-ui/src/components/BrowserProfileSelector.tsx create mode 100644 packages/finicky-ui/src/components/Footer.module.css create mode 100644 packages/finicky-ui/src/components/Footer.tsx create mode 100644 packages/finicky-ui/src/components/LogContent.module.css delete mode 100644 packages/finicky-ui/src/components/LogContent.svelte create mode 100644 packages/finicky-ui/src/components/LogContent.tsx create mode 100644 packages/finicky-ui/src/components/OptionRow.module.css delete mode 100644 packages/finicky-ui/src/components/OptionRow.svelte create mode 100644 packages/finicky-ui/src/components/OptionRow.tsx create mode 100644 packages/finicky-ui/src/components/PageContainer.module.css delete mode 100644 packages/finicky-ui/src/components/PageContainer.svelte create mode 100644 packages/finicky-ui/src/components/PageContainer.tsx create mode 100644 packages/finicky-ui/src/components/TabBar.module.css delete mode 100644 packages/finicky-ui/src/components/TabBar.svelte create mode 100644 packages/finicky-ui/src/components/TabBar.tsx create mode 100644 packages/finicky-ui/src/components/ToastContainer.module.css delete mode 100644 packages/finicky-ui/src/components/ToastContainer.svelte create mode 100644 packages/finicky-ui/src/components/ToastContainer.tsx delete mode 100644 packages/finicky-ui/src/components/Toggle.svelte create mode 100644 packages/finicky-ui/src/components/Tooltip.module.css delete mode 100644 packages/finicky-ui/src/components/Tooltip.svelte create mode 100644 packages/finicky-ui/src/components/Tooltip.tsx delete mode 100644 packages/finicky-ui/src/components/icons/About.svelte create mode 100644 packages/finicky-ui/src/components/icons/About.tsx delete mode 100644 packages/finicky-ui/src/components/icons/External.svelte create mode 100644 packages/finicky-ui/src/components/icons/External.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Info.svelte create mode 100644 packages/finicky-ui/src/components/icons/Info.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Link.svelte create mode 100644 packages/finicky-ui/src/components/icons/Link.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Lock.svelte create mode 100644 packages/finicky-ui/src/components/icons/Lock.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Logs.svelte create mode 100644 packages/finicky-ui/src/components/icons/Logs.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Preferences.svelte create mode 100644 packages/finicky-ui/src/components/icons/Preferences.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Rules.svelte create mode 100644 packages/finicky-ui/src/components/icons/Rules.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Spinner.svelte create mode 100644 packages/finicky-ui/src/components/icons/Spinner.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Test.svelte create mode 100644 packages/finicky-ui/src/components/icons/Test.tsx delete mode 100644 packages/finicky-ui/src/components/icons/Warning.svelte create mode 100644 packages/finicky-ui/src/components/icons/Warning.tsx delete mode 100644 packages/finicky-ui/src/components/icons/X.svelte create mode 100644 packages/finicky-ui/src/components/icons/X.tsx create mode 100644 packages/finicky-ui/src/lib/api.ts create mode 100644 packages/finicky-ui/src/lib/appStore.ts delete mode 100644 packages/finicky-ui/src/lib/testUrlStore.ts create mode 100644 packages/finicky-ui/src/lib/useDebouncedCallback.ts create mode 100644 packages/finicky-ui/src/lib/useLogControls.ts create mode 100644 packages/finicky-ui/src/lib/useRulesSave.ts create mode 100644 packages/finicky-ui/src/lib/useSSE.ts delete mode 100644 packages/finicky-ui/src/main.ts create mode 100644 packages/finicky-ui/src/main.tsx create mode 100644 packages/finicky-ui/src/pages/About.module.css delete mode 100644 packages/finicky-ui/src/pages/About.svelte create mode 100644 packages/finicky-ui/src/pages/About.tsx create mode 100644 packages/finicky-ui/src/pages/LogViewer.module.css delete mode 100644 packages/finicky-ui/src/pages/LogViewer.svelte create mode 100644 packages/finicky-ui/src/pages/LogViewer.tsx create mode 100644 packages/finicky-ui/src/pages/Rules.module.css delete mode 100644 packages/finicky-ui/src/pages/Rules.svelte create mode 100644 packages/finicky-ui/src/pages/Rules.tsx create mode 100644 packages/finicky-ui/src/pages/StartPage.module.css delete mode 100644 packages/finicky-ui/src/pages/StartPage.svelte create mode 100644 packages/finicky-ui/src/pages/StartPage.tsx create mode 100644 packages/finicky-ui/src/pages/TestUrl.module.css delete mode 100644 packages/finicky-ui/src/pages/TestUrl.svelte create mode 100644 packages/finicky-ui/src/pages/TestUrl.tsx delete mode 100644 packages/finicky-ui/svelte.config.js diff --git a/.gitignore b/.gitignore index 1ff93c9..352dab2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules build .env dist -example-config \ No newline at end of file +example-config +.fallow \ No newline at end of file diff --git a/apps/finicky/src/logger/logger.go b/apps/finicky/src/logger/logger.go index 324bc28..17816ee 100644 --- a/apps/finicky/src/logger/logger.go +++ b/apps/finicky/src/logger/logger.go @@ -19,13 +19,11 @@ var file *os.File type windowWriter struct{} func (w *windowWriter) Write(p []byte) (n int, err error) { - // Remove trailing newline if present - msg := string(p) - if len(msg) > 0 && msg[len(msg)-1] == '\n' { - msg = msg[:len(msg)-1] + payload := p + if len(payload) > 0 && payload[len(payload)-1] == '\n' { + payload = payload[:len(payload)-1] } - - window.SendMessageToWebView("log", msg) + window.BroadcastSSERaw("log", payload) return len(p), nil } diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 829a8f2..2a47d96 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -45,10 +45,10 @@ type URLInfo struct { } type ConfigInfo struct { - Handlers int16 - Rewrites int16 - DefaultBrowser string - ConfigPath string + Handlers int16 `json:"handlers"` + Rewrites int16 `json:"rewrites"` + DefaultBrowser string `json:"defaultBrowser"` + ConfigPath string `json:"configPath"` } var urlListener chan URLInfo = make(chan URLInfo) @@ -62,6 +62,7 @@ var dryRun bool = false var skipJSConfig bool = false var updateInfo UpdateInfo var configInfo *ConfigInfo +var lastConfigPayload map[string]interface{} var shouldKeepRunning bool = true func main() { @@ -132,9 +133,36 @@ func main() { go checkForUpdates() - // Set up test URL handler - window.TestUrlHandler = func(url string) { - go TestURLInternal(url) + window.TestURLFunc = func(url string) (interface{}, error) { + slog.Debug("Testing URL", "url", url) + cfg, err := resolver.ResolveURL(vm, url, nil, false) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "url": cfg.URL, + "browser": cfg.Name, + "openInBackground": cfg.OpenInBackground, + "profile": cfg.Profile, + "args": cfg.Args, + }, nil + } + + window.GetVersionFunc = version.GetCurrentVersion + + window.GetConfigFunc = func() interface{} { + return lastConfigPayload + } + + window.GetUpdateInfoFunc = func() interface{} { + if updateInfo.ReleaseInfo == nil && !updateInfo.UpdateCheckEnabled { + return nil + } + return buildUpdateInfoPayload() + } + + if err := window.StartAPIServer(); err != nil { + slog.Error("Failed to start API server", "error", err) } // Set up rules save handler. @@ -299,32 +327,6 @@ func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, window } } -//export TestURL -func TestURL(url *C.char) { - urlString := C.GoString(url) - TestURLInternal(urlString) -} - -func TestURLInternal(urlString string) { - slog.Debug("Testing URL", "url", urlString) - - config, err := resolver.ResolveURL(vm, urlString, nil, false) - if err != nil { - slog.Error("Failed to evaluate URL", "error", err) - window.SendMessageToWebView("testUrlResult", map[string]interface{}{ - "error": err.Error(), - }) - return - } - - window.SendMessageToWebView("testUrlResult", map[string]interface{}{ - "url": config.URL, - "browser": config.Name, - "openInBackground": config.OpenInBackground, - "profile": config.Profile, - "args": config.Args, - }) -} func handleFatalError(errorMessage string) { slog.Error("Fatal error", "msg", errorMessage) @@ -341,11 +343,6 @@ func QueueWindowDisplay(openWindow int32) { func ShowConfigWindow() { slog.Debug("Showing window") window.ShowWindow() - - // Send version information - currentVersion := version.GetCurrentVersion() - window.SendMessageToWebView("version", currentVersion) - } //export WindowDidClose @@ -383,22 +380,23 @@ func checkForUpdates() { slog.Info("New version is available", "version", updateInfo.ReleaseInfo.LatestVersion) } + window.BroadcastSSE("updateInfo", buildUpdateInfoPayload()) +} + +func buildUpdateInfoPayload() map[string]interface{} { if updateInfo.ReleaseInfo != nil { - window.SendMessageToWebView("updateInfo", map[string]interface{}{ + return map[string]interface{}{ "version": updateInfo.ReleaseInfo.LatestVersion, "hasUpdate": updateInfo.ReleaseInfo.HasUpdate, "updateCheckEnabled": updateInfo.UpdateCheckEnabled, "downloadUrl": updateInfo.ReleaseInfo.DownloadUrl, "releaseUrl": updateInfo.ReleaseInfo.ReleaseUrl, - }) - } else { - window.SendMessageToWebView("updateInfo", map[string]interface{}{ - "version": "", - "hasUpdate": false, - "updateCheckEnabled": updateInfo.UpdateCheckEnabled, - "downloadUrl": "", - "releaseUrl": "", - }) + } + } + return map[string]interface{}{ + "version": "", "hasUpdate": false, + "updateCheckEnabled": updateInfo.UpdateCheckEnabled, + "downloadUrl": "", "releaseUrl": "", } } @@ -480,19 +478,20 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error opts := newVM.GetAllConfigOptions() logRequests = opts.LogRequests - window.SendMessageToWebView("config", map[string]interface{}{ + lastConfigPayload = map[string]interface{}{ "handlers": configInfo.Handlers, "rewrites": configInfo.Rewrites, "defaultBrowser": configInfo.DefaultBrowser, "configPath": util.ShortenPath(configInfo.ConfigPath), - "isJSConfig": newVM.IsJSConfig(), + "hasJsConfig": newVM.IsJSConfig(), "options": map[string]interface{}{ "keepRunning": opts.KeepRunning, "hideIcon": opts.HideIcon, "logRequests": opts.LogRequests, "checkForUpdates": opts.CheckForUpdates, }, - }) + } + window.BroadcastSSE("config", lastConfigPayload) return newVM, nil } diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 30909ec..33dbb65 100644 --- a/apps/finicky/src/rules/rules.go +++ b/apps/finicky/src/rules/rules.go @@ -36,18 +36,16 @@ func (r *Rule) UnmarshalJSON(data []byte) error { return nil } -// MarshalJSON serializes match as a plain string when there is only one entry. +// MarshalJSON serializes match always as an array for consistency. func (r Rule) MarshalJSON() ([]byte, error) { type RuleAlias struct { - Match interface{} `json:"match"` - Browser string `json:"browser"` - Profile string `json:"profile,omitempty"` + Match []string `json:"match"` + Browser string `json:"browser"` + Profile string `json:"profile,omitempty"` } - var match interface{} - if len(r.Match) == 1 { - match = r.Match[0] - } else { - match = r.Match + match := r.Match + if match == nil { + match = []string{} } return json.Marshal(RuleAlias{Match: match, Browser: r.Browser, Profile: r.Profile}) } diff --git a/apps/finicky/src/window/server.go b/apps/finicky/src/window/server.go new file mode 100644 index 0000000..b576b09 --- /dev/null +++ b/apps/finicky/src/window/server.go @@ -0,0 +1,234 @@ +package window + +import ( + "encoding/json" + "finicky/browser" + "finicky/rules" + "finicky/util" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "sync" +) + +var ( + apiPort int + hub = newSSEHub() + GetVersionFunc func() string + GetConfigFunc func() interface{} + GetUpdateInfoFunc func() interface{} + TestURLFunc func(url string) (interface{}, error) +) + +type sseHub struct { + mu sync.Mutex + clients map[chan string]struct{} +} + +func newSSEHub() *sseHub { + return &sseHub{clients: make(map[chan string]struct{})} +} + +func (h *sseHub) register(ch chan string) { + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() +} + +func (h *sseHub) unregister(ch chan string) { + h.mu.Lock() + delete(h.clients, ch) + close(ch) + h.mu.Unlock() +} + +func (h *sseHub) broadcast(event string, data interface{}) { + payload, err := json.Marshal(data) + if err != nil { + slog.Error("Failed to marshal SSE event", "event", event, "error", err) + return + } + h.broadcastRaw(event, payload) +} + +func (h *sseHub) broadcastRaw(event string, payload []byte) { + msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, payload) + h.mu.Lock() + for ch := range h.clients { + select { + case ch <- msg: + default: + } + } + h.mu.Unlock() +} + +// BroadcastSSE sends a named SSE event to all connected UI clients. +func BroadcastSSE(event string, data interface{}) { + hub.broadcast(event, data) +} + +// BroadcastSSERaw sends a named SSE event with pre-encoded JSON (e.g. from slog). +func BroadcastSSERaw(event string, rawJSON []byte) { + hub.broadcastRaw(event, rawJSON) +} + +// StartAPIServer binds to a random local port and serves the REST + SSE API. +func StartAPIServer() error { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("failed to start API server: %w", err) + } + apiPort = ln.Addr().(*net.TCPAddr).Port + slog.Debug("API server listening", "url", fmt.Sprintf("http://127.0.0.1:%d/api", apiPort)) + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/initial-data", handleInitialData) + mux.HandleFunc("GET /api/rules", handleGetRulesHTTP) + mux.HandleFunc("POST /api/rules", handleSaveRulesHTTP) + mux.HandleFunc("GET /api/browsers", handleGetBrowsersHTTP) + mux.HandleFunc("GET /api/browser-profiles", handleGetBrowserProfilesHTTP) + mux.HandleFunc("POST /api/test-url", handleTestURLHTTP) + mux.HandleFunc("GET /api/events", handleSSE) + + go http.Serve(ln, corsMiddleware(mux)) //nolint:errcheck + return nil +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + slog.Error("Failed to write JSON response", "error", err) + } +} + +func handleInitialData(w http.ResponseWriter, _ *http.Request) { + result := map[string]interface{}{ + "rules": getRulesData(), + "installedBrowsers": browser.GetInstalledBrowsers(), + } + if GetVersionFunc != nil { + result["version"] = GetVersionFunc() + } + if GetConfigFunc != nil { + result["config"] = GetConfigFunc() + } + if GetUpdateInfoFunc != nil { + if ui := GetUpdateInfoFunc(); ui != nil { + result["updateInfo"] = ui + } + } + writeJSON(w, result) +} + +func handleGetRulesHTTP(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, getRulesData()) +} + +func handleSaveRulesHTTP(w http.ResponseWriter, r *http.Request) { + var rf rules.RulesFile + if err := json.NewDecoder(r.Body).Decode(&rf); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := rules.Save(rf); err != nil { + slog.Error("Failed to save rules", "error", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + slog.Debug("Rules saved", "count", len(rf.Rules)) + if SaveRulesHandler != nil { + go SaveRulesHandler(rf) + } + writeJSON(w, getRulesData()) +} + +func handleGetBrowsersHTTP(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, browser.GetInstalledBrowsers()) +} + +func handleGetBrowserProfilesHTTP(w http.ResponseWriter, r *http.Request) { + browserName := r.URL.Query().Get("browser") + writeJSON(w, browser.GetProfilesForBrowser(browserName)) +} + +func handleTestURLHTTP(w http.ResponseWriter, r *http.Request) { + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if TestURLFunc == nil { + http.Error(w, "test URL handler not initialized", http.StatusServiceUnavailable) + return + } + result, err := TestURLFunc(body.URL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, result) +} + +func handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := make(chan string, 32) + hub.register(ch) + defer hub.unregister(ch) + + for { + select { + case msg, ok := <-ch: + if !ok { + return + } + fmt.Fprint(w, msg) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func getRulesData() interface{} { + rf, err := rules.Load() + if err != nil { + slog.Error("Failed to load rules", "error", err) + return map[string]interface{}{"defaultBrowser": "", "rules": []interface{}{}} + } + path, _ := rules.GetPath() + var rulesPath string + if _, statErr := os.Stat(path); statErr == nil { + rulesPath = path + } + type rulesResponse struct { + rules.RulesFile + Path string `json:"path,omitempty"` + } + return rulesResponse{RulesFile: rf, Path: util.ShortenPath(rulesPath)} +} diff --git a/apps/finicky/src/window/window.go b/apps/finicky/src/window/window.go index 1119946..8768446 100644 --- a/apps/finicky/src/window/window.go +++ b/apps/finicky/src/window/window.go @@ -8,121 +8,55 @@ package window */ import "C" import ( - "encoding/json" "finicky/assets" - "finicky/browser" "finicky/rules" - "finicky/util" - "finicky/version" - "fmt" "io/fs" "log/slog" "net/http" - "os" "path/filepath" "strings" - "sync" "unsafe" ) -var ( - messageQueue []string - queueMutex sync.Mutex - windowReady bool - TestUrlHandler func(string) - SaveRulesHandler func(rules.RulesFile) -) - -//export WindowIsReady -func WindowIsReady() { - queueMutex.Lock() - windowReady = true - // Process any queued messages - for _, message := range messageQueue { - sendMessageToWebViewInternal(message) - } - messageQueue = nil - queueMutex.Unlock() -} - -func sendMessageToWebViewInternal(message string) { - cMessage := C.CString(message) - defer C.free(unsafe.Pointer(cMessage)) - C.SendMessageToWebView(cMessage) -} - -func SendMessageToWebView(messageType string, message interface{}) { - jsonMsg := struct { - Type string `json:"type"` - Message interface{} `json:"message"` - }{ - Type: messageType, - Message: message, - } - jsonBytes, err := json.Marshal(jsonMsg) - if err != nil { - slog.Error("Error marshaling message", "error", err) - return - } - - queueMutex.Lock() - defer queueMutex.Unlock() - - if windowReady { - sendMessageToWebViewInternal(string(jsonBytes)) - } else { - messageQueue = append(messageQueue, string(jsonBytes)) - } -} +var SaveRulesHandler func(rules.RulesFile) func init() { - // Load HTML content html, err := assets.GetHTML() if err != nil { slog.Error("Error loading HTML content", "error", err) return } - // Set HTML content cContent := C.CString(html) defer C.free(unsafe.Pointer(cContent)) C.SetHTMLContent(cContent) - // Get the filesystem and walk through all files in templates directory filesystem := assets.GetFileSystem() err = fs.WalkDir(filesystem, "templates", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - - // Skip directories and index.html (already handled by GetHTML) if d.IsDir() || filepath.Base(path) == "index.html" { return nil } - - // Get the file content content, err := assets.GetFile(filepath.Base(path)) if err != nil { slog.Error("Error loading file", "path", path, "error", err) return nil } - cPath := C.CString(filepath.Base(path)) cContent := C.CString(string(content)) defer C.free(unsafe.Pointer(cPath)) defer C.free(unsafe.Pointer(cContent)) - // Detect content type contentType := http.DetectContentType(content) if strings.HasPrefix(contentType, "text/") || strings.HasPrefix(contentType, "application/javascript") { C.SetFileContent(cPath, cContent) } else { - // Handle binary files C.SetFileContentWithLength(cPath, cContent, C.size_t(len(content))) } return nil }) - if err != nil { slog.Error("Error walking templates directory", "error", err) } @@ -130,146 +64,13 @@ func init() { func ShowWindow() { C.ShowWindow() - SendBuildInfo() } func CloseWindow() { C.CloseWindow() } -func SendBuildInfo() { - commitHash, buildDate := version.GetBuildInfo() - buildInfo := fmt.Sprintf("(%s, built %s)", commitHash, buildDate) - SendMessageToWebView("buildInfo", buildInfo) -} - -//export HandleWebViewMessage -func HandleWebViewMessage(messagePtr *C.char) { - messageStr := C.GoString(messagePtr) - - var msg map[string]interface{} - if err := json.Unmarshal([]byte(messageStr), &msg); err != nil { - slog.Error("Failed to parse webview message", "error", err) - return - } - - messageType, ok := msg["type"].(string) - if !ok { - slog.Error("Message missing type field") - return - } - - slog.Debug("Received message from webview", "type", messageType) - - switch messageType { - case "testUrl": - handleTestUrl(msg) - case "getRules": - handleGetRules() - case "saveRules": - handleSaveRules(msg) - case "getInstalledBrowsers": - handleGetInstalledBrowsers() - case "getBrowserProfiles": - handleGetBrowserProfiles(msg) - default: - slog.Debug("Unknown message type", "type", messageType) - } -} - -func handleTestUrl(msg map[string]interface{}) { - url, ok := msg["url"].(string) - if !ok { - slog.Error("testUrl message missing url field") - return - } - - slog.Debug("Forwarding test URL request", "url", url) - - if TestUrlHandler != nil { - TestUrlHandler(url) - } else { - slog.Error("TestUrlHandler not set") - SendMessageToWebView("testUrlResult", map[string]interface{}{ - "error": "Test handler not initialized", - }) - } -} - -func handleGetRules() { - rf, err := rules.Load() - if err != nil { - slog.Error("Failed to load rules", "error", err) - SendMessageToWebView("rules", map[string]interface{}{ - "defaultBrowser": "", - "rules": []interface{}{}, - }) - return - } - - path, _ := rules.GetPath() - var rulesPath string - if _, statErr := os.Stat(path); statErr == nil { - rulesPath = path - } - - type rulesResponse struct { - rules.RulesFile - Path string `json:"path,omitempty"` - } - SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(rulesPath)}) -} - -func handleSaveRules(msg map[string]interface{}) { - payload, ok := msg["payload"] - if !ok { - slog.Error("saveRules message missing payload field") - return - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - slog.Error("Failed to marshal saveRules payload", "error", err) - return - } - - var rf rules.RulesFile - if err := json.Unmarshal(payloadBytes, &rf); err != nil { - slog.Error("Failed to parse saveRules payload", "error", err) - return - } - - if err := rules.Save(rf); err != nil { - slog.Error("Failed to save rules", "error", err) - SendMessageToWebView("saveRulesError", map[string]interface{}{"error": err.Error()}) - return - } - - slog.Debug("Rules saved", "rules", len(rf.Rules)) - - // Send the path back so the UI badge appears if the file was just created. - path, _ := rules.GetPath() - type rulesResponse struct { - rules.RulesFile - Path string `json:"path,omitempty"` - } - SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(path)}) - - if SaveRulesHandler != nil { - SaveRulesHandler(rf) - } -} - -func handleGetInstalledBrowsers() { - installed := browser.GetInstalledBrowsers() - SendMessageToWebView("installedBrowsers", installed) -} - -func handleGetBrowserProfiles(msg map[string]interface{}) { - browserName, _ := msg["browser"].(string) - profiles := browser.GetProfilesForBrowser(browserName) - SendMessageToWebView("browserProfiles", map[string]interface{}{ - "browser": browserName, - "profiles": profiles, - }) +//export GetAPIPort +func GetAPIPort() C.int { + return C.int(apiPort) } diff --git a/apps/finicky/src/window/window.h b/apps/finicky/src/window/window.h index dc7b90d..62e9e45 100644 --- a/apps/finicky/src/window/window.h +++ b/apps/finicky/src/window/window.h @@ -4,20 +4,18 @@ #import #import -@interface WindowController : NSObject +@interface WindowController : NSObject - (void)showWindow; - (void)closeWindow; -- (void)sendMessageToWebView:(NSString *)message; @end void ShowWindow(void); void CloseWindow(void); -void SendMessageToWebView(const char* message); void SetHTMLContent(const char* content); void SetFileContent(const char* path, const char* content); void SetFileContentWithLength(const char* path, const char* content, size_t length); extern void WindowDidClose(void); -extern void WindowIsReady(void); +extern int GetAPIPort(void); -#endif /* WINDOW_H */ \ No newline at end of file +#endif /* WINDOW_H */ diff --git a/apps/finicky/src/window/window.m b/apps/finicky/src/window/window.m index b407acc..c012820 100644 --- a/apps/finicky/src/window/window.m +++ b/apps/finicky/src/window/window.m @@ -17,10 +17,8 @@ void SetFileContent(const char* path, const char* content) { if (path && content) { NSString* pathStr = [NSString stringWithUTF8String:path]; if ([pathStr hasSuffix:@".png"]) { - // For PNG files, use SetFileContentWithLength instead SetFileContentWithLength(path, content, strlen(content)); } else { - // For text files, store as string NSString* contentStr = [NSString stringWithUTF8String:content]; fileContents[pathStr] = contentStr; } @@ -47,7 +45,6 @@ - (id)init { self = [super init]; NSLog(@"Initialize window controller"); if (self) { - // Always setup window on main thread if ([NSThread isMainThread]) { [self setupWindow]; [self setupMenu]; @@ -62,7 +59,6 @@ - (id)init { } - (void)setupWindow { - // Create window window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 860, 600) styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | @@ -74,34 +70,22 @@ - (void)setupWindow { [window center]; [window setReleasedWhenClosed:NO]; [window setBackgroundColor:[NSColor colorWithCalibratedWhite:0.1 alpha:1.0]]; - - // Set minimum window size [window setMinSize:NSMakeSize(800, 500)]; [window setMaxSize:NSMakeSize(1200, 900)]; - // Configure WKWebView WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; - [config.userContentController addScriptMessageHandler:self name:@"finicky"]; [config setURLSchemeHandler:self forURLScheme:@"finicky-assets"]; - // Inject a stub window.finicky at document start so the native side can - // safely call finicky.receiveMessage() before the Svelte app has mounted. - // Messages are buffered in _queue and drained by App.svelte at module scope. - NSString *stubScript = @"" - "window.finicky = {" - " _queue: []," - " receiveMessage: function(msg) { this._queue.push(msg); }," - " sendMessage: function(msg) {" - " window.webkit?.messageHandlers?.finicky?.postMessage(JSON.stringify(msg));" - " }" - "};"; - WKUserScript *stubUserScript = [[WKUserScript alloc] - initWithSource:stubScript + // Inject the API base URL so the UI can reach the local HTTP server. + extern int GetAPIPort(void); + int port = GetAPIPort(); + NSString *apiScript = [NSString stringWithFormat:@"window.__FINICKY_API__ = 'http://127.0.0.1:%d/api';", port]; + WKUserScript *apiUserScript = [[WKUserScript alloc] + initWithSource:apiScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; - [config.userContentController addUserScript:stubUserScript]; + [config.userContentController addUserScript:apiUserScript]; - // Create WKWebView webView = [[WKWebView alloc] initWithFrame:window.contentView.bounds configuration:config]; webView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; webView.navigationDelegate = self; @@ -110,7 +94,6 @@ - (void)setupWindow { [webView.configuration.preferences setValue:@true forKey:@"developerExtrasEnabled"]; - // Load HTML content if (htmlContent) { NSURL* baseURL = [NSURL URLWithString:@"finicky-assets://local/"]; [webView loadHTMLString:htmlContent baseURL:baseURL]; @@ -118,13 +101,11 @@ - (void)setupWindow { NSLog(@"Warning: HTML content not set"); } - // Add window close notification observer [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowWillClose:) name:NSWindowWillCloseNotification object:window]; - // Set webView as content view window.contentView = webView; } @@ -150,46 +131,6 @@ - (void)closeWindow { } } -- (void)sendMessageToWebView:(NSString *)message { - // The message is already JSON encoded from Go, pass it as a string literal, but escape the quotes and backslashes - NSString *escapedMessage = [[message stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] - stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""]; - NSString *js = [NSString stringWithFormat:@"finicky.receiveMessage(\"%@\")", escapedMessage]; - - if ([NSThread isMainThread]) { - if (webView && !webView.loading) { - [webView evaluateJavaScript:js completionHandler:nil]; - } - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - if (webView && !webView.loading) { - [webView evaluateJavaScript:js completionHandler:nil]; - } - }); - } -} - -- (void)userContentController:(WKUserContentController *)userContentController - didReceiveScriptMessage:(WKScriptMessage *)message { - // Handle messages from JavaScript here - NSLog(@"Received message from WebView: %@", message.body); - - // Convert the message body to JSON string and forward to Go - if ([message.body isKindOfClass:[NSString class]]) { - extern void HandleWebViewMessage(const char* message); - NSString *messageString = (NSString *)message.body; - HandleWebViewMessage([messageString UTF8String]); - } else if ([message.body isKindOfClass:[NSDictionary class]]) { - NSError *error; - NSData *jsonData = [NSJSONSerialization dataWithJSONObject:message.body options:0 error:&error]; - if (jsonData && !error) { - NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; - extern void HandleWebViewMessage(const char* message); - HandleWebViewMessage([jsonString UTF8String]); - } - } -} - #pragma mark - WKURLSchemeHandler - (void)webView:(WKWebView *)webView startURLSchemeTask:(id)urlSchemeTask { @@ -198,8 +139,6 @@ - (void)webView:(WKWebView *)webView startURLSchemeTask:(id)url if ([path hasPrefix:@"/"]) { path = [path substringFromIndex:1]; } - - // Remove 'local/' prefix if present if ([path hasPrefix:@"local/"]) { path = [path substringFromIndex:6]; } @@ -226,7 +165,6 @@ - (void)webView:(WKWebView *)webView startURLSchemeTask:(id)url MIMEType:mimeType expectedContentLength:data.length textEncodingName:nil]; - [urlSchemeTask didReceiveResponse:response]; [urlSchemeTask didReceiveData:data]; [urlSchemeTask didFinish]; @@ -239,39 +177,27 @@ - (void)webView:(WKWebView *)webView startURLSchemeTask:(id)url } - (void)webView:(WKWebView *)webView stopURLSchemeTask:(id)urlSchemeTask { - // Nothing to do here } #pragma mark - WKNavigationDelegate - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { - // Notify Go that the window is ready to receive messages - extern void WindowIsReady(void); - WindowIsReady(); } - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURL *url = navigationAction.request.URL; - - // Handle finicky-assets:// URLs internally if ([url.scheme isEqualToString:@"finicky-assets"]) { decisionHandler(WKNavigationActionPolicyAllow); return; } - - // If it's a regular link click (not a page load) if (navigationAction.navigationType == WKNavigationTypeLinkActivated) { - // Open the URL in the default browser [[NSWorkspace sharedWorkspace] openURL:url]; decisionHandler(WKNavigationActionPolicyCancel); return; } - - // Allow all other navigation decisionHandler(WKNavigationActionPolicyAllow); } -// Add new method to handle window close - (void)windowWillClose:(NSNotification *)notification { extern void WindowDidClose(void); WindowDidClose(); @@ -281,79 +207,38 @@ - (void)setupMenu { NSMenu *mainMenu = [[NSMenu alloc] init]; [NSApp setMainMenu:mainMenu]; - // Application menu NSMenuItem *appMenuItem = [[NSMenuItem alloc] init]; [mainMenu addItem:appMenuItem]; NSMenu *appMenu = [[NSMenu alloc] init]; [appMenuItem setSubmenu:appMenu]; - - // Quit menu item (⌘Q) NSMenuItem *quitMenuItem = [[NSMenuItem alloc] initWithTitle:@"Quit" action:@selector(terminate:) keyEquivalent:@"q"]; [quitMenuItem setTarget:NSApp]; [appMenu addItem:quitMenuItem]; - // File menu NSMenuItem *fileMenuItem = [[NSMenuItem alloc] init]; [mainMenu addItem:fileMenuItem]; NSMenu *fileMenu = [[NSMenu alloc] initWithTitle:@"File"]; [fileMenuItem setSubmenu:fileMenu]; - - // Close window menu item (⌘W) NSMenuItem *closeMenuItem = [[NSMenuItem alloc] initWithTitle:@"Close Window" action:@selector(performClose:) keyEquivalent:@"w"]; [closeMenuItem setTarget:window]; [fileMenu addItem:closeMenuItem]; - // Edit menu NSMenuItem *editMenuItem = [[NSMenuItem alloc] init]; [mainMenu addItem:editMenuItem]; NSMenu *editMenu = [[NSMenu alloc] initWithTitle:@"Edit"]; [editMenuItem setSubmenu:editMenu]; - - // Add Cut menu item (⌘X) - NSMenuItem *cutMenuItem = [[NSMenuItem alloc] initWithTitle:@"Cut" - action:@selector(cut:) - keyEquivalent:@"x"]; - [editMenu addItem:cutMenuItem]; - - // Add Copy menu item (⌘C) - NSMenuItem *copyMenuItem = [[NSMenuItem alloc] initWithTitle:@"Copy" - action:@selector(copy:) - keyEquivalent:@"c"]; - [editMenu addItem:copyMenuItem]; - - // Add Paste menu item (⌘V) - NSMenuItem *pasteMenuItem = [[NSMenuItem alloc] initWithTitle:@"Paste" - action:@selector(paste:) - keyEquivalent:@"v"]; - [editMenu addItem:pasteMenuItem]; - - // Add separator + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Cut" action:@selector(cut:) keyEquivalent:@"x"]]; + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Copy" action:@selector(copy:) keyEquivalent:@"c"]]; + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Paste" action:@selector(paste:) keyEquivalent:@"v"]]; [editMenu addItem:[NSMenuItem separatorItem]]; - - // Add Undo menu item (⌘Z) - NSMenuItem *undoMenuItem = [[NSMenuItem alloc] initWithTitle:@"Undo" - action:@selector(undo:) - keyEquivalent:@"z"]; - [editMenu addItem:undoMenuItem]; - - // Add Redo menu item (⌘⇧Z) - NSMenuItem *redoMenuItem = [[NSMenuItem alloc] initWithTitle:@"Redo" - action:@selector(redo:) - keyEquivalent:@"Z"]; - [editMenu addItem:redoMenuItem]; - - // Add separator + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Undo" action:@selector(undo:) keyEquivalent:@"z"]]; + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"Z"]]; [editMenu addItem:[NSMenuItem separatorItem]]; - - // Add Select All menu item (⌘A) - NSMenuItem *selectAllMenuItem = [[NSMenuItem alloc] initWithTitle:@"Select All" - action:@selector(selectAll:) - keyEquivalent:@"a"]; - [editMenu addItem:selectAllMenuItem]; + [editMenu addItem:[[NSMenuItem alloc] initWithTitle:@"Select All" action:@selector(selectAll:) keyEquivalent:@"a"]]; } @end @@ -370,10 +255,3 @@ void CloseWindow(void) { [windowController closeWindow]; } } - -void SendMessageToWebView(const char* message) { - if (windowController) { - NSString *nsMessage = [NSString stringWithUTF8String:message]; - [windowController sendMessageToWebView:nsMessage]; - } -} \ No newline at end of file diff --git a/packages/finicky-ui/index.html b/packages/finicky-ui/index.html index 5e43fde..9673483 100644 --- a/packages/finicky-ui/index.html +++ b/packages/finicky-ui/index.html @@ -7,6 +7,6 @@
- + diff --git a/packages/finicky-ui/package-lock.json b/packages/finicky-ui/package-lock.json index f2d07ae..b7d8ef4 100644 --- a/packages/finicky-ui/package-lock.json +++ b/packages/finicky-ui/package-lock.json @@ -8,17 +8,301 @@ "name": "finicky-ui", "version": "0.0.0", "dependencies": { - "svelte-routing": "^2.13.0" + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@tsconfig/svelte": "^5.0.4", - "svelte": "^5.20.2", - "svelte-check": "^4.1.4", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.5.0", "typescript": "~5.7.2", "vite": "^6.3.6" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -511,6 +795,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -861,63 +1161,50 @@ "win32" ] }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" + "@babel/types": "^7.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" } }, - "node_modules/@tsconfig/svelte": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", - "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } }, "node_modules/@types/estree": { "version": "1.0.8", @@ -926,72 +1213,138 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "dev": true, "license": "MIT" }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" } }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, "engines": { - "node": ">= 0.4" + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, "engines": { - "node": ">= 0.4" + "node": ">=6.0.0" } }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "peer": true, "dependencies": { - "readdirp": "^4.0.1" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, - "engines": { - "node": ">= 14.16.0" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -1011,22 +1364,12 @@ } } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/devalue": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", - "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/esbuild": { "version": "0.25.12", @@ -1070,29 +1413,14 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", - "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } + "engines": { + "node": ">=6" } }, "node_modules/fdir": { @@ -1128,51 +1456,68 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { "node": ">=6" } }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, - "license": "MIT" + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" } }, "node_modules/ms": { @@ -1201,6 +1546,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1251,18 +1606,73 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/rollup": { @@ -1310,17 +1720,23 @@ "fsevents": "~2.3.2" } }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/source-map-js": { @@ -1333,65 +1749,6 @@ "node": ">=0.10.0" } }, - "node_modules/svelte": { - "version": "5.55.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", - "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.4", - "esm-env": "^1.2.1", - "esrap": "^2.2.4", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/svelte-check": { - "version": "4.4.6", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", - "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^4.0.1", - "fdir": "^6.2.0", - "picocolors": "^1.0.0", - "sade": "^1.7.4" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" - } - }, - "node_modules/svelte-routing": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/svelte-routing/-/svelte-routing-2.13.0.tgz", - "integrity": "sha512-/NTxqTwLc7Dq306hARJrH2HLXOBtKd7hu8nxgoFDlK0AC4SOKnzisiX/9m8Uksei1QAWtlAEdF91YphNM8iDMg==", - "license": "MIT" - }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1424,6 +1781,37 @@ "node": ">=14.17" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1500,32 +1888,12 @@ } } }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "MIT" + "license": "ISC" } } } diff --git a/packages/finicky-ui/package.json b/packages/finicky-ui/package.json index c79e166..c221c1e 100644 --- a/packages/finicky-ui/package.json +++ b/packages/finicky-ui/package.json @@ -5,20 +5,21 @@ "type": "module", "scripts": { "dev": "vite", + "dev:api": "VITE_API_URL=http://127.0.0.1:${PORT}/api vite", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + "check": "tsc -b" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.3", - "@tsconfig/svelte": "^5.0.4", - "svelte": "^5.20.2", - "svelte-check": "^4.1.4", + "@vitejs/plugin-react": "^4.5.0", + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.5", "typescript": "~5.7.2", "vite": "^6.3.6" }, "dependencies": { - "svelte-routing": "^2.13.0" - }, - "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.0" + } } diff --git a/packages/finicky-ui/src/App.module.css b/packages/finicky-ui/src/App.module.css new file mode 100644 index 0000000..c44e48d --- /dev/null +++ b/packages/finicky-ui/src/App.module.css @@ -0,0 +1,27 @@ +.main { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; +} + +.layout { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.container { + padding: 1.25rem 1.25rem; + max-width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + flex: 1 1 100%; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.content { + flex: 1; +} diff --git a/packages/finicky-ui/src/App.svelte b/packages/finicky-ui/src/App.svelte deleted file mode 100644 index 9578c49..0000000 --- a/packages/finicky-ui/src/App.svelte +++ /dev/null @@ -1,287 +0,0 @@ - - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - -
-
-
- -
-
- - - - diff --git a/packages/finicky-ui/src/App.tsx b/packages/finicky-ui/src/App.tsx new file mode 100644 index 0000000..e8281bb --- /dev/null +++ b/packages/finicky-ui/src/App.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { TabBar } from "./components/TabBar"; +import { Footer } from "./components/Footer"; +import { ToastContainer } from "./components/ToastContainer"; +import { StartPage } from "./pages/StartPage"; +import { LogViewer } from "./pages/LogViewer"; +import { TestUrl } from "./pages/TestUrl"; +import { About } from "./pages/About"; +import { Rules } from "./pages/Rules"; +import { PreferencesIcon } from "./components/icons/Preferences"; +import { RulesIcon } from "./components/icons/Rules"; +import { TestIcon } from "./components/icons/Test"; +import { LogsIcon } from "./components/icons/Logs"; +import { AboutIcon } from "./components/icons/About"; +import { api } from "./lib/api"; +import { appStore } from "./lib/appStore"; +import { useSSE } from "./lib/useSSE"; +import type { TabDef, BottomTabDef } from "./components/TabBar"; +import styles from "./App.module.css"; + +const TABS: TabDef[] = [ + { path: "/", label: "Preferences", Icon: PreferencesIcon }, + { path: "/rules", label: "Rules", Icon: RulesIcon }, + { path: "/test", label: "Test", Icon: TestIcon }, +]; + +const BOTTOM_TABS: BottomTabDef[] = [ + { path: "/troubleshoot", label: "Logs", Icon: LogsIcon, showErrors: true }, + { path: "/about", label: "About", Icon: AboutIcon }, +]; + +export default function App() { + useSSE(); + + useEffect(() => { + async function load() { + try { + const data = await api.initialData(); + appStore.update({ + version: (data.version as string) ?? "v0.0.0", + installedBrowsers: (data.installedBrowsers as string[]) ?? [], + rulesFile: (data.rules as any) ?? { defaultBrowser: "", rules: [] }, + ...(data.config ? { hasConfig: true, config: data.config as any } : {}), + ...(data.updateInfo ? { updateInfo: data.updateInfo as any } : {}), + }); + } catch {} + } + load(); + }, []); + + return ( + +
+
+ +
+
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+
+ +
+ ); +} diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.module.css b/packages/finicky-ui/src/components/BrowserProfileSelector.module.css new file mode 100644 index 0000000..dc95d42 --- /dev/null +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.module.css @@ -0,0 +1,102 @@ +.browserSelectRow { + display: flex; + gap: 8px; + align-items: center; +} + +.selectWrapper { + position: relative; + flex: 1; + min-width: 0; +} + +.selectWrapper::after { + content: ''; + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-60%) rotate(45deg); + width: 5px; + height: 5px; + border-right: 2px solid var(--text-secondary); + border-bottom: 2px solid var(--text-secondary); + pointer-events: none; +} + +.selectWrapper:focus-within::after { + border-color: var(--accent-color); +} + +.browserDropdown { + width: 100%; + padding: 7px 32px 7px 12px; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.9em; + font-family: inherit; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.browserDropdown:focus { + outline: none; + border-color: var(--accent-color); +} + +.selectWrapper.empty .browserDropdown { + border-color: var(--log-warning); +} + +.selectWrapper.empty::after { + border-color: var(--log-warning); +} + +.browserDropdown:disabled { + opacity: 0.6; + cursor: default; +} + +.selectWrapper:has(.browserDropdown:disabled)::after { + opacity: 0.6; +} + +.browserInput { + flex: 1; + min-width: 0; + padding: 8px 12px; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.9em; + font-family: inherit; +} + +.browserInput:focus { + outline: none; + border-color: var(--accent-color); +} + +.browserInput:disabled { + opacity: 0.6; + cursor: default; +} + +.clearBtn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 3px; + border-radius: 4px; + flex-shrink: 0; + display: flex; + transition: color 0.15s; +} + +.clearBtn:hover { + color: var(--log-error); +} diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.svelte b/packages/finicky-ui/src/components/BrowserProfileSelector.svelte deleted file mode 100644 index 61f158d..0000000 --- a/packages/finicky-ui/src/components/BrowserProfileSelector.svelte +++ /dev/null @@ -1,248 +0,0 @@ - - -
- {#if isCustom} - onSave?.()} - {disabled} - /> - - {:else} -
- -
- {/if} - - {#if !isCustom && browser && profileOptions(browser).length > 0} - {#if isProfileCustom} - onSave?.()} - {disabled} - /> - - {:else} -
- -
- {/if} - {/if} -
- - diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.tsx b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx new file mode 100644 index 0000000..04cf59d --- /dev/null +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx @@ -0,0 +1,158 @@ +import { XIcon } from "./icons/X"; +import type { BrowserProfile, BrowserOptions, BrowserProfileCustom } from "../types"; +import styles from "./BrowserProfileSelector.module.css"; + +const CUSTOM = "__custom__"; + +interface Props { + value: BrowserProfile; + custom: BrowserProfileCustom; + browsers: BrowserOptions; + disabled?: boolean; + required?: boolean; + onChange?: (value: BrowserProfile, custom: BrowserProfileCustom, committed: boolean) => void; + onRequestProfiles?: (b: string) => void; +} + +interface BrowserFieldProps { + browser: string; + isCustom: boolean; + required?: boolean; + disabled?: boolean; + placeholder: string; + installedBrowsers: string[]; + onChange: (val: string) => void; + onInput: (val: string) => void; + onClear: () => void; + onBlur: () => void; +} + +function BrowserField({ browser, isCustom, required, disabled, placeholder, installedBrowsers, onChange, onInput, onClear, onBlur }: BrowserFieldProps) { + if (isCustom) { + return ( + <> + onInput(e.target.value)} + onBlur={onBlur} + disabled={disabled} + /> + + + ); + } + return ( +
+ +
+ ); +} + +interface ProfileFieldProps { + profile: string; + isProfileCustom: boolean; + disabled?: boolean; + profileOptions: string[]; + onChange: (val: string) => void; + onInput: (val: string) => void; + onClear: () => void; + onBlur: () => void; +} + +function ProfileField({ profile, isProfileCustom, disabled, profileOptions, onChange, onInput, onClear, onBlur }: ProfileFieldProps) { + if (isProfileCustom) { + return ( + <> + onInput(e.target.value)} + onBlur={onBlur} + disabled={disabled} + /> + + + ); + } + return ( +
+ +
+ ); +} + +export function BrowserProfileSelector({ + value, + custom, + browsers, + disabled, + required, + onChange = () => {}, + onRequestProfiles = () => {}, +}: Props) { + const { browser, profile } = value; + const profileOptions = browsers.profiles[browser] ?? []; + + function handleBrowserSelect(val: string) { + if (val === CUSTOM) { + onChange({ browser: "", profile: "" }, { browser: true, profile: false }, false); + } else { + if (val && browsers.profiles[val] === undefined) onRequestProfiles(val); + onChange({ browser: val, profile: "" }, { browser: false, profile: false }, true); + } + } + + function handleProfileSelect(val: string) { + if (val === CUSTOM) { + onChange({ browser, profile: "" }, { browser: custom.browser, profile: true }, false); + } else { + onChange({ browser, profile: val }, { browser: custom.browser, profile: false }, true); + } + } + + return ( +
+ onChange({ browser: val, profile }, { browser: true, profile: custom.profile }, false)} + onClear={() => onChange({ browser: "", profile: "" }, { browser: false, profile: false }, true)} + onBlur={() => onChange({ browser, profile }, custom, true)} + /> + {!custom.browser && browser && profileOptions.length > 0 && ( + onChange({ browser, profile: val }, { browser: custom.browser, profile: true }, false)} + onClear={() => onChange({ browser, profile: "" }, { browser: custom.browser, profile: false }, true)} + onBlur={() => onChange({ browser, profile }, custom, true)} + /> + )} +
+ ); +} diff --git a/packages/finicky-ui/src/components/Footer.module.css b/packages/finicky-ui/src/components/Footer.module.css new file mode 100644 index 0000000..6d79f18 --- /dev/null +++ b/packages/finicky-ui/src/components/Footer.module.css @@ -0,0 +1,73 @@ +.footer { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.75rem; + background: var(--bg-nav); + border-top: 1px solid var(--border-color); + overflow: hidden; +} + +.version { + color: var(--text-secondary); + font-size: 0.75em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} + +.spacer { + flex: 1; +} + +.upToDate { + color: var(--log-success); + font-size: 0.75em; +} + +.configLabel { + color: var(--text-secondary); + font-size: 0.75em; +} + +.configBadge { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.72em; + padding: 2px 8px; + border-radius: 3px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s ease, color 0.15s ease; +} + +.configBadge:hover { + background: var(--button-hover); + color: var(--text-primary); +} + +.configStatus { + font-size: 0.75em; + color: var(--text-secondary); +} + +.configStatus.warning { + color: var(--log-warning); +} + +.configLink { + color: var(--accent-color); + font-size: 0.8em; + text-decoration: none; + transition: opacity 0.2s ease; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.configLink:hover { + text-decoration: underline; +} diff --git a/packages/finicky-ui/src/components/Footer.tsx b/packages/finicky-ui/src/components/Footer.tsx new file mode 100644 index 0000000..a97708c --- /dev/null +++ b/packages/finicky-ui/src/components/Footer.tsx @@ -0,0 +1,64 @@ +import { useSyncExternalStore } from "react"; +import { ExternalIcon } from "./icons/External"; +import { toast } from "../lib/toast"; +import { appStore } from "../lib/appStore"; +import styles from "./Footer.module.css"; + +function basename(path: string): string { + return path.split("/").pop() || path; +} + +function showPathToast(label: string, description: string) { + toast.show(label, "info", description, 5000); +} + +export function Footer() { + const { version, hasConfig, config, rulesFile, updateInfo } = useSyncExternalStore( + appStore.subscribe, + appStore.getSnapshot + ); + + return ( +
+ {hasConfig || rulesFile.path ? ( + <> + Config loaded: + {hasConfig && ( + + )} + {rulesFile.path && rulesFile.path !== config.configPath && ( + + )} + + ) : ( + <> + No config + + Get started + + + + )} + + {updateInfo && updateInfo.updateCheckEnabled && !updateInfo.hasUpdate && ( + ✓ Up to date + )} + {version} +
+ ); +} diff --git a/packages/finicky-ui/src/components/LogContent.module.css b/packages/finicky-ui/src/components/LogContent.module.css new file mode 100644 index 0000000..73b5f71 --- /dev/null +++ b/packages/finicky-ui/src/components/LogContent.module.css @@ -0,0 +1,57 @@ +.logContent { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + line-height: 1.5; + color: var(--text-primary); + flex: 1; + overflow: auto; +} + +.logEntry { + display: flex; + justify-content: flex-start; + gap: 1em; + margin-bottom: 8px; + align-items: flex-start; +} + +.logTime { + color: var(--text-secondary); + white-space: nowrap; + font-size: 0.85em; + opacity: 0.8; +} + +.logMessage { + flex-grow: 1; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.95em; +} + +.logMessage a { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; + opacity: 0.9; +} + +.logMessage a:hover { + text-decoration-style: solid; + opacity: 1; +} + +.logLevelError { + color: var(--log-error); +} + +.logLevelWarn { + color: var(--log-warning); +} + +.logLevelDebug { + color: var(--log-debug); +} diff --git a/packages/finicky-ui/src/components/LogContent.svelte b/packages/finicky-ui/src/components/LogContent.svelte deleted file mode 100644 index 421e5f6..0000000 --- a/packages/finicky-ui/src/components/LogContent.svelte +++ /dev/null @@ -1,93 +0,0 @@ - - -
    - {#each messageBuffer as entry} - {#if showDebug || entry.level.toLowerCase() !== "debug"} -
  1. - - {new Date(entry.time).toLocaleTimeString()} - -
    - {#each formatLogEntry(entry) as part} - {#if part.type === "url"} - {part.content} - {:else} - {part.content} - {/if} - {/each} -
    -
  2. - {/if} - {/each} -
- - diff --git a/packages/finicky-ui/src/components/LogContent.tsx b/packages/finicky-ui/src/components/LogContent.tsx new file mode 100644 index 0000000..7fa3dda --- /dev/null +++ b/packages/finicky-ui/src/components/LogContent.tsx @@ -0,0 +1,41 @@ +import type { LogEntry } from "../types"; +import { formatLogEntry } from "../utils/text"; +import styles from "./LogContent.module.css"; + +interface Props { + messageBuffer: LogEntry[]; + showDebug: boolean; +} + +const levelClassMap: Record = { + error: styles.logLevelError, + warn: styles.logLevelWarn, + debug: styles.logLevelDebug, +}; + +export function LogContent({ messageBuffer, showDebug }: Props) { + return ( +
    + {messageBuffer + .filter((entry) => showDebug || entry.level.toLowerCase() !== "debug") + .map((entry, i) => ( +
  1. + + {new Date(entry.time).toLocaleTimeString()} + +
    + {formatLogEntry(entry).map((part, j) => + part.type === "url" ? ( + + {part.content} + + ) : ( + {part.content} + ) + )} +
    +
  2. + ))} +
+ ); +} diff --git a/packages/finicky-ui/src/components/OptionRow.module.css b/packages/finicky-ui/src/components/OptionRow.module.css new file mode 100644 index 0000000..0c0a47b --- /dev/null +++ b/packages/finicky-ui/src/components/OptionRow.module.css @@ -0,0 +1,127 @@ +.optionRow { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background: var(--inset-bg); + border-radius: 8px; + transition: background 0.2s ease; + width: 100%; + text-align: left; + border: none; + font: inherit; + color: inherit; +} + +.optionRow:hover { + background: var(--bg-hover); +} + +.optionRow.locked { + cursor: default; +} + +.optionInfo { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.optionText { + display: flex; + flex-direction: column; + gap: 2px; +} + +.optionLabel { + color: var(--text-primary); + font-size: 0.95em; + font-weight: 500; +} + +.optionHint { + color: var(--text-secondary); + font-size: 0.85em; + opacity: 0.7; +} + +.toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + cursor: pointer; + flex-shrink: 0; +} + +.toggle.locked { + cursor: default; +} + +.toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.toggleSlider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #666; + transition: 0.3s; + border-radius: 24px; +} + +.toggleSlider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: #ddd; + transition: 0.3s; + border-radius: 50%; +} + +.toggle input:checked + .toggleSlider { + background-color: var(--toggle-active); +} + +.toggle input:checked + .toggleSlider:before { + transform: translateX(20px); + background-color: #111111; +} + +.nubLock { + position: absolute; + left: 3px; + bottom: 3px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + transition: transform 0.3s; +} + +.toggle.locked input:checked ~ .nubLock { + transform: translateX(20px); +} + +.nubLock svg { + width: 10px; + height: 10px; + color: #444; + stroke-width: 2.5; +} + +.toggle.locked input:checked ~ .nubLock svg { + color: #aaa; +} diff --git a/packages/finicky-ui/src/components/OptionRow.svelte b/packages/finicky-ui/src/components/OptionRow.svelte deleted file mode 100644 index 6db94e2..0000000 --- a/packages/finicky-ui/src/components/OptionRow.svelte +++ /dev/null @@ -1,178 +0,0 @@ - - -{#snippet inner()} -
-
- {label} - {hint} -
- -
-{/snippet} - -{#if locked} - - - -{:else} -
- {@render inner()} -
-{/if} - - diff --git a/packages/finicky-ui/src/components/OptionRow.tsx b/packages/finicky-ui/src/components/OptionRow.tsx new file mode 100644 index 0000000..e4d80e7 --- /dev/null +++ b/packages/finicky-ui/src/components/OptionRow.tsx @@ -0,0 +1,59 @@ +import { LockIcon } from "./icons/Lock"; +import { Tooltip } from "./Tooltip"; +import styles from "./OptionRow.module.css"; + +const LOCKED_TOOLTIP = "JavaScript configuration file loaded — these settings can't be changed here"; + +interface Props { + label: string; + hint: string; + checked: boolean; + locked?: boolean; + onLockedClick?: () => void; + onChange?: (checked: boolean) => void; +} + +function Inner({ label, hint, checked, locked, onChange }: Props) { + return ( +
+
+ {label} + {hint} +
+ +
+ ); +} + +export function OptionRow(props: Props) { + const { locked, onLockedClick } = props; + + if (locked) { + return ( + + + + ); + } + + return ( +
+ +
+ ); +} diff --git a/packages/finicky-ui/src/components/PageContainer.module.css b/packages/finicky-ui/src/components/PageContainer.module.css new file mode 100644 index 0000000..89ab7f5 --- /dev/null +++ b/packages/finicky-ui/src/components/PageContainer.module.css @@ -0,0 +1,40 @@ +.pageContainer { + max-width: 680px; + padding: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.pageContainer.center { + align-items: center; +} + +.headerSection { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 4px; +} + +.headerSection h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.1em; + font-weight: 600; + letter-spacing: -0.01em; +} + +.pageDescription { + color: var(--text-secondary); + margin: 0; + font-size: 0.85em; +} + +.pageDescription code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + background: var(--inset-bg); + padding: 1px 4px; + border-radius: 3px; +} diff --git a/packages/finicky-ui/src/components/PageContainer.svelte b/packages/finicky-ui/src/components/PageContainer.svelte deleted file mode 100644 index 2d7c763..0000000 --- a/packages/finicky-ui/src/components/PageContainer.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
- {#if title} -
-

{title}

- {#if description} -

{@render description()}

- {/if} -
- {/if} - {@render children()} -
- - diff --git a/packages/finicky-ui/src/components/PageContainer.tsx b/packages/finicky-ui/src/components/PageContainer.tsx new file mode 100644 index 0000000..5056347 --- /dev/null +++ b/packages/finicky-ui/src/components/PageContainer.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import styles from "./PageContainer.module.css"; + +interface Props { + children: ReactNode; + center?: boolean; + title?: string; + description?: ReactNode; +} + +export function PageContainer({ children, center, title, description }: Props) { + return ( +
+ {title && ( +
+

{title}

+ {description &&

{description}

} +
+ )} + {children} +
+ ); +} diff --git a/packages/finicky-ui/src/components/TabBar.module.css b/packages/finicky-ui/src/components/TabBar.module.css new file mode 100644 index 0000000..9b1a265 --- /dev/null +++ b/packages/finicky-ui/src/components/TabBar.module.css @@ -0,0 +1,104 @@ +.tabContainer { + width: 210px; + background: var(--bg-nav); + position: sticky; + top: 0; + z-index: 10; + padding: 0.75rem 0.625rem; + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + border-right: 1px solid var(--border-color); + height: 100%; + box-sizing: border-box; +} + +.tabBar { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + background: transparent; + padding: 0; + margin: 0; +} + +.tabBar a { + text-decoration: none; +} + +.spacer { + flex: 1; +} + +.bottomTabs { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 6px; + border-top: 1px solid var(--border-color); +} + +.tabContent { + user-select: none; + padding: 0.6rem 0.875rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + flex-direction: row; + gap: 10px; + color: var(--nav-text); + background: transparent; + transition: background 0.1s ease; + width: 100%; + text-align: left; + font-size: 0.925em; + font-weight: 400; +} + +.tabContent:hover { + background: var(--nav-hover); +} + +.tabContent.active { + background: var(--nav-active); + color: var(--nav-active-text); + font-weight: 500; +} + +.tabSmall { + font-size: 0.78em; + padding: 0.35rem 0.75rem; + color: var(--nav-text-secondary); +} + +.tabSmall.active { + color: var(--nav-active-text); +} + +.iconContainer { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.errorBadge { + position: absolute; + top: -8px; + right: -8px; + background: var(--log-error); + color: white; + border-radius: 50%; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + padding: 0 4px; + border: 2px solid var(--bg-nav); +} diff --git a/packages/finicky-ui/src/components/TabBar.svelte b/packages/finicky-ui/src/components/TabBar.svelte deleted file mode 100644 index 0ccd4d7..0000000 --- a/packages/finicky-ui/src/components/TabBar.svelte +++ /dev/null @@ -1,179 +0,0 @@ - - -
- -
- - diff --git a/packages/finicky-ui/src/components/TabBar.tsx b/packages/finicky-ui/src/components/TabBar.tsx new file mode 100644 index 0000000..96479ca --- /dev/null +++ b/packages/finicky-ui/src/components/TabBar.tsx @@ -0,0 +1,61 @@ +import { useSyncExternalStore } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { appStore } from "../lib/appStore"; +import styles from "./TabBar.module.css"; + +export interface TabDef { + path: string; + label: string; + Icon: React.ComponentType; +} + +export interface BottomTabDef extends TabDef { + showErrors?: boolean; +} + +interface Props { + tabs: TabDef[]; + bottomTabs: BottomTabDef[]; +} + +export function TabBar({ tabs, bottomTabs }: Props) { + const numErrors = useSyncExternalStore(appStore.subscribe, appStore.getNumErrors); + const location = useLocation(); + + function isActive(path: string) { + return location.pathname === path; + } + + return ( +
+ +
+ ); +} diff --git a/packages/finicky-ui/src/components/ToastContainer.module.css b/packages/finicky-ui/src/components/ToastContainer.module.css new file mode 100644 index 0000000..24eb07b --- /dev/null +++ b/packages/finicky-ui/src/components/ToastContainer.module.css @@ -0,0 +1,96 @@ +.toastContainer { + position: fixed; + bottom: 60px; + right: 24px; + z-index: 9999; + display: flex; + flex-direction: column-reverse; + gap: 12px; + pointer-events: none; +} + +.toastItem { + display: flex; + flex-direction: column; + align-items: left; + gap: 12px; + padding: 12px 16px; + border-radius: 8px; + background: var(--log-bg); + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 250px; + max-width: 400px; + pointer-events: auto; + position: relative; + animation: toastSlideUp 200ms ease forwards; +} + +@keyframes toastSlideUp { + from { transform: translateY(50px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.toastContent { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.toastTimer { + width: 20px; + height: 20px; + flex-shrink: 0; + transform: rotate(-90deg); +} + +.timerBackground { + fill: none; + stroke: var(--border-color); + stroke-width: 2; + opacity: 0.3; +} + +.timerProgress { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-dasharray: 62.83; + stroke-dashoffset: 0; + animation: circleProgress linear forwards; + opacity: 0.6; +} + +@keyframes circleProgress { + to { stroke-dashoffset: 62.83; } +} + +.toastMessage { + flex: 1; + font-size: 0.9em; + line-height: 1.4; +} + +.toastExtra { + font-family: var(--font-mono, monospace); + background: rgba(30, 32, 36, 0.9); + color: #e0e0e0; + padding: 8px 10px; + border-radius: 6px; + font-size: 0.85em; + overflow-x: auto; + white-space: pre-wrap; +} + +.toastSuccess { color: var(--log-success); } +.toastSuccess .toastMessage { color: var(--log-success); } + +.toastError { color: var(--log-error); } +.toastError .toastMessage { color: var(--log-error); } + +.toastWarning { color: var(--log-warning); } +.toastWarning .toastMessage { color: var(--log-warning); } + +.toastInfo { color: var(--accent-color); } +.toastInfo .toastMessage { color: var(--accent-color); } diff --git a/packages/finicky-ui/src/components/ToastContainer.svelte b/packages/finicky-ui/src/components/ToastContainer.svelte deleted file mode 100644 index bf8c0f5..0000000 --- a/packages/finicky-ui/src/components/ToastContainer.svelte +++ /dev/null @@ -1,170 +0,0 @@ - - -
- {#each toasts as t (t.id)} - - {/each} -
- - diff --git a/packages/finicky-ui/src/components/ToastContainer.tsx b/packages/finicky-ui/src/components/ToastContainer.tsx new file mode 100644 index 0000000..372609f --- /dev/null +++ b/packages/finicky-ui/src/components/ToastContainer.tsx @@ -0,0 +1,39 @@ +import { useSyncExternalStore } from "react"; +import { toast } from "../lib/toast"; +import type { Toast } from "../lib/toast"; +import styles from "./ToastContainer.module.css"; + +const typeClass: Record = { + success: styles.toastSuccess, + error: styles.toastError, + warning: styles.toastWarning, + info: styles.toastInfo, +}; + +export function ToastContainer() { + const toasts = useSyncExternalStore(toast.subscribe, toast.getSnapshot); + + return ( +
+ {toasts.map((t) => ( +
+
+ + + + + {t.message} +
+ {t.extra &&
{t.extra}
} +
+ ))} +
+ ); +} diff --git a/packages/finicky-ui/src/components/Toggle.svelte b/packages/finicky-ui/src/components/Toggle.svelte deleted file mode 100644 index e69de29..0000000 diff --git a/packages/finicky-ui/src/components/Tooltip.module.css b/packages/finicky-ui/src/components/Tooltip.module.css new file mode 100644 index 0000000..520643d --- /dev/null +++ b/packages/finicky-ui/src/components/Tooltip.module.css @@ -0,0 +1,33 @@ +.tooltipAnchor { + position: relative; + display: inline-flex; +} + +.tooltipAnchor.block { + display: flex; +} + +.tooltipText { + display: none; + position: absolute; + bottom: calc(100% + 6px); + right: 0; + background: var(--card-bg, #222); + color: var(--text-primary); + border: 1px solid var(--border-color); + font-size: 0.78rem; + font-family: inherit; + white-space: normal; + min-width: 160px; + max-width: 200px; + line-height: 1.4; + padding: 5px 8px; + border-radius: 5px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + pointer-events: none; + z-index: 100; +} + +.tooltipAnchor:hover .tooltipText { + display: block; +} diff --git a/packages/finicky-ui/src/components/Tooltip.svelte b/packages/finicky-ui/src/components/Tooltip.svelte deleted file mode 100644 index 8050d6d..0000000 --- a/packages/finicky-ui/src/components/Tooltip.svelte +++ /dev/null @@ -1,54 +0,0 @@ - - - - {@render children()} - {text} - - - diff --git a/packages/finicky-ui/src/components/Tooltip.tsx b/packages/finicky-ui/src/components/Tooltip.tsx new file mode 100644 index 0000000..7961475 --- /dev/null +++ b/packages/finicky-ui/src/components/Tooltip.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import styles from "./Tooltip.module.css"; + +interface Props { + children: ReactNode; + text: string; + block?: boolean; +} + +export function Tooltip({ children, text, block }: Props) { + return ( + + {children} + {text} + + ); +} diff --git a/packages/finicky-ui/src/components/icons/About.svelte b/packages/finicky-ui/src/components/icons/About.svelte deleted file mode 100644 index 39fc706..0000000 --- a/packages/finicky-ui/src/components/icons/About.svelte +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/packages/finicky-ui/src/components/icons/About.tsx b/packages/finicky-ui/src/components/icons/About.tsx new file mode 100644 index 0000000..35e2391 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/About.tsx @@ -0,0 +1,9 @@ +export function AboutIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/External.svelte b/packages/finicky-ui/src/components/icons/External.svelte deleted file mode 100644 index f029166..0000000 --- a/packages/finicky-ui/src/components/icons/External.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/packages/finicky-ui/src/components/icons/External.tsx b/packages/finicky-ui/src/components/icons/External.tsx new file mode 100644 index 0000000..2e49db5 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/External.tsx @@ -0,0 +1,9 @@ +export function ExternalIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Info.svelte b/packages/finicky-ui/src/components/icons/Info.svelte deleted file mode 100644 index 51fa882..0000000 --- a/packages/finicky-ui/src/components/icons/Info.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/packages/finicky-ui/src/components/icons/Info.tsx b/packages/finicky-ui/src/components/icons/Info.tsx new file mode 100644 index 0000000..8fa4610 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Info.tsx @@ -0,0 +1,9 @@ +export function InfoIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Link.svelte b/packages/finicky-ui/src/components/icons/Link.svelte deleted file mode 100644 index 37eb36f..0000000 --- a/packages/finicky-ui/src/components/icons/Link.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/packages/finicky-ui/src/components/icons/Link.tsx b/packages/finicky-ui/src/components/icons/Link.tsx new file mode 100644 index 0000000..e144eff --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Link.tsx @@ -0,0 +1,8 @@ +export function LinkIcon() { + return ( + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Lock.svelte b/packages/finicky-ui/src/components/icons/Lock.svelte deleted file mode 100644 index 05b947b..0000000 --- a/packages/finicky-ui/src/components/icons/Lock.svelte +++ /dev/null @@ -1,15 +0,0 @@ - \ No newline at end of file diff --git a/packages/finicky-ui/src/components/icons/Lock.tsx b/packages/finicky-ui/src/components/icons/Lock.tsx new file mode 100644 index 0000000..51ba81d --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Lock.tsx @@ -0,0 +1,8 @@ +export function LockIcon() { + return ( + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Logs.svelte b/packages/finicky-ui/src/components/icons/Logs.svelte deleted file mode 100644 index edfbec7..0000000 --- a/packages/finicky-ui/src/components/icons/Logs.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/packages/finicky-ui/src/components/icons/Logs.tsx b/packages/finicky-ui/src/components/icons/Logs.tsx new file mode 100644 index 0000000..1f5fbfe --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Logs.tsx @@ -0,0 +1,10 @@ +export function LogsIcon() { + return ( + + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Preferences.svelte b/packages/finicky-ui/src/components/icons/Preferences.svelte deleted file mode 100644 index d515172..0000000 --- a/packages/finicky-ui/src/components/icons/Preferences.svelte +++ /dev/null @@ -1,15 +0,0 @@ - diff --git a/packages/finicky-ui/src/components/icons/Preferences.tsx b/packages/finicky-ui/src/components/icons/Preferences.tsx new file mode 100644 index 0000000..6ae2340 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Preferences.tsx @@ -0,0 +1,8 @@ +export function PreferencesIcon() { + return ( + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Rules.svelte b/packages/finicky-ui/src/components/icons/Rules.svelte deleted file mode 100644 index b24732d..0000000 --- a/packages/finicky-ui/src/components/icons/Rules.svelte +++ /dev/null @@ -1,13 +0,0 @@ - diff --git a/packages/finicky-ui/src/components/icons/Rules.tsx b/packages/finicky-ui/src/components/icons/Rules.tsx new file mode 100644 index 0000000..9a5e5b5 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Rules.tsx @@ -0,0 +1,9 @@ +export function RulesIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Spinner.svelte b/packages/finicky-ui/src/components/icons/Spinner.svelte deleted file mode 100644 index 8beb07c..0000000 --- a/packages/finicky-ui/src/components/icons/Spinner.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/packages/finicky-ui/src/components/icons/Spinner.tsx b/packages/finicky-ui/src/components/icons/Spinner.tsx new file mode 100644 index 0000000..4447b67 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Spinner.tsx @@ -0,0 +1,7 @@ +export function SpinnerIcon() { + return ( + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Test.svelte b/packages/finicky-ui/src/components/icons/Test.svelte deleted file mode 100644 index dafecf8..0000000 --- a/packages/finicky-ui/src/components/icons/Test.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/packages/finicky-ui/src/components/icons/Test.tsx b/packages/finicky-ui/src/components/icons/Test.tsx new file mode 100644 index 0000000..c0ab39e --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Test.tsx @@ -0,0 +1,9 @@ +export function TestIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/Warning.svelte b/packages/finicky-ui/src/components/icons/Warning.svelte deleted file mode 100644 index 1878edc..0000000 --- a/packages/finicky-ui/src/components/icons/Warning.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/packages/finicky-ui/src/components/icons/Warning.tsx b/packages/finicky-ui/src/components/icons/Warning.tsx new file mode 100644 index 0000000..a1f97d3 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Warning.tsx @@ -0,0 +1,9 @@ +export function WarningIcon() { + return ( + + + + + + ); +} diff --git a/packages/finicky-ui/src/components/icons/X.svelte b/packages/finicky-ui/src/components/icons/X.svelte deleted file mode 100644 index 0c493a1..0000000 --- a/packages/finicky-ui/src/components/icons/X.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - - diff --git a/packages/finicky-ui/src/components/icons/X.tsx b/packages/finicky-ui/src/components/icons/X.tsx new file mode 100644 index 0000000..8b5907e --- /dev/null +++ b/packages/finicky-ui/src/components/icons/X.tsx @@ -0,0 +1,8 @@ +export function XIcon() { + return ( + + + + + ); +} diff --git a/packages/finicky-ui/src/lib/api.ts b/packages/finicky-ui/src/lib/api.ts new file mode 100644 index 0000000..c5870ff --- /dev/null +++ b/packages/finicky-ui/src/lib/api.ts @@ -0,0 +1,45 @@ +const BASE_URL: string = (() => { + const port = new URLSearchParams(window.location.search).get("apiPort"); + if (port) { + const url = `http://127.0.0.1:${port}/api`; + localStorage.setItem("devApiUrl", url); + return url; + } + return ( + (window as any).__FINICKY_API__ ?? + import.meta.env.VITE_API_URL ?? + localStorage.getItem("devApiUrl") ?? + "http://127.0.0.1:0/api" + ); +})(); + +function base(): string { + return BASE_URL; +} + +async function get(path: string): Promise { + const res = await fetch(`${base()}${path}`); + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return res.json(); +} + +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${base()}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export const api = { + initialData: () => get>("/initial-data"), + getRules: () => get("/rules"), + saveRules: (payload: unknown) => post("/rules", payload), + getBrowsers: () => get("/browsers"), + getBrowserProfiles: (browser: string) => + get(`/browser-profiles?browser=${encodeURIComponent(browser)}`), + testUrl: (url: string) => post("/test-url", { url }), + eventsUrl: () => `${base()}/events`, +}; diff --git a/packages/finicky-ui/src/lib/appStore.ts b/packages/finicky-ui/src/lib/appStore.ts new file mode 100644 index 0000000..cca9e7d --- /dev/null +++ b/packages/finicky-ui/src/lib/appStore.ts @@ -0,0 +1,62 @@ +import type { ConfigInfo, LogEntry, RulesFile, UpdateInfo } from "../types"; + +interface AppState { + version: string; + hasConfig: boolean; + config: ConfigInfo; + updateInfo: UpdateInfo | null; + rulesFile: RulesFile; + installedBrowsers: string[]; + profilesByBrowser: Record; + messageBuffer: LogEntry[]; +} + +const initialState: AppState = { + version: "v0.0.0", + hasConfig: false, + config: { configPath: "" }, + updateInfo: null, + rulesFile: { defaultBrowser: "", rules: [] }, + installedBrowsers: [], + profilesByBrowser: {}, + messageBuffer: [], +}; + +function createAppStore() { + let state = initialState; + let listeners: (() => void)[] = []; + + function notify() { + for (const l of listeners) l(); + } + + return { + getSnapshot: () => state, + getNumErrors: () => + state.messageBuffer.filter((m) => m.level.toLowerCase() === "error").length, + subscribe(listener: () => void) { + listeners.push(listener); + return () => { + listeners = listeners.filter((l) => l !== listener); + }; + }, + update(patch: Partial) { + state = { ...state, ...patch }; + notify(); + }, + addBrowserProfiles(browser: string, profiles: string[]) { + state = { ...state, profilesByBrowser: { ...state.profilesByBrowser, [browser]: profiles } }; + notify(); + }, + appendMessage(entry: LogEntry) { + state = { ...state, messageBuffer: [...state.messageBuffer, entry] }; + notify(); + }, + clearMessages() { + state = { ...state, messageBuffer: [] }; + notify(); + }, + }; +} + +export const appStore = createAppStore(); diff --git a/packages/finicky-ui/src/lib/testUrlStore.ts b/packages/finicky-ui/src/lib/testUrlStore.ts deleted file mode 100644 index c7adfac..0000000 --- a/packages/finicky-ui/src/lib/testUrlStore.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { writable } from 'svelte/store'; - -export interface TestUrlResult { - browser: string; - url: string; - openInBackground: boolean; - profile?: string; -} - -export const testUrlResult = writable(null); -export const testUrlInput = writable(''); diff --git a/packages/finicky-ui/src/lib/toast.ts b/packages/finicky-ui/src/lib/toast.ts index 2e43627..7557053 100644 --- a/packages/finicky-ui/src/lib/toast.ts +++ b/packages/finicky-ui/src/lib/toast.ts @@ -1,5 +1,3 @@ -import { writable } from "svelte/store"; - export type ToastType = "success" | "error" | "info" | "warning"; export interface Toast { @@ -9,94 +7,91 @@ export interface Toast { type: ToastType; duration?: number; timeoutId?: number; - key?: number; // Used to force animation restart + key?: number; } const MAX_TOASTS = 3; const DEFAULT_DURATION = 3000; -function createToastStore() { - const { subscribe, update } = writable([]); - - function show(message: string, type: ToastType = "info", extra?: string, duration = DEFAULT_DURATION) { - let id: string; - let timeoutId: number; - - update((toasts) => { - // Check if toast with same message and type already exists - const existing = toasts.find((t) => t.message === message && t.type === type); - - if (existing) { - // Clear existing timeout - if (existing.timeoutId) { - clearTimeout(existing.timeoutId); - } - id = existing.id; - - // Reset timeout - timeoutId = setTimeout(() => { - remove(id); - }, duration) as unknown as number; - - // Update the existing toast with new timeout and key to force animation restart - return toasts.map((t) => - t.id === existing.id ? { ...t, duration, timeoutId, key: Date.now() } : t - ); - } else { - // Create new toast - id = `${Date.now()}-${Math.random()}`; - - timeoutId = setTimeout(() => { - remove(id); - }, duration) as unknown as number; +type Listener = () => void; - const newToast: Toast = { id, message, extra, type, duration, timeoutId, key: Date.now() }; - const updatedToasts = [...toasts, newToast]; +function createToastStore() { + let toasts: Toast[] = []; + const listeners = new Set(); - // Limit max toasts (remove oldest) - if (updatedToasts.length > MAX_TOASTS) { - const removed = updatedToasts[0]; - if (removed.timeoutId) { - clearTimeout(removed.timeoutId); - } - return updatedToasts.slice(-MAX_TOASTS); - } + function getSnapshot(): Toast[] { + return toasts; + } - return updatedToasts; - } - }); + function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + } - return id!; + function notify() { + listeners.forEach((l) => l()); } function remove(id: string) { - update((toasts) => { - const toast = toasts.find((t) => t.id === id); - if (toast?.timeoutId) { - clearTimeout(toast.timeoutId); - } - return toasts.filter((t) => t.id !== id); - }); + const t = toasts.find((t) => t.id === id); + if (t?.timeoutId) clearTimeout(t.timeoutId); + toasts = toasts.filter((t) => t.id !== id); + notify(); + } + + function show( + message: string, + type: ToastType = "info", + extra?: string, + duration = DEFAULT_DURATION + ): string { + const existing = toasts.find((t) => t.message === message && t.type === type); + + if (existing) { + if (existing.timeoutId) clearTimeout(existing.timeoutId); + const timeoutId = setTimeout(() => remove(existing.id), duration) as unknown as number; + toasts = toasts.map((t) => + t.id === existing.id ? { ...t, duration, timeoutId, key: Date.now() } : t + ); + notify(); + return existing.id; + } + + const id = `${Date.now()}-${Math.random()}`; + const timeoutId = setTimeout(() => remove(id), duration) as unknown as number; + const newToast: Toast = { id, message, extra, type, duration, timeoutId, key: Date.now() }; + + let updated = [...toasts, newToast]; + if (updated.length > MAX_TOASTS) { + const oldest = updated[0]; + if (oldest.timeoutId) clearTimeout(oldest.timeoutId); + updated = updated.slice(-MAX_TOASTS); + } + toasts = updated; + notify(); + return id; } function clear() { - update((toasts) => { - toasts.forEach((t) => { - if (t.timeoutId) { - clearTimeout(t.timeoutId); - } - }); - return []; + toasts.forEach((t) => { + if (t.timeoutId) clearTimeout(t.timeoutId); }); + toasts = []; + notify(); } return { + getSnapshot, subscribe, show, - success: (message: string, duration?: number) => show(message, "success", duration), - error: (message: string, error: string, duration?: number) => show(message, "error", error, duration), - info: (message: string, duration?: number) => show(message, "info", duration), - warning: (message: string, duration?: number) => show(message, "warning", duration), + success: (message: string, duration?: number) => + show(message, "success", undefined, duration), + error: (message: string, extra?: string, duration?: number) => + show(message, "error", extra, duration), + info: (message: string, extra?: string, duration?: number) => + show(message, "info", extra, duration), + warning: (message: string, duration?: number) => + show(message, "warning", undefined, duration), remove, clear, }; diff --git a/packages/finicky-ui/src/lib/useDebouncedCallback.ts b/packages/finicky-ui/src/lib/useDebouncedCallback.ts new file mode 100644 index 0000000..1339439 --- /dev/null +++ b/packages/finicky-ui/src/lib/useDebouncedCallback.ts @@ -0,0 +1,20 @@ +import { useRef, useEffect } from "react"; + +export function useDebouncedCallback(fn: () => void | Promise, delay: number) { + const timer = useRef>(); + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => () => clearTimeout(timer.current), []); + + return { + schedule() { + clearTimeout(timer.current); + timer.current = setTimeout(() => fnRef.current(), delay); + }, + flush() { + clearTimeout(timer.current); + fnRef.current(); + }, + }; +} diff --git a/packages/finicky-ui/src/lib/useLogControls.ts b/packages/finicky-ui/src/lib/useLogControls.ts new file mode 100644 index 0000000..ad5e70b --- /dev/null +++ b/packages/finicky-ui/src/lib/useLogControls.ts @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { toast } from "./toast"; +import type { LogEntry } from "../types"; + +function formatEntry(entry: LogEntry): string { + const time = new Date(entry.time).toISOString(); + const base = `[${time}] [${entry.level.padEnd(5)}] ${entry.msg}`; + const extra = Object.entries(entry) + .filter(([key]) => !["level", "msg", "time", "error"].includes(key)) + .map(([key, value]) => `${key}: ${value}`) + .join(" | "); + const parts = [base]; + if (extra) parts.push(extra); + if (entry.error) parts.push(`Error: ${entry.error}`); + return parts.join(" | "); +} + +export function useLogControls(messageBuffer: LogEntry[]) { + const [showDebug, setShowDebug] = useState( + () => localStorage.getItem("showDebugLogs") === "true" + ); + + function handleShowDebugChange(e: React.ChangeEvent) { + const val = e.target.checked; + setShowDebug(val); + localStorage.setItem("showDebugLogs", String(val)); + } + + async function copyLogs() { + const text = messageBuffer + .filter((entry) => showDebug || entry.level.toLowerCase() !== "debug") + .map(formatEntry) + .join("\n"); + try { + await navigator.clipboard.writeText(text); + toast.success("Logs copied to clipboard"); + } catch (err) { + toast.error("Failed to copy logs", err instanceof Error ? err.message : String(err)); + } + } + + return { showDebug, handleShowDebugChange, copyLogs }; +} diff --git a/packages/finicky-ui/src/lib/useRulesSave.ts b/packages/finicky-ui/src/lib/useRulesSave.ts new file mode 100644 index 0000000..a6ec6e0 --- /dev/null +++ b/packages/finicky-ui/src/lib/useRulesSave.ts @@ -0,0 +1,30 @@ +import { useRef } from "react"; +import { api } from "./api"; +import { appStore } from "./appStore"; +import { useDebouncedCallback } from "./useDebouncedCallback"; +import type { Rule, RulesFile } from "../types"; + +export function useRulesSave(rulesFile: RulesFile, getRules: () => Rule[], delay: number) { + const isPending = useRef(false); + + const debounced = useDebouncedCallback(async () => { + isPending.current = false; + try { + const updated = await api.saveRules({ + defaultBrowser: rulesFile.defaultBrowser, + defaultProfile: rulesFile.defaultProfile, + rules: getRules(), + }); + appStore.update({ rulesFile: updated as any }); + } catch {} + }, delay); + + return { + isPending, + save() { debounced.flush(); }, + scheduleSave() { + isPending.current = true; + debounced.schedule(); + }, + }; +} diff --git a/packages/finicky-ui/src/lib/useSSE.ts b/packages/finicky-ui/src/lib/useSSE.ts new file mode 100644 index 0000000..4be05ad --- /dev/null +++ b/packages/finicky-ui/src/lib/useSSE.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { api } from "./api"; +import { appStore } from "./appStore"; +import type { LogEntry, ConfigInfo, UpdateInfo } from "../types"; + +export function useSSE() { + useEffect(() => { + const es = new EventSource(api.eventsUrl()); + + es.addEventListener("log", (e) => { + try { + appStore.appendMessage(JSON.parse(e.data) as LogEntry); + } catch {} + }); + + es.addEventListener("config", (e) => { + try { + const config = JSON.parse(e.data) as ConfigInfo; + appStore.update({ hasConfig: true, config }); + } catch {} + }); + + es.addEventListener("updateInfo", (e) => { + try { + appStore.update({ updateInfo: JSON.parse(e.data) as UpdateInfo }); + } catch {} + }); + + return () => es.close(); + }, []); +} diff --git a/packages/finicky-ui/src/main.ts b/packages/finicky-ui/src/main.ts deleted file mode 100644 index 4177d1d..0000000 --- a/packages/finicky-ui/src/main.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./reset.css"; -import "./app.css"; - -const app = mount(App, { - target: document.getElementById("app")!, -}); - -export default app; diff --git a/packages/finicky-ui/src/main.tsx b/packages/finicky-ui/src/main.tsx new file mode 100644 index 0000000..2eb828d --- /dev/null +++ b/packages/finicky-ui/src/main.tsx @@ -0,0 +1,6 @@ +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./reset.css"; +import "./app.css"; + +createRoot(document.getElementById("app")!).render(); diff --git a/packages/finicky-ui/src/pages/About.module.css b/packages/finicky-ui/src/pages/About.module.css new file mode 100644 index 0000000..aed3008 --- /dev/null +++ b/packages/finicky-ui/src/pages/About.module.css @@ -0,0 +1,73 @@ +.logoSection { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 0; +} + +.appInfo { + display: flex; + flex-direction: column; + gap: 3px; +} + +.appName { + font-size: 1.4em; + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; +} + +.version { + color: var(--text-secondary); + font-size: 0.85em; +} + +.section { + padding: 16px 20px; + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.section h2 { + font-size: 0.8em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin: 0 0 6px 0; +} + +.section p { + margin: 0; + line-height: 1.6; + color: var(--text-secondary); + font-size: 0.9em; +} + +.section a { + color: var(--accent-color); + text-decoration: none; +} + +.section a:hover { + opacity: 0.8; +} + +.repoLink { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent-color); + text-decoration: none; + font-size: 0.9em; + margin-top: 6px; +} + +.repoLink:hover { + opacity: 0.8; +} diff --git a/packages/finicky-ui/src/pages/About.svelte b/packages/finicky-ui/src/pages/About.svelte deleted file mode 100644 index 5149aea..0000000 --- a/packages/finicky-ui/src/pages/About.svelte +++ /dev/null @@ -1,145 +0,0 @@ - - - -
-
- Finicky - Version {version} -
-
- -
-

- Finicky is a macOS application that lets you set up rules to decide which - browser to open for every link. -

- - View on GitHub - -
- -
-

Credits

-

- Created by John Sterling -

-

- Icon designed by @uetchy -

-

- View all contributors -

-
- -
-

- If you find Finicky useful, consider sponsoring the project. -

-
-
- - diff --git a/packages/finicky-ui/src/pages/About.tsx b/packages/finicky-ui/src/pages/About.tsx new file mode 100644 index 0000000..57cffc7 --- /dev/null +++ b/packages/finicky-ui/src/pages/About.tsx @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; +import { PageContainer } from "../components/PageContainer"; +import { ExternalIcon } from "../components/icons/External"; +import { appStore } from "../lib/appStore"; +import styles from "./About.module.css"; + +export function About() { + const { version } = useSyncExternalStore(appStore.subscribe, appStore.getSnapshot); + return ( + +
+
+ Finicky + Version {version} +
+
+ +
+

+ Finicky is a macOS application that lets you set up rules to decide which browser to open + for every link. +

+ + View on GitHub + +
+ +
+

Credits

+

+ Created by{" "} + + John Sterling + +

+

+ Icon designed by{" "} + + @uetchy + +

+

+ + View all contributors + +

+
+ +
+

+ If you find Finicky useful, consider{" "} + + sponsoring the project + + . +

+
+
+ ); +} diff --git a/packages/finicky-ui/src/pages/LogViewer.module.css b/packages/finicky-ui/src/pages/LogViewer.module.css new file mode 100644 index 0000000..b61bc05 --- /dev/null +++ b/packages/finicky-ui/src/pages/LogViewer.module.css @@ -0,0 +1,36 @@ +.logControls { + display: flex; + align-items: center; + gap: 8px; +} + +.debugToggle { + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-size: 0.85em; + color: var(--text-secondary); + user-select: none; +} + +.debugToggle input[type="checkbox"] { + cursor: pointer; + accent-color: var(--accent-color); +} + +.logControls button { + padding: 3px 10px; + border: 1px solid var(--border-color); + border-radius: 5px; + background: var(--button-bg); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.82em; + font-family: inherit; + transition: color 0.15s; +} + +.logControls button:hover { + color: var(--text-primary); +} diff --git a/packages/finicky-ui/src/pages/LogViewer.svelte b/packages/finicky-ui/src/pages/LogViewer.svelte deleted file mode 100644 index 8221252..0000000 --- a/packages/finicky-ui/src/pages/LogViewer.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - - - {#snippet description()} -
- - - -
- {/snippet} - - -
- - diff --git a/packages/finicky-ui/src/pages/LogViewer.tsx b/packages/finicky-ui/src/pages/LogViewer.tsx new file mode 100644 index 0000000..406ca32 --- /dev/null +++ b/packages/finicky-ui/src/pages/LogViewer.tsx @@ -0,0 +1,28 @@ +import { useSyncExternalStore } from "react"; +import { PageContainer } from "../components/PageContainer"; +import { LogContent } from "../components/LogContent"; +import { appStore } from "../lib/appStore"; +import { useLogControls } from "../lib/useLogControls"; +import styles from "./LogViewer.module.css"; + +export function LogViewer() { + const { messageBuffer } = useSyncExternalStore(appStore.subscribe, appStore.getSnapshot); + const { showDebug, handleShowDebugChange, copyLogs } = useLogControls(messageBuffer); + + const controls = ( +
+ + + +
+ ); + + return ( + + + + ); +} diff --git a/packages/finicky-ui/src/pages/Rules.module.css b/packages/finicky-ui/src/pages/Rules.module.css new file mode 100644 index 0000000..a8b573c --- /dev/null +++ b/packages/finicky-ui/src/pages/Rules.module.css @@ -0,0 +1,213 @@ +.textInput { + padding: 8px 12px; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.9em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.textInput:focus { + outline: none; + border-color: var(--accent-color); +} + +.textInput::placeholder { + color: var(--text-secondary); + opacity: 0.4; +} + +.emptyRules { + color: var(--text-secondary); + font-size: 0.9em; + opacity: 0.6; + padding: 16px 0 4px; + text-align: center; +} + +.rulesList { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; +} + +.ruleRow { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + background: var(--card-bg); + border-radius: 0; + border: none; + border-bottom: 1px solid var(--border-color); + transition: background 0.15s; +} + +.ruleRow:last-child { + border-bottom: none; +} + +.ruleRow:hover { + background: var(--bg-hover); +} + +.ruleRow.noBrowser { + border-left: 3px solid var(--log-warning); + padding-left: 9px; +} + +.browserRequiredHint { + display: flex; + align-items: center; + gap: 4px; + color: var(--log-warning); + font-size: 0.75em; + white-space: nowrap; + flex-shrink: 0; +} + +.browserRequiredHint svg { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.ruleRow.dragging { + outline: 2px solid var(--accent-color); + outline-offset: -2px; + opacity: 0.7; +} + +.ruleTop { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.ruleBottom { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + padding-left: 22px; +} + +.dragHandle { + color: var(--text-secondary); + opacity: 0.4; + cursor: grab; + font-size: 1.1em; + user-select: none; + flex-shrink: 0; +} + +.patterns { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.patternRow { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.patternInputWrapper { + position: relative; + flex: 1; + min-width: 0; +} + +.patternInputWrapper.hasWarning .patternInput { + padding-right: 32px; +} + +.patternWarningIcon { + position: absolute; + right: 7px; + top: 50%; + transform: translateY(-50%); + display: flex; + color: var(--log-warning); + line-height: 1; + cursor: help; +} + + +.patternInput { + width: 100%; +} + +.removePatternBtn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 3px; + border-radius: 4px; + flex-shrink: 0; + display: flex; + transition: color 0.15s; +} + +.removePatternBtn:hover { + color: var(--log-error); +} + +.addPatternBtn { + align-self: flex-start; + background: none; + border: none; + color: var(--text-secondary); + font-size: 0.78em; + padding: 2px 4px; + cursor: pointer; + transition: color 0.15s; +} + +.addPatternBtn:hover { + color: var(--accent-color); +} + +.deleteBtn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; + flex-shrink: 0; + display: flex; + margin-left: auto; + transition: color 0.15s; +} + +.deleteBtn:hover { + color: var(--log-error); +} + +.addRuleBtn { + align-self: flex-start; + background: transparent; + border: 1px solid var(--accent-color); + border-radius: 8px; + color: var(--accent-color); + font-size: 0.88em; + padding: 8px 16px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.addRuleBtn:hover { + background: var(--button-hover); + color: var(--accent-color); +} diff --git a/packages/finicky-ui/src/pages/Rules.svelte b/packages/finicky-ui/src/pages/Rules.svelte deleted file mode 100644 index ccc97c5..0000000 --- a/packages/finicky-ui/src/pages/Rules.svelte +++ /dev/null @@ -1,535 +0,0 @@ - - - - {#snippet description()} - {#if isJSConfig} - The first matching rule wins. JS config is active — its handlers run first and take priority over these rules. - {:else} - The first matching rule wins. Use * as a wildcard, e.g. *example.com/*. - {/if} - {/snippet} - - {#if rules.length === 0} -
- No rules yet. Add one below. -
- {:else} -
- {#each rules as rule, i} -
onDragStart(i)} - ondragover={(e) => onDragOver(e, i)} - ondragend={onDragEnd} - role="listitem" - > -
- - { - rules[i] = { ...rules[i], browser, profile }; - rowIsCustom[i] = isCustom; - rowProfileIsCustom[i] = false; - }} - onProfileChange={(profile, isProfileCustom) => { - rules[i] = { ...rules[i], profile }; - rowProfileIsCustom[i] = isProfileCustom; - }} - onRequestProfiles={(b) => window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b })} - onSave={save} - onInput={scheduleSave} - /> - {#if !rule.browser && !rowIsCustom[i]} - - - Browser required - - {/if} - -
- -
-
- {#each rule.match as pattern, j} -
-
- onRowMatchInput(i, j, e)} - onblur={() => save()} - use:autofocusNew={{ rule: i, pattern: j }} - /> - {#if patternNeedsWildcard(pattern)} - - {/if} -
- {#if rule.match.length > 1} - - {/if} -
- {/each} - -
-
-
- {/each} -
- {/if} - - -
- - diff --git a/packages/finicky-ui/src/pages/Rules.tsx b/packages/finicky-ui/src/pages/Rules.tsx new file mode 100644 index 0000000..e4bc520 --- /dev/null +++ b/packages/finicky-ui/src/pages/Rules.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; +import { PageContainer } from "../components/PageContainer"; +import { BrowserProfileSelector } from "../components/BrowserProfileSelector"; +import { Tooltip } from "../components/Tooltip"; +import { WarningIcon } from "../components/icons/Warning"; +import { XIcon } from "../components/icons/X"; +import { appStore } from "../lib/appStore"; +import { api } from "../lib/api"; +import { useRulesSave } from "../lib/useRulesSave"; +import type { Rule, BrowserProfile, BrowserOptions, BrowserProfileCustom } from "../types"; +import styles from "./Rules.module.css"; + +const SAVE_DEBOUNCE = 3000; + +type RowState = BrowserProfileCustom; + +function patternNeedsWildcard(pattern: string): boolean { + return !/\*/.test(pattern.trim()) && pattern.trim().length > 0; +} + +function normalizeRules(rules: Rule[]): Rule[] { + return rules.map((r) => ({ + ...r, + match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""], + })); +} + +function profileIsCustom(rule: Rule, profiles: Record): boolean { + const profile = rule.profile ?? ""; + if (!profile) return false; + const options = profiles[rule.browser]; + return options !== undefined && !options.includes(profile); +} + +function computeRowStates(rules: Rule[], browsers: BrowserOptions): RowState[] { + return rules.map((r) => ({ + browser: r.browser !== "" && !browsers.installed.includes(r.browser), + profile: profileIsCustom(r, browsers.profiles), + })); +} + +async function fetchMissingProfiles(rules: Rule[], browsers: BrowserOptions) { + for (const r of rules) { + if (r.browser && browsers.profiles[r.browser] === undefined) { + try { + appStore.addBrowserProfiles(r.browser, await api.getBrowserProfiles(r.browser)); + } catch {} + } + } +} + +function useDragSort(onReorder: (from: number, to: number) => void, onDone: () => void) { + const [dragIndex, setDragIndex] = useState(null); + return { + dragIndex, + onDragStart: (i: number) => setDragIndex(i), + onDragOver: (e: React.DragEvent, i: number) => { + e.preventDefault(); + if (dragIndex === null || dragIndex === i) return; + onReorder(dragIndex, i); + setDragIndex(i); + }, + onDragEnd: () => { setDragIndex(null); onDone(); }, + }; +} + +function usePendingFocus() { + const ref = useRef<{ rule: number; pattern: number } | null>(null); + return { + set: (rule: number, pattern: number) => { ref.current = { rule, pattern }; }, + inputRef: (el: HTMLInputElement | null, ruleIdx: number, patternIdx: number) => { + if (el && ref.current?.rule === ruleIdx && ref.current?.pattern === patternIdx) { + el.focus(); + ref.current = null; + } + }, + }; +} + +interface RuleRowCallbacks { + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDragEnd: () => void; + onChange: (value: BrowserProfile, custom: BrowserProfileCustom, committed: boolean) => void; + onMatchInput: (j: number, e: React.ChangeEvent) => void; + onBlurSave: () => void; + onAddPattern: () => void; + onRemovePattern: (j: number) => void; + onRemoveRule: () => void; + onRequestProfiles: (b: string) => void; + inputRef: (el: HTMLInputElement | null, j: number) => void; +} + +interface RuleRowProps { + rule: Rule; + isDragging: boolean; + custom: BrowserProfileCustom; + browsers: BrowserOptions; + callbacks: RuleRowCallbacks; +} + +function RuleRow({ rule, isDragging, custom, browsers, callbacks: cb }: RuleRowProps) { + return ( +
+
+ + + {!rule.browser && !custom.browser && ( + + + Browser required + + )} + +
+ +
+
+ {rule.match.map((pattern, j) => ( +
+
+ cb.inputRef(el, j)} + className={`${styles.textInput} ${styles.patternInput}`} + type="text" + placeholder="*.example.com/*" + value={pattern} + onChange={(e) => cb.onMatchInput(j, e)} + onBlur={cb.onBlurSave} + /> + {patternNeedsWildcard(pattern) && ( + + + + + + )} +
+ {rule.match.length > 1 && ( + + )} +
+ ))} + +
+
+
+ ); +} + +export function Rules() { + const { rulesFile, installedBrowsers, profilesByBrowser, config } = useSyncExternalStore( + appStore.subscribe, + appStore.getSnapshot + ); + const hasJsConfig = config.hasJsConfig ?? false; + const browsers = { installed: installedBrowsers, profiles: profilesByBrowser }; + + const [rules, setRules] = useState(() => normalizeRules(rulesFile.rules)); + const [rowStates, setRowStates] = useState([]); + + const { save, scheduleSave, isPending } = useRulesSave(rulesFile, () => rules, SAVE_DEBOUNCE); + const drag = useDragSort( + (from, to) => { + const move = (arr: T[]) => { const a = [...arr]; a.splice(to, 0, ...a.splice(from, 1)); return a; }; + setRules(move); + setRowStates(move); + }, + scheduleSave + ); + const focus = usePendingFocus(); + + useEffect(() => { + if (isPending.current) return; + const newRules = normalizeRules(rulesFile.rules); + setRules(newRules); + setRowStates(computeRowStates(newRules, browsers)); + fetchMissingProfiles(newRules, browsers); + }, [rulesFile.rules, installedBrowsers]); // profilesByBrowser intentionally omitted + + useEffect(() => { + setRowStates((prev) => + prev.map((s, i) => ({ ...s, profile: profileIsCustom(rules[i], profilesByBrowser) })) + ); + }, [profilesByBrowser]); + + function onRowMatchInput(i: number, j: number, e: React.ChangeEvent) { + const newMatch = [...rules[i].match]; + newMatch[j] = e.target.value; + setRules(rules.map((r, idx) => (idx === i ? { ...r, match: newMatch } : r))); + scheduleSave(); + } + + function addPattern(i: number) { + focus.set(i, rules[i].match.length); + setRules(rules.map((r, idx) => (idx === i ? { ...r, match: [...r.match, ""] } : r))); + } + + function removePattern(i: number, j: number) { + const newMatch = rules[i].match.filter((_, idx) => idx !== j); + setRules(rules.map((r, idx) => (idx === i ? { ...r, match: newMatch.length > 0 ? newMatch : [""] } : r))); + save(); + } + + function addRule() { + focus.set(rules.length, 0); + setRules([...rules, { match: [""], browser: "", profile: "" }]); + setRowStates((prev) => [...prev, { browser: false, profile: false }]); + } + + function removeRule(i: number) { + setRules(rules.filter((_, idx) => idx !== i)); + setRowStates((prev) => prev.filter((_, idx) => idx !== i)); + save(); + } + + function handleChange(i: number, { browser, profile }: BrowserProfile, custom: BrowserProfileCustom) { + setRules(rules.map((r, idx) => (idx === i ? { ...r, browser, profile } : r))); + setRowStates((prev) => prev.map((s, idx) => (idx === i ? custom : s))); + } + + const description = hasJsConfig ? ( + <> + The first matching rule wins.{" "} + JavaScript configuration file loaded — its handlers run + first and take priority over these rules. + + ) : ( + <> + The first matching rule wins. Use * as a wildcard, e.g.{" "} + *example.com/*. + + ); + + return ( + + {rules.length === 0 ? ( +
No rules yet. Add one below.
+ ) : ( +
+ {rules.map((rule, i) => ( + drag.onDragStart(i), + onDragOver: (e) => drag.onDragOver(e, i), + onDragEnd: drag.onDragEnd, + onChange: (bp, custom, committed) => { + handleChange(i, bp, custom); + if (committed) save(); else scheduleSave(); + }, + onMatchInput: (j, e) => onRowMatchInput(i, j, e), + onBlurSave: save, + onAddPattern: () => addPattern(i), + onRemovePattern: (j) => removePattern(i, j), + onRemoveRule: () => removeRule(i), + onRequestProfiles: async (b) => { try { appStore.addBrowserProfiles(b, await api.getBrowserProfiles(b)); } catch {} }, + inputRef: (el, j) => focus.inputRef(el, i, j), + }} + /> + ))} +
+ )} + +
+ ); +} diff --git a/packages/finicky-ui/src/pages/StartPage.module.css b/packages/finicky-ui/src/pages/StartPage.module.css new file mode 100644 index 0000000..81e5c34 --- /dev/null +++ b/packages/finicky-ui/src/pages/StartPage.module.css @@ -0,0 +1,156 @@ +.section { + display: flex; + flex-direction: column; + gap: 10px; + background: var(--card-bg); + border-radius: 12px; + padding: 16px; + border: 1px solid var(--card-border); +} + +.section.readonly { + cursor: default; +} + +.sectionHeader { + display: flex; + align-items: baseline; + gap: 10px; +} + +.sectionLabel { + color: var(--text-primary); + font-size: 0.9em; + font-weight: 600; +} + +.sectionHint { + color: var(--text-secondary); + font-size: 0.82em; + opacity: 0.7; +} + +.lockInline { + display: inline-flex; + align-items: center; + opacity: 0.6; + color: var(--text-secondary); + flex-shrink: 0; + cursor: help; +} + +.statusCard { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border-radius: 12px; + text-align: left; + background: var(--log-bg); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.statusCard:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.statusCard h3 { + margin: 0; + font-size: 1.1em; + display: flex; + align-items: center; + gap: 8px; +} + +.statusCard h3::before { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.statusCard a { + color: var(--accent-color); + text-decoration: none; +} + +.statusCard p { + margin: 0; + color: var(--text-secondary); + font-size: 0.9em; +} + +.error h3::before { background: #f44336; } +.info h3::before { background: #2196f3; } + +.updateHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.updateVersion { + font-size: 0.78em; + color: var(--text-secondary); + background: var(--inset-bg); + border-radius: 4px; + padding: 2px 6px; +} + +.updateActions { + display: flex; + align-items: center; + gap: 12px; +} + +.downloadBtn { + display: inline-flex; + align-items: center; + padding: 7px 16px; + background: var(--accent-color); + color: #fff; + border-radius: 8px; + font-size: 0.88em; + font-weight: 500; + text-decoration: none; + transition: opacity 0.15s; +} + +.downloadBtn:hover { + opacity: 0.85; +} + +.releaseLink { + font-size: 0.85em; + color: var(--text-secondary); + text-decoration: none; + opacity: 0.7; +} + +.releaseLink:hover { + opacity: 1; +} + +.externalLink { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.noConfigMessage { + color: var(--text-secondary); + font-size: 0.9em; +} + +.optionsGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.configOptions { + display: flex; + flex-direction: column; +} diff --git a/packages/finicky-ui/src/pages/StartPage.svelte b/packages/finicky-ui/src/pages/StartPage.svelte deleted file mode 100644 index 30582bf..0000000 --- a/packages/finicky-ui/src/pages/StartPage.svelte +++ /dev/null @@ -1,363 +0,0 @@ - - - - {#if hasConfig} - {#snippet description()}Current settings from your configuration file{/snippet} - {/if} - {#if !hasConfig} - - {/if} - - -
-
- - {#if isJSConfig} - - - - {:else} - Used when no rule matches - {/if} -
- { - defaultBrowser = browser; - defaultProfile = profile; - defaultBrowserIsCustom = isCustom; - defaultProfileIsCustom = false; - }} - onProfileChange={(profile, isProfileCustom) => { - defaultProfile = profile; - defaultProfileIsCustom = isProfileCustom; - }} - onRequestProfiles={(b) => window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b })} - onSave={save} - onInput={scheduleSave} - /> -
- -
-
- - - - -
-
- - {#if numErrors > 0} -
-

Errors

-

- {numErrors} errors encountered. -

- Troubleshooting -
- {/if} - - {#if updateInfo} - {#if updateInfo.hasUpdate} -
-
-

New Version Available

- {updateInfo.version} -
- -
- {:else if !updateInfo.updateCheckEnabled} -
-

Update check is disabled

- Check releases -
- {/if} - {/if} -
- - diff --git a/packages/finicky-ui/src/pages/StartPage.tsx b/packages/finicky-ui/src/pages/StartPage.tsx new file mode 100644 index 0000000..592463b --- /dev/null +++ b/packages/finicky-ui/src/pages/StartPage.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; +import { useDebouncedCallback } from "../lib/useDebouncedCallback"; +import { Link } from "react-router-dom"; +import { PageContainer } from "../components/PageContainer"; +import { BrowserProfileSelector } from "../components/BrowserProfileSelector"; +import { OptionRow } from "../components/OptionRow"; +import { Tooltip } from "../components/Tooltip"; +import { ExternalIcon } from "../components/icons/External"; +import { LockIcon } from "../components/icons/Lock"; +import { toast } from "../lib/toast"; +import { appStore } from "../lib/appStore"; +import { api } from "../lib/api"; +import type { RulesFile, ConfigInfo, UpdateInfo } from "../types"; +import styles from "./StartPage.module.css"; + +const SAVE_DEBOUNCE = 500; +const SAFARI = "Safari"; + +interface Options { + keepRunning: boolean; + hideIcon: boolean; + logRequests: boolean; + checkForUpdates: boolean; +} + +function resolveOptions(rulesFile: RulesFile, config: ConfigInfo): Options { + return { + keepRunning: rulesFile.options?.keepRunning ?? config.options?.keepRunning ?? true, + hideIcon: rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false, + logRequests: rulesFile.options?.logRequests ?? config.options?.logRequests ?? false, + checkForUpdates: rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true, + }; +} + +function isBrowserCustom(browser: string, installed: string[]): boolean { + return browser !== "" && !installed.includes(browser); +} + +function isProfileCustom(profile: string, browser: string, byBrowser: Record): boolean { + return profile !== "" && !(byBrowser[browser] ?? []).includes(profile); +} + +function initialBp(rulesFile: RulesFile, config: ConfigInfo, hasJsConfig: boolean) { + return { + browser: hasJsConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser || SAFARI), + profile: rulesFile.defaultProfile ?? "", + browserCustom: false, + profileCustom: false, + }; +} + +function resolveBrowserIsCustom(customMode: boolean, browser: string, installed: string[]): boolean { + return customMode || isBrowserCustom(browser, installed); +} + +function resolveProfileIsCustom(customMode: boolean, profile: string, browser: string, byBrowser: Record): boolean { + return customMode || isProfileCustom(profile, browser, byBrowser); +} + +function onLockedClick() { + toast.show( + "Configuration loaded from a JavaScript configuration file", + "info", + "These settings are managed by your config file and can't be changed here." + ); +} + +function UpdateCard({ updateInfo }: { updateInfo: UpdateInfo }) { + if (updateInfo.hasUpdate) { + return ( +
+
+

New Version Available

+ {updateInfo.version} +
+ +
+ ); + } + if (!updateInfo.updateCheckEnabled) { + return ( +
+

Update check is disabled

+ + Check releases + +
+ ); + } + return null; +} + +export function StartPage() { + const { hasConfig, config, updateInfo, rulesFile, installedBrowsers, profilesByBrowser, messageBuffer } = + useSyncExternalStore(appStore.subscribe, appStore.getSnapshot); + const hasJsConfig = config.hasJsConfig ?? false; + const numErrors = messageBuffer.filter((m) => m.level.toLowerCase() === "error").length; + + const [options, setOptions] = useState(() => resolveOptions(rulesFile, config)); + const [bp, setBp] = useState(() => initialBp(rulesFile, config, hasJsConfig)); + + const pendingRef = useRef({ options, bp, rulesFile }); + pendingRef.current = { options, bp, rulesFile }; + + useEffect(() => { + setOptions(resolveOptions(rulesFile, config)); + setBp(initialBp(rulesFile, config, hasJsConfig)); + }, [rulesFile, config, hasJsConfig]); + + const saveDebounced = useDebouncedCallback(async () => { + const { options, bp, rulesFile } = pendingRef.current; + try { + const updated = await api.saveRules({ ...rulesFile, defaultBrowser: bp.browser, defaultProfile: bp.profile, options }); + appStore.update({ rulesFile: updated as any }); + } catch {} + }, SAVE_DEBOUNCE); + + const defaultBrowserIsCustom = resolveBrowserIsCustom(bp.browserCustom, bp.browser, installedBrowsers); + const defaultProfileIsCustom = resolveProfileIsCustom(bp.profileCustom, bp.profile, bp.browser, profilesByBrowser); + + function save() { if (!hasJsConfig) saveDebounced.flush(); } + function scheduleSave() { if (!hasJsConfig) saveDebounced.schedule(); } + + function setOption(key: K, value: Options[K]) { + setOptions((prev) => ({ ...prev, [key]: value })); + pendingRef.current = { ...pendingRef.current, options: { ...pendingRef.current.options, [key]: value } }; + scheduleSave(); + } + + const { keepRunning, hideIcon, logRequests, checkForUpdates } = options; + + return ( + + {!hasConfig && ( + + )} + +
+
+ Default browser + {hasJsConfig ? ( + + + + ) : ( + Used when no rule matches + )} +
+ { + const next = { ...bp, ...value, browserCustom: custom.browser, profileCustom: custom.profile }; + setBp(next); + pendingRef.current = { ...pendingRef.current, bp: next }; + if (committed) save(); else scheduleSave(); + }} + onRequestProfiles={async (b) => { try { appStore.addBrowserProfiles(b, await api.getBrowserProfiles(b)); } catch {} }} + /> +
+ +
+
+ setOption("keepRunning", v)} /> + setOption("hideIcon", v)} /> + setOption("logRequests", v)} /> + setOption("checkForUpdates", v)} /> +
+
+ + {numErrors > 0 && ( +
+

Errors

+

{numErrors} errors encountered.

+ Troubleshooting +
+ )} + + {updateInfo && } +
+ ); +} diff --git a/packages/finicky-ui/src/pages/TestUrl.module.css b/packages/finicky-ui/src/pages/TestUrl.module.css new file mode 100644 index 0000000..0a45ad3 --- /dev/null +++ b/packages/finicky-ui/src/pages/TestUrl.module.css @@ -0,0 +1,140 @@ +.inputSection { + display: flex; + flex-direction: column; + gap: 10px; +} + +.inputLabel { + color: var(--text-primary); + font-size: 0.9em; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.loadingSpinner { + width: 16px; + height: 16px; + color: var(--accent-color); + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.urlInput { + padding: 14px 16px; + font-size: 1em; + background: var(--input-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + transition: all 0.2s ease; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.urlInput:focus { + outline: none; + border-color: var(--accent-color); + background: var(--input-bg); +} + +.urlInput::placeholder { + color: var(--text-secondary); + opacity: 0.4; +} + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 16px; + color: var(--text-secondary); + opacity: 0.6; +} + +.emptyState p { + margin: 0; + font-size: 0.95em; +} + +.hintMessage { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(245, 188, 28, 0.06); + border: 1px solid rgba(245, 188, 28, 0.25); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.9em; +} + +.resultSection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.resultHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.resultHeader h3 { + margin: 0; + color: var(--text-primary); + font-size: 1.1em; +} + +.resultGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.resultItem { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + background: var(--inset-bg); + border-radius: 8px; +} + +.resultItem.fullWidth { + grid-column: 1 / -1; +} + +.resultLabel { + color: var(--text-secondary); + font-size: 0.8em; + font-weight: 500; +} + +.resultValue { + color: var(--text-primary); + font-size: 1em; + word-break: break-word; + overflow-wrap: break-word; + min-width: 0; +} + +.resultValue.browser { + font-weight: 600; + color: var(--accent-color); +} + +.resultValue.url { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9em; + word-break: break-all; +} diff --git a/packages/finicky-ui/src/pages/TestUrl.svelte b/packages/finicky-ui/src/pages/TestUrl.svelte deleted file mode 100644 index 95cee7c..0000000 --- a/packages/finicky-ui/src/pages/TestUrl.svelte +++ /dev/null @@ -1,285 +0,0 @@ - - - - {#snippet description()}Test how Finicky will handle a URL based on your current configuration{/snippet} -
- - -
- - {#if $testUrlResult} -
-
-

Result

-
-
-
- Browser - {$testUrlResult.browser} -
-
- Profile - {$testUrlResult.profile || "N/A"} -
- {#if typeof $testUrlResult.openInBackground === "boolean"} -
- Open in background - {$testUrlResult.openInBackground ? "Yes" : "No"} -
- {/if} -
- Final URL - {$testUrlResult.url} -
-
-
- {:else if testUrl.trim() && !isValidUrl(testUrl)} -
- - Enter a valid URL to see how Finicky will handle it -
- {:else if !testUrl.trim()} -
- -

Enter a URL above to test your configuration

-
- {/if} -
- - diff --git a/packages/finicky-ui/src/pages/TestUrl.tsx b/packages/finicky-ui/src/pages/TestUrl.tsx new file mode 100644 index 0000000..c2541e4 --- /dev/null +++ b/packages/finicky-ui/src/pages/TestUrl.tsx @@ -0,0 +1,137 @@ +import { useState, useEffect } from "react"; +import { PageContainer } from "../components/PageContainer"; +import { LinkIcon } from "../components/icons/Link"; +import { InfoIcon } from "../components/icons/Info"; +import { SpinnerIcon } from "../components/icons/Spinner"; +import { api } from "../lib/api"; +import type { TestUrlResult } from "../types"; +import styles from "./TestUrl.module.css"; + +function isValidUrl(url: string): boolean { + if (!url.trim()) return false; + try { + new URL(url.includes("://") ? url : `https://${url}`); + return true; + } catch { + return false; + } +} + +function normalizeUrl(url: string): string { + return url.includes("://") ? url : `https://${url}`; +} + +const DEBOUNCE_DELAY = 300; +const LOADING_DELAY = DEBOUNCE_DELAY + 100; + +function ResultItem({ label, value, itemClass, valueClass }: { + label: string; + value: React.ReactNode; + itemClass?: string; + valueClass?: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +// fallow-ignore-next-line complexity +function UrlStateView({ testUrl }: { testUrl: string }) { + if (testUrl.trim() && !isValidUrl(testUrl)) { + return ( +
+ + Enter a valid URL to see how Finicky will handle it +
+ ); + } + if (!testUrl.trim()) { + return ( +
+ +

Enter a URL above to test your configuration

+
+ ); + } + return null; +} + +export function TestUrl() { + const [testUrl, setTestUrl] = useState(""); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!isValidUrl(testUrl)) { + setResult(null); + setLoading(false); + return; + } + + const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY); + const debounceTimer = setTimeout(async () => { + try { + setResult((await api.testUrl(normalizeUrl(testUrl))) as TestUrlResult); + } catch { + setResult(null); + } finally { + setLoading(false); + } + }, DEBOUNCE_DELAY); + + return () => { + clearTimeout(loadingTimer); + clearTimeout(debounceTimer); + }; + }, [testUrl]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + +
+ + setTestUrl(e.target.value)} + /> +
+ + {result ? ( +
+
+

Result

+
+
+ + + {typeof result.openInBackground === "boolean" && ( + + )} + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/packages/finicky-ui/src/types.ts b/packages/finicky-ui/src/types.ts index 2eeb589..66ccb57 100644 --- a/packages/finicky-ui/src/types.ts +++ b/packages/finicky-ui/src/types.ts @@ -1,3 +1,18 @@ +export interface BrowserProfile { + browser: string; + profile: string; +} + +export interface BrowserOptions { + installed: string[]; + profiles: Record; +} + +export interface BrowserProfileCustom { + browser: boolean; + profile: boolean; +} + export interface Rule { match: string[]; browser: string; @@ -24,24 +39,19 @@ export interface LogEntry { msg: string; time: string; error?: string; - [key: string]: any; // Allow for additional dynamic fields + [key: string]: any; +} + +export interface TestUrlResult { + browser: string; + url: string; + openInBackground: boolean; + profile?: string; } declare global { interface Window { - finicky: { - sendMessage: (msg: any) => void; - receiveMessage: (msg: any) => void; - /** Stub queue populated by the WKUserScript before the Svelte app is ready */ - _queue?: any[]; - }; - webkit?: { - messageHandlers?: { - finicky?: { - postMessage: (msg: string) => void; - }; - }; - }; + __FINICKY_API__?: string; } } @@ -55,7 +65,7 @@ export interface UpdateInfo { export interface ConfigInfo { configPath: string; - isJSConfig?: boolean; + hasJsConfig?: boolean; handlers?: number; rewrites?: number; defaultBrowser?: string; diff --git a/packages/finicky-ui/src/vite-env.d.ts b/packages/finicky-ui/src/vite-env.d.ts index 4078e74..11f02fe 100644 --- a/packages/finicky-ui/src/vite-env.d.ts +++ b/packages/finicky-ui/src/vite-env.d.ts @@ -1,2 +1 @@ -/// /// diff --git a/packages/finicky-ui/svelte.config.js b/packages/finicky-ui/svelte.config.js deleted file mode 100644 index de2ddd6..0000000 --- a/packages/finicky-ui/svelte.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - // Consult https://svelte.dev/docs#compile-time-svelte-preprocess - // for more information about preprocessors - preprocess: vitePreprocess(), -}; diff --git a/packages/finicky-ui/tsconfig.app.json b/packages/finicky-ui/tsconfig.app.json index 55a2f9b..00441f8 100644 --- a/packages/finicky-ui/tsconfig.app.json +++ b/packages/finicky-ui/tsconfig.app.json @@ -1,20 +1,22 @@ { - "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { - "target": "ESNext", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", - "resolveJsonModule": true, - /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable checkJs if you'd like to use dynamic types in JS. - * Note that setting allowJs false does not prevent the use - * of JS in `.svelte` files. - */ - "allowJs": true, - "checkJs": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "isolatedModules": true, - "moduleDetection": "force" + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] + "include": ["src"] } diff --git a/packages/finicky-ui/vite.config.ts b/packages/finicky-ui/vite.config.ts index 8c5ca74..1eac326 100644 --- a/packages/finicky-ui/vite.config.ts +++ b/packages/finicky-ui/vite.config.ts @@ -1,10 +1,9 @@ import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; +import react from "@vitejs/plugin-react"; -// https://vite.dev/config/ export default defineConfig({ - plugins: [svelte()], - base: "finicky-assets://local/", + plugins: [react()], + base: "./", build: { assetsDir: "assets", rollupOptions: { From 2da05e73055376d48511f55b7e1e44aefdc322c2 Mon Sep 17 00:00:00 2001 From: John Sterling Date: Sun, 21 Jun 2026 16:15:18 +0200 Subject: [PATCH 2/7] Convert to React, add REST API --- apps/finicky/src/main.go | 182 +++++++++++++----- apps/finicky/src/window/server.go | 57 +++++- apps/finicky/src/window/window.go | 5 + apps/finicky/src/window/window.h | 1 + apps/finicky/src/window/window.m | 12 +- packages/finicky-ui/src/App.tsx | 23 ++- .../src/components/BrowserProfileSelector.tsx | 2 +- packages/finicky-ui/src/components/Footer.tsx | 4 +- .../finicky-ui/src/components/OptionRow.tsx | 19 +- packages/finicky-ui/src/lib/api.ts | 28 ++- packages/finicky-ui/src/lib/toast.ts | 2 +- packages/finicky-ui/src/lib/useLogControls.ts | 8 +- packages/finicky-ui/src/lib/useRulesSave.ts | 13 +- packages/finicky-ui/src/pages/StartPage.tsx | 10 +- packages/finicky-ui/src/pages/TestUrl.tsx | 4 +- packages/finicky-ui/src/types.ts | 2 +- 16 files changed, 293 insertions(+), 79 deletions(-) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 2a47d96..e7fb89d 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -25,6 +25,7 @@ import ( "os" "runtime" "strings" + "sync" "time" "github.com/dop251/goja" @@ -65,6 +66,83 @@ var configInfo *ConfigInfo var lastConfigPayload map[string]interface{} var shouldKeepRunning bool = true +// stateMu guards vm, updateInfo, configInfo, lastConfigPayload, and +// shouldKeepRunning. They're written from the single event-loop goroutine in +// main() (and from setupVM) but now also read/written from the REST API's +// HTTP handler goroutines (window.TestURLFunc, window.SaveRulesHandler, +// window.GetConfigFunc, window.GetUpdateInfoFunc), so plain reads/writes are +// no longer safe. Always go through the getX/setX helpers below instead of +// touching these globals directly. +var stateMu sync.Mutex + +func getVM() *config.VM { + stateMu.Lock() + defer stateMu.Unlock() + return vm +} + +func setVM(v *config.VM) { + stateMu.Lock() + vm = v + stateMu.Unlock() +} + +func getUpdateInfo() UpdateInfo { + stateMu.Lock() + defer stateMu.Unlock() + return updateInfo +} + +func setUpdateInfo(ui UpdateInfo) { + stateMu.Lock() + updateInfo = ui + stateMu.Unlock() +} + +func getConfigInfo() *ConfigInfo { + stateMu.Lock() + defer stateMu.Unlock() + return configInfo +} + +func getConfigPayload() map[string]interface{} { + stateMu.Lock() + defer stateMu.Unlock() + return lastConfigPayload +} + +// publishConfigInfo records a freshly computed ConfigInfo and returns the +// resulting effective value. If ci is nil the previously published +// ConfigInfo is kept and returned (mirrors the pre-existing behavior in +// setupVM, where a nil ConfigState from the VM doesn't clear out the prior +// config). +func publishConfigInfo(ci *ConfigInfo) *ConfigInfo { + stateMu.Lock() + defer stateMu.Unlock() + if ci != nil { + configInfo = ci + } + return configInfo +} + +func setConfigPayload(payload map[string]interface{}) { + stateMu.Lock() + lastConfigPayload = payload + stateMu.Unlock() +} + +func getShouldKeepRunning() bool { + stateMu.Lock() + defer stateMu.Unlock() + return shouldKeepRunning +} + +func setShouldKeepRunning(v bool) { + stateMu.Lock() + shouldKeepRunning = v + stateMu.Unlock() +} + func main() { startTime := time.Now() logger.Setup() @@ -124,10 +202,11 @@ func main() { handleFatalError(fmt.Sprintf("Failed to setup config file watcher: %v", err)) } - vm, err = setupVM(cfw, namespace) + initialVM, err := setupVM(cfw, namespace) if err != nil { handleFatalError(err.Error()) } + setVM(initialVM) slog.Debug("VM setup complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) @@ -135,7 +214,7 @@ func main() { window.TestURLFunc = func(url string) (interface{}, error) { slog.Debug("Testing URL", "url", url) - cfg, err := resolver.ResolveURL(vm, url, nil, false) + cfg, err := resolver.ResolveURL(getVM(), url, nil, false) if err != nil { return nil, err } @@ -151,29 +230,32 @@ func main() { window.GetVersionFunc = version.GetCurrentVersion window.GetConfigFunc = func() interface{} { - return lastConfigPayload + return getConfigPayload() } window.GetUpdateInfoFunc = func() interface{} { - if updateInfo.ReleaseInfo == nil && !updateInfo.UpdateCheckEnabled { + ui := getUpdateInfo() + if ui.ReleaseInfo == nil && !ui.UpdateCheckEnabled { return nil } - return buildUpdateInfoPayload() + return buildUpdateInfoPayload(ui) } if err := window.StartAPIServer(); err != nil { - slog.Error("Failed to start API server", "error", err) + handleFatalError(fmt.Sprintf("Failed to start API server: %v", err)) } // Set up rules save handler. // When there is no JS config, rebuild the VM from the updated rules. // When there is a JS config, JSON rules are loaded fresh in evaluateURL — nothing to do. + // Invoked synchronously from the /api/rules HTTP handler so the new VM is + // guaranteed to be in place by the time that request completes. window.SaveRulesHandler = func(rf rules.RulesFile) { slog.Debug("Rules updated", "count", len(rf.Rules)) resolver.SetCachedRules(rf) - if vm == nil || !vm.IsJSConfig() { + if v := getVM(); v == nil || !v.IsJSConfig() { if rf.DefaultBrowser == "" && len(rf.Rules) == 0 && rf.Options == nil { - vm = nil + setVM(nil) return } script, err := rules.ToJSConfigScript(rf, namespace) @@ -186,9 +268,9 @@ func main() { slog.Error("Failed to rebuild VM from rules", "error", err) return } - vm = newVM - if vm != nil { - shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + setVM(newVM) + if newVM != nil { + setShouldKeepRunning(newVM.GetAllConfigOptions().KeepRunning) go checkForUpdates() } } @@ -200,10 +282,10 @@ func main() { timeoutChan := time.After(1 * time.Second) updateChan := time.After(oneDay) - if vm != nil { - shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + if v := getVM(); v != nil { + setShouldKeepRunning(v.GetAllConfigOptions().KeepRunning) } - if shouldKeepRunning { + if getShouldKeepRunning() { timeoutChan = nil } @@ -218,7 +300,7 @@ func main() { slog.Info("URL received", "url", url) - config, err := resolver.ResolveURL(vm, url, urlInfo.Opener, urlInfo.OpenInBackground) + config, err := resolver.ResolveURL(getVM(), url, urlInfo.Opener, urlInfo.OpenInBackground) if err != nil { handleRuntimeError(err) } else { @@ -230,7 +312,7 @@ func main() { slog.Debug("Time taken evaluating URL and opening browser", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) - if !showingWindow && !shouldKeepRunning { + if !showingWindow && !getShouldKeepRunning() { timeoutChan = time.After(2 * time.Second) } else { timeoutChan = nil @@ -238,9 +320,9 @@ func main() { case <-configChange: startTime := time.Now() - var setupErr error slog.Debug("Config has changed") - vm, setupErr = setupVM(cfw, namespace) + newVM, setupErr := setupVM(cfw, namespace) + setVM(newVM) if setupErr != nil { handleRuntimeError(setupErr) } else { @@ -248,8 +330,8 @@ func main() { C.SetStatusItemError(false) } slog.Debug("VM refresh complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) - if vm != nil { - shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + if newVM != nil { + setShouldKeepRunning(newVM.GetAllConfigOptions().KeepRunning) go checkForUpdates() } @@ -265,7 +347,7 @@ func main() { updateChan = time.After(oneDay) case <-windowClosed: - if !shouldKeepRunning { + if !getShouldKeepRunning() { slog.Info("Exiting due to window closed") tearDown() } else { @@ -280,10 +362,10 @@ func main() { }() shouldHideIcon := false - if vm != nil { - shouldHideIcon = vm.GetAllConfigOptions().HideIcon + if v := getVM(); v != nil { + shouldHideIcon = v.GetAllConfigOptions().HideIcon } - C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(shouldKeepRunning)) + C.RunApp(C.bool(forceWindowOpen), C.bool(!shouldHideIcon), C.bool(getShouldKeepRunning())) } func handleRuntimeError(err error) { @@ -352,8 +434,8 @@ func WindowDidClose() { //export GetCurrentConfigPath func GetCurrentConfigPath() *C.char { - if configInfo != nil && configInfo.ConfigPath != "" { - cPath := C.CString(configInfo.ConfigPath) + if ci := getConfigInfo(); ci != nil && ci.ConfigPath != "" { + cPath := C.CString(ci.ConfigPath) return cPath } else { return nil @@ -362,8 +444,8 @@ func GetCurrentConfigPath() *C.char { func checkForUpdates() { var runtime *goja.Runtime - if vm != nil { - runtime = vm.Runtime() + if v := getVM(); v != nil { + runtime = v.Runtime() } releaseInfo, updateCheckEnabled, err := version.CheckForUpdatesIfEnabled(runtime) @@ -371,31 +453,32 @@ func checkForUpdates() { slog.Error("Error checking for updates", "error", err) } - updateInfo = UpdateInfo{ + ui := UpdateInfo{ ReleaseInfo: releaseInfo, UpdateCheckEnabled: updateCheckEnabled, } + setUpdateInfo(ui) - if updateInfo.ReleaseInfo != nil && updateInfo.ReleaseInfo.HasUpdate { - slog.Info("New version is available", "version", updateInfo.ReleaseInfo.LatestVersion) + if ui.ReleaseInfo != nil && ui.ReleaseInfo.HasUpdate { + slog.Info("New version is available", "version", ui.ReleaseInfo.LatestVersion) } - window.BroadcastSSE("updateInfo", buildUpdateInfoPayload()) + window.BroadcastSSE("updateInfo", buildUpdateInfoPayload(ui)) } -func buildUpdateInfoPayload() map[string]interface{} { - if updateInfo.ReleaseInfo != nil { +func buildUpdateInfoPayload(ui UpdateInfo) map[string]interface{} { + if ui.ReleaseInfo != nil { return map[string]interface{}{ - "version": updateInfo.ReleaseInfo.LatestVersion, - "hasUpdate": updateInfo.ReleaseInfo.HasUpdate, - "updateCheckEnabled": updateInfo.UpdateCheckEnabled, - "downloadUrl": updateInfo.ReleaseInfo.DownloadUrl, - "releaseUrl": updateInfo.ReleaseInfo.ReleaseUrl, + "version": ui.ReleaseInfo.LatestVersion, + "hasUpdate": ui.ReleaseInfo.HasUpdate, + "updateCheckEnabled": ui.UpdateCheckEnabled, + "downloadUrl": ui.ReleaseInfo.DownloadUrl, + "releaseUrl": ui.ReleaseInfo.ReleaseUrl, } } return map[string]interface{}{ "version": "", "hasUpdate": false, - "updateCheckEnabled": updateInfo.UpdateCheckEnabled, + "updateCheckEnabled": ui.UpdateCheckEnabled, "downloadUrl": "", "releaseUrl": "", } } @@ -466,8 +549,9 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error } cs := newVM.GetConfigState() + var ci *ConfigInfo if cs != nil { - configInfo = &ConfigInfo{ + ci = &ConfigInfo{ Handlers: cs.Handlers, Rewrites: cs.Rewrites, DefaultBrowser: cs.DefaultBrowser, @@ -478,11 +562,14 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error opts := newVM.GetAllConfigOptions() logRequests = opts.LogRequests - lastConfigPayload = map[string]interface{}{ - "handlers": configInfo.Handlers, - "rewrites": configInfo.Rewrites, - "defaultBrowser": configInfo.DefaultBrowser, - "configPath": util.ShortenPath(configInfo.ConfigPath), + // publishConfigInfo keeps the previously published ConfigInfo when cs + // (and so ci) is nil, mirroring the prior behavior of this function. + publishedCI := publishConfigInfo(ci) + payload := map[string]interface{}{ + "handlers": publishedCI.Handlers, + "rewrites": publishedCI.Rewrites, + "defaultBrowser": publishedCI.DefaultBrowser, + "configPath": util.ShortenPath(publishedCI.ConfigPath), "hasJsConfig": newVM.IsJSConfig(), "options": map[string]interface{}{ "keepRunning": opts.KeepRunning, @@ -491,7 +578,8 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error "checkForUpdates": opts.CheckForUpdates, }, } - window.BroadcastSSE("config", lastConfigPayload) + setConfigPayload(payload) + window.BroadcastSSE("config", payload) return newVM, nil } diff --git a/apps/finicky/src/window/server.go b/apps/finicky/src/window/server.go index b576b09..b16092a 100644 --- a/apps/finicky/src/window/server.go +++ b/apps/finicky/src/window/server.go @@ -1,6 +1,9 @@ package window import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" "encoding/json" "finicky/browser" "finicky/rules" @@ -15,6 +18,7 @@ import ( var ( apiPort int + apiToken string hub = newSSEHub() GetVersionFunc func() string GetConfigFunc func() interface{} @@ -22,6 +26,22 @@ var ( TestURLFunc func(url string) (interface{}, error) ) +// trustedOrigin is the Origin header sent by the WebView. Pages loaded via a +// custom, non-special URL scheme (see "finicky-assets://" in window.m) get an +// opaque origin per the URL living standard, which browsers serialize as the +// literal string "null". A regular website navigated to in a browser always +// has a real http(s) origin, so restricting CORS to this value keeps other +// sites from reading responses from this loopback API. +const trustedOrigin = "null" + +func init() { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("failed to generate API token: %v", err)) + } + apiToken = hex.EncodeToString(b) +} + type sseHub struct { mu sync.Mutex clients map[chan string]struct{} @@ -93,15 +113,17 @@ func StartAPIServer() error { mux.HandleFunc("POST /api/test-url", handleTestURLHTTP) mux.HandleFunc("GET /api/events", handleSSE) - go http.Serve(ln, corsMiddleware(mux)) //nolint:errcheck + go http.Serve(ln, corsMiddleware(authMiddleware(mux))) //nolint:errcheck return nil } func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Header.Get("Origin") == trustedOrigin { + w.Header().Set("Access-Control-Allow-Origin", trustedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Finicky-Token") + } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -110,6 +132,29 @@ func corsMiddleware(next http.Handler) http.Handler { }) } +// authMiddleware requires the per-process API token on every request, via +// either the X-Finicky-Token header (used by fetch) or a "token" query +// parameter (used by EventSource, which cannot set custom headers). This +// keeps the loopback API from being usable by anything that doesn't have the +// token the native app injected into the WebView, regardless of origin. +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + token := r.Header.Get("X-Finicky-Token") + if token == "" { + token = r.URL.Query().Get("token") + } + if token == "" || subtle.ConstantTimeCompare([]byte(token), []byte(apiToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + func writeJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(data); err != nil { @@ -152,8 +197,10 @@ func handleSaveRulesHTTP(w http.ResponseWriter, r *http.Request) { return } slog.Debug("Rules saved", "count", len(rf.Rules)) + // Apply synchronously so a follow-up request (e.g. /api/test-url) is + // guaranteed to see the rebuilt VM rather than racing with it. if SaveRulesHandler != nil { - go SaveRulesHandler(rf) + SaveRulesHandler(rf) } writeJSON(w, getRulesData()) } diff --git a/apps/finicky/src/window/window.go b/apps/finicky/src/window/window.go index 8768446..cb6ac24 100644 --- a/apps/finicky/src/window/window.go +++ b/apps/finicky/src/window/window.go @@ -74,3 +74,8 @@ func CloseWindow() { func GetAPIPort() C.int { return C.int(apiPort) } + +//export GetAPIToken +func GetAPIToken() *C.char { + return C.CString(apiToken) +} diff --git a/apps/finicky/src/window/window.h b/apps/finicky/src/window/window.h index 62e9e45..3cf52e6 100644 --- a/apps/finicky/src/window/window.h +++ b/apps/finicky/src/window/window.h @@ -17,5 +17,6 @@ void SetFileContentWithLength(const char* path, const char* content, size_t leng extern void WindowDidClose(void); extern int GetAPIPort(void); +extern char* GetAPIToken(void); #endif /* WINDOW_H */ diff --git a/apps/finicky/src/window/window.m b/apps/finicky/src/window/window.m index c012820..837c3ed 100644 --- a/apps/finicky/src/window/window.m +++ b/apps/finicky/src/window/window.m @@ -76,10 +76,18 @@ - (void)setupWindow { WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; [config setURLSchemeHandler:self forURLScheme:@"finicky-assets"]; - // Inject the API base URL so the UI can reach the local HTTP server. + // Inject the API base URL and per-process auth token so the UI can reach + // the local HTTP server. The token must be freed since it crosses the + // CGo boundary as a copy (see GetAPIToken in window.go). extern int GetAPIPort(void); + extern char* GetAPIToken(void); int port = GetAPIPort(); - NSString *apiScript = [NSString stringWithFormat:@"window.__FINICKY_API__ = 'http://127.0.0.1:%d/api';", port]; + char* tokenCStr = GetAPIToken(); + NSString *token = [NSString stringWithUTF8String:tokenCStr]; + free(tokenCStr); + NSString *apiScript = [NSString stringWithFormat: + @"window.__FINICKY_API__ = 'http://127.0.0.1:%d/api'; window.__FINICKY_API_TOKEN__ = '%@';", + port, token]; WKUserScript *apiUserScript = [[WKUserScript alloc] initWithSource:apiScript injectionTime:WKUserScriptInjectionTimeAtDocumentStart diff --git a/packages/finicky-ui/src/App.tsx b/packages/finicky-ui/src/App.tsx index e8281bb..e51178d 100644 --- a/packages/finicky-ui/src/App.tsx +++ b/packages/finicky-ui/src/App.tsx @@ -15,6 +15,7 @@ import { LogsIcon } from "./components/icons/Logs"; import { AboutIcon } from "./components/icons/About"; import { api } from "./lib/api"; import { appStore } from "./lib/appStore"; +import { toast } from "./lib/toast"; import { useSSE } from "./lib/useSSE"; import type { TabDef, BottomTabDef } from "./components/TabBar"; import styles from "./App.module.css"; @@ -34,9 +35,13 @@ export default function App() { useSSE(); useEffect(() => { - async function load() { + let cancelled = false; + const MAX_ATTEMPTS = 3; + + async function load(attempt = 0) { try { const data = await api.initialData(); + if (cancelled) return; appStore.update({ version: (data.version as string) ?? "v0.0.0", installedBrowsers: (data.installedBrowsers as string[]) ?? [], @@ -44,9 +49,23 @@ export default function App() { ...(data.config ? { hasConfig: true, config: data.config as any } : {}), ...(data.updateInfo ? { updateInfo: data.updateInfo as any } : {}), }); - } catch {} + } catch (err) { + if (cancelled) return; + if (attempt < MAX_ATTEMPTS) { + setTimeout(() => load(attempt + 1), 500 * (attempt + 1)); + } else { + toast.error( + "Failed to load configuration", + err instanceof Error ? err.message : String(err) + ); + } + } } load(); + + return () => { + cancelled = true; + }; }, []); return ( diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.tsx b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx index 04cf59d..394eff8 100644 --- a/packages/finicky-ui/src/components/BrowserProfileSelector.tsx +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx @@ -141,7 +141,7 @@ export function BrowserProfileSelector({ onClear={() => onChange({ browser: "", profile: "" }, { browser: false, profile: false }, true)} onBlur={() => onChange({ browser, profile }, custom, true)} /> - {!custom.browser && browser && profileOptions.length > 0 && ( + {!custom.browser && browser && (profileOptions.length > 0 || custom.profile) && ( showPathToast(basename(config.configPath), "Configuration loaded from\n${config.configPath}")} + onClick={() => showPathToast(basename(config.configPath), `Configuration loaded from\n${config.configPath}`)} > ✓ {basename(config.configPath)} @@ -34,7 +34,7 @@ export function Footer() { {rulesFile.path && rulesFile.path !== config.configPath && ( diff --git a/packages/finicky-ui/src/components/OptionRow.tsx b/packages/finicky-ui/src/components/OptionRow.tsx index e4d80e7..770c02e 100644 --- a/packages/finicky-ui/src/components/OptionRow.tsx +++ b/packages/finicky-ui/src/components/OptionRow.tsx @@ -25,6 +25,7 @@ function Inner({ label, hint, checked, locked, onChange }: Props) { type="checkbox" checked={checked} disabled={locked} + aria-label={label} onChange={(e) => onChange?.(e.target.checked)} /> @@ -42,11 +43,25 @@ export function OptionRow(props: Props) { const { locked, onLockedClick } = props; if (locked) { + // A + ); } diff --git a/packages/finicky-ui/src/lib/api.ts b/packages/finicky-ui/src/lib/api.ts index c5870ff..7cd619f 100644 --- a/packages/finicky-ui/src/lib/api.ts +++ b/packages/finicky-ui/src/lib/api.ts @@ -13,12 +13,31 @@ const BASE_URL: string = (() => { ); })(); +// The native app injects a per-process secret into the WebView so the local +// REST/SSE API can reject requests from anything else (e.g. a website +// scanning localhost ports). Dev builds can pass the same token as a query +// param when pointed at a real running instance via VITE_API_URL. +const API_TOKEN: string = (() => { + const token = new URLSearchParams(window.location.search).get("apiToken"); + if (token) { + localStorage.setItem("devApiToken", token); + return token; + } + return ( + (window as any).__FINICKY_API_TOKEN__ ?? + localStorage.getItem("devApiToken") ?? + "" + ); +})(); + function base(): string { return BASE_URL; } async function get(path: string): Promise { - const res = await fetch(`${base()}${path}`); + const res = await fetch(`${base()}${path}`, { + headers: { "X-Finicky-Token": API_TOKEN }, + }); if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); return res.json(); } @@ -26,7 +45,10 @@ async function get(path: string): Promise { async function post(path: string, body: unknown): Promise { const res = await fetch(`${base()}${path}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + "X-Finicky-Token": API_TOKEN, + }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(await res.text()); @@ -41,5 +63,5 @@ export const api = { getBrowserProfiles: (browser: string) => get(`/browser-profiles?browser=${encodeURIComponent(browser)}`), testUrl: (url: string) => post("/test-url", { url }), - eventsUrl: () => `${base()}/events`, + eventsUrl: () => `${base()}/events?token=${encodeURIComponent(API_TOKEN)}`, }; diff --git a/packages/finicky-ui/src/lib/toast.ts b/packages/finicky-ui/src/lib/toast.ts index 7557053..7bacf78 100644 --- a/packages/finicky-ui/src/lib/toast.ts +++ b/packages/finicky-ui/src/lib/toast.ts @@ -51,7 +51,7 @@ function createToastStore() { if (existing.timeoutId) clearTimeout(existing.timeoutId); const timeoutId = setTimeout(() => remove(existing.id), duration) as unknown as number; toasts = toasts.map((t) => - t.id === existing.id ? { ...t, duration, timeoutId, key: Date.now() } : t + t.id === existing.id ? { ...t, extra, duration, timeoutId, key: Date.now() } : t ); notify(); return existing.id; diff --git a/packages/finicky-ui/src/lib/useLogControls.ts b/packages/finicky-ui/src/lib/useLogControls.ts index ad5e70b..9b9576c 100644 --- a/packages/finicky-ui/src/lib/useLogControls.ts +++ b/packages/finicky-ui/src/lib/useLogControls.ts @@ -27,11 +27,11 @@ export function useLogControls(messageBuffer: LogEntry[]) { } async function copyLogs() { - const text = messageBuffer - .filter((entry) => showDebug || entry.level.toLowerCase() !== "debug") - .map(formatEntry) - .join("\n"); try { + const text = messageBuffer + .filter((entry) => showDebug || entry.level.toLowerCase() !== "debug") + .map(formatEntry) + .join("\n"); await navigator.clipboard.writeText(text); toast.success("Logs copied to clipboard"); } catch (err) { diff --git a/packages/finicky-ui/src/lib/useRulesSave.ts b/packages/finicky-ui/src/lib/useRulesSave.ts index a6ec6e0..c3422e9 100644 --- a/packages/finicky-ui/src/lib/useRulesSave.ts +++ b/packages/finicky-ui/src/lib/useRulesSave.ts @@ -8,20 +8,23 @@ export function useRulesSave(rulesFile: RulesFile, getRules: () => Rule[], delay const isPending = useRef(false); const debounced = useDebouncedCallback(async () => { - isPending.current = false; try { const updated = await api.saveRules({ - defaultBrowser: rulesFile.defaultBrowser, - defaultProfile: rulesFile.defaultProfile, + ...rulesFile, rules: getRules(), }); appStore.update({ rulesFile: updated as any }); - } catch {} + } catch {} finally { + isPending.current = false; + } }, delay); return { isPending, - save() { debounced.flush(); }, + save() { + isPending.current = true; + debounced.flush(); + }, scheduleSave() { isPending.current = true; debounced.schedule(); diff --git a/packages/finicky-ui/src/pages/StartPage.tsx b/packages/finicky-ui/src/pages/StartPage.tsx index 592463b..0261e60 100644 --- a/packages/finicky-ui/src/pages/StartPage.tsx +++ b/packages/finicky-ui/src/pages/StartPage.tsx @@ -108,8 +108,10 @@ export function StartPage() { const pendingRef = useRef({ options, bp, rulesFile }); pendingRef.current = { options, bp, rulesFile }; + const isPending = useRef(false); useEffect(() => { + if (isPending.current) return; setOptions(resolveOptions(rulesFile, config)); setBp(initialBp(rulesFile, config, hasJsConfig)); }, [rulesFile, config, hasJsConfig]); @@ -119,14 +121,16 @@ export function StartPage() { try { const updated = await api.saveRules({ ...rulesFile, defaultBrowser: bp.browser, defaultProfile: bp.profile, options }); appStore.update({ rulesFile: updated as any }); - } catch {} + } catch {} finally { + isPending.current = false; + } }, SAVE_DEBOUNCE); const defaultBrowserIsCustom = resolveBrowserIsCustom(bp.browserCustom, bp.browser, installedBrowsers); const defaultProfileIsCustom = resolveProfileIsCustom(bp.profileCustom, bp.profile, bp.browser, profilesByBrowser); - function save() { if (!hasJsConfig) saveDebounced.flush(); } - function scheduleSave() { if (!hasJsConfig) saveDebounced.schedule(); } + function save() { if (!hasJsConfig) { isPending.current = true; saveDebounced.flush(); } } + function scheduleSave() { if (!hasJsConfig) { isPending.current = true; saveDebounced.schedule(); } } function setOption(key: K, value: Options[K]) { setOptions((prev) => ({ ...prev, [key]: value })); diff --git a/packages/finicky-ui/src/pages/TestUrl.tsx b/packages/finicky-ui/src/pages/TestUrl.tsx index c2541e4..c38ae4f 100644 --- a/packages/finicky-ui/src/pages/TestUrl.tsx +++ b/packages/finicky-ui/src/pages/TestUrl.tsx @@ -71,13 +71,15 @@ export function TestUrl() { return; } - const loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY); + let loadingTimer: ReturnType | undefined; const debounceTimer = setTimeout(async () => { + loadingTimer = setTimeout(() => setLoading(true), LOADING_DELAY - DEBOUNCE_DELAY); try { setResult((await api.testUrl(normalizeUrl(testUrl))) as TestUrlResult); } catch { setResult(null); } finally { + clearTimeout(loadingTimer); setLoading(false); } }, DEBOUNCE_DELAY); diff --git a/packages/finicky-ui/src/types.ts b/packages/finicky-ui/src/types.ts index 66ccb57..38eccac 100644 --- a/packages/finicky-ui/src/types.ts +++ b/packages/finicky-ui/src/types.ts @@ -45,7 +45,7 @@ export interface LogEntry { export interface TestUrlResult { browser: string; url: string; - openInBackground: boolean; + openInBackground?: boolean; profile?: string; } From 4f1bcd3d2b0125689fdc0cfe80a1c7231d39b259 Mon Sep 17 00:00:00 2001 From: John Sterling Date: Wed, 1 Jul 2026 19:48:00 +0200 Subject: [PATCH 3/7] split api, add clsx --- apps/finicky/src/config/vm.go | 8 +- apps/finicky/src/main.go | 82 +++++++++++++------ apps/finicky/src/window/server.go | 57 +++++++++++-- packages/finicky-ui/package-lock.json | 11 ++- packages/finicky-ui/package.json | 3 +- .../src/components/BrowserProfileSelector.tsx | 3 +- packages/finicky-ui/src/components/Footer.tsx | 3 +- .../finicky-ui/src/components/LogContent.tsx | 3 +- .../finicky-ui/src/components/OptionRow.tsx | 5 +- .../src/components/PageContainer.tsx | 3 +- packages/finicky-ui/src/components/TabBar.tsx | 5 +- .../src/components/ToastContainer.tsx | 3 +- .../finicky-ui/src/components/Tooltip.tsx | 3 +- .../src/lib/{api.ts => api/client.ts} | 38 +++------ packages/finicky-ui/src/lib/api/index.ts | 12 +++ packages/finicky-ui/src/lib/useRulesSave.ts | 23 ++++-- packages/finicky-ui/src/pages/Rules.tsx | 63 ++++++++++---- packages/finicky-ui/src/pages/StartPage.tsx | 47 +++++++---- packages/finicky-ui/src/pages/TestUrl.tsx | 22 +++-- packages/finicky-ui/src/types.ts | 1 + 20 files changed, 273 insertions(+), 122 deletions(-) rename packages/finicky-ui/src/lib/{api.ts => api/client.ts} (58%) create mode 100644 packages/finicky-ui/src/lib/api/index.ts diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index 3383dd8..344f349 100644 --- a/apps/finicky/src/config/vm.go +++ b/apps/finicky/src/config/vm.go @@ -17,10 +17,10 @@ type VM struct { // ConfigOptions holds the values of all runtime config options. type ConfigOptions struct { - KeepRunning bool - HideIcon bool - LogRequests bool - CheckForUpdates bool + KeepRunning bool `json:"keepRunning"` + HideIcon bool `json:"hideIcon"` + LogRequests bool `json:"logRequests"` + CheckForUpdates bool `json:"checkForUpdates"` } // ConfigState represents the current state of the configuration diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index e7fb89d..37c6f13 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -52,6 +52,16 @@ type ConfigInfo struct { ConfigPath string `json:"configPath"` } +// configPayload is the REST/SSE "config" wire shape. Embedding ConfigInfo +// and config.ConfigOptions means their fields only need to be named once, +// in their own tagged struct definitions, instead of being restated as +// string literal map keys that could silently drift from the struct. +type configPayload struct { + ConfigInfo + HasJsConfig bool `json:"hasJsConfig"` + Options config.ConfigOptions `json:"options"` +} + var urlListener chan URLInfo = make(chan URLInfo) var windowClosed chan struct{} = make(chan struct{}) var vm *config.VM @@ -63,7 +73,7 @@ var dryRun bool = false var skipJSConfig bool = false var updateInfo UpdateInfo var configInfo *ConfigInfo -var lastConfigPayload map[string]interface{} +var lastConfigPayload interface{} var shouldKeepRunning bool = true // stateMu guards vm, updateInfo, configInfo, lastConfigPayload, and @@ -105,7 +115,7 @@ func getConfigInfo() *ConfigInfo { return configInfo } -func getConfigPayload() map[string]interface{} { +func getConfigPayload() interface{} { stateMu.Lock() defer stateMu.Unlock() return lastConfigPayload @@ -125,7 +135,7 @@ func publishConfigInfo(ci *ConfigInfo) *ConfigInfo { return configInfo } -func setConfigPayload(payload map[string]interface{}) { +func setConfigPayload(payload interface{}) { stateMu.Lock() lastConfigPayload = payload stateMu.Unlock() @@ -137,6 +147,15 @@ func getShouldKeepRunning() bool { return shouldKeepRunning } +// vmBuildMu serializes the "decide whether/how to rebuild the VM, build it, +// then publish it" sequence run by the configChange case (main event-loop +// goroutine) and window.SaveRulesHandler (an HTTP-handler goroutine, invoked +// synchronously from POST /api/rules). stateMu alone only makes each +// individual getVM()/setVM() call atomic, not that whole read-decide-write +// sequence, so without this the two rebuild paths could race to publish and +// whichever finished last would silently win, discarding the other's change. +var vmBuildMu sync.Mutex + func setShouldKeepRunning(v bool) { stateMu.Lock() shouldKeepRunning = v @@ -241,21 +260,23 @@ func main() { return buildUpdateInfoPayload(ui) } - if err := window.StartAPIServer(); err != nil { - handleFatalError(fmt.Sprintf("Failed to start API server: %v", err)) - } - - // Set up rules save handler. + // Set up rules save handler before starting the API server below, since + // StartAPIServer begins accepting HTTP requests immediately and a + // POST /api/rules landing before this assignment would silently skip + // the VM rebuild while still reporting success. // When there is no JS config, rebuild the VM from the updated rules. // When there is a JS config, JSON rules are loaded fresh in evaluateURL — nothing to do. // Invoked synchronously from the /api/rules HTTP handler so the new VM is // guaranteed to be in place by the time that request completes. window.SaveRulesHandler = func(rf rules.RulesFile) { + vmBuildMu.Lock() + defer vmBuildMu.Unlock() slog.Debug("Rules updated", "count", len(rf.Rules)) resolver.SetCachedRules(rf) if v := getVM(); v == nil || !v.IsJSConfig() { if rf.DefaultBrowser == "" && len(rf.Rules) == 0 && rf.Options == nil { setVM(nil) + setShouldKeepRunning(true) return } script, err := rules.ToJSConfigScript(rf, namespace) @@ -276,6 +297,10 @@ func main() { } } + if err := window.StartAPIServer(); err != nil { + handleFatalError(fmt.Sprintf("Failed to start API server: %v", err)) + } + const oneDay = 24 * time.Hour var showingWindow bool = false @@ -321,19 +346,26 @@ func main() { case <-configChange: startTime := time.Now() slog.Debug("Config has changed") + vmBuildMu.Lock() newVM, setupErr := setupVM(cfw, namespace) - setVM(newVM) if setupErr != nil { + // Keep the last good VM running rather than publishing + // the failed reload's nil result, so URL handling still + // uses the previous config while the error is surfaced. handleRuntimeError(setupErr) } else { + setVM(newVM) lastError = nil C.SetStatusItemError(false) - } - slog.Debug("VM refresh complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) - if newVM != nil { - setShouldKeepRunning(newVM.GetAllConfigOptions().KeepRunning) + keepRunning := true + if newVM != nil { + keepRunning = newVM.GetAllConfigOptions().KeepRunning + } + setShouldKeepRunning(keepRunning) go checkForUpdates() } + vmBuildMu.Unlock() + slog.Debug("VM refresh complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) case shouldShowWindow := <-queueWindowOpen: if !showingWindow && shouldShowWindow { @@ -563,21 +595,19 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error logRequests = opts.LogRequests // publishConfigInfo keeps the previously published ConfigInfo when cs - // (and so ci) is nil, mirroring the prior behavior of this function. + // (and so ci) is nil, mirroring the prior behavior of this function. On + // the very first call there's nothing previously published yet, so fall + // back to an empty ConfigInfo rather than dereferencing nil below. publishedCI := publishConfigInfo(ci) - payload := map[string]interface{}{ - "handlers": publishedCI.Handlers, - "rewrites": publishedCI.Rewrites, - "defaultBrowser": publishedCI.DefaultBrowser, - "configPath": util.ShortenPath(publishedCI.ConfigPath), - "hasJsConfig": newVM.IsJSConfig(), - "options": map[string]interface{}{ - "keepRunning": opts.KeepRunning, - "hideIcon": opts.HideIcon, - "logRequests": opts.LogRequests, - "checkForUpdates": opts.CheckForUpdates, - }, + if publishedCI == nil { + publishedCI = &ConfigInfo{} + } + payload := configPayload{ + ConfigInfo: *publishedCI, + HasJsConfig: newVM.IsJSConfig(), + Options: opts, } + payload.ConfigPath = util.ShortenPath(publishedCI.ConfigPath) setConfigPayload(payload) window.BroadcastSSE("config", payload) diff --git a/apps/finicky/src/window/server.go b/apps/finicky/src/window/server.go index b16092a..0336b9b 100644 --- a/apps/finicky/src/window/server.go +++ b/apps/finicky/src/window/server.go @@ -42,19 +42,42 @@ func init() { apiToken = hex.EncodeToString(b) } +// logBacklogSize bounds how many recent "log" SSE messages are kept to +// replay to a newly-connecting client. Without this, log lines emitted +// before any client is connected (which is essentially all startup +// logging, since logger.Setup() runs long before the WebView's EventSource +// connects) would be lost forever, since there's no other endpoint that +// exposes historical log lines to the UI. +const logBacklogSize = 200 + type sseHub struct { - mu sync.Mutex - clients map[chan string]struct{} + mu sync.Mutex + clients map[chan string]struct{} + logBacklog []string // recent raw "log" messages, oldest first, capped at logBacklogSize + lastByEvent map[string]string // most recent raw message per non-"log" event (e.g. config, updateInfo) } func newSSEHub() *sseHub { - return &sseHub{clients: make(map[chan string]struct{})} + return &sseHub{ + clients: make(map[chan string]struct{}), + lastByEvent: make(map[string]string), + } } -func (h *sseHub) register(ch chan string) { +// registerWithBacklog adds ch to the set of live clients and returns a +// snapshot of messages to replay to it first, atomically with respect to +// broadcastRaw so nothing broadcast after this call is missed and nothing +// is replayed twice. +func (h *sseHub) registerWithBacklog(ch chan string) []string { h.mu.Lock() + defer h.mu.Unlock() h.clients[ch] = struct{}{} - h.mu.Unlock() + backlog := make([]string, 0, len(h.logBacklog)+len(h.lastByEvent)) + backlog = append(backlog, h.logBacklog...) + for _, msg := range h.lastByEvent { + backlog = append(backlog, msg) + } + return backlog } func (h *sseHub) unregister(ch chan string) { @@ -76,6 +99,14 @@ func (h *sseHub) broadcast(event string, data interface{}) { func (h *sseHub) broadcastRaw(event string, payload []byte) { msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, payload) h.mu.Lock() + if event == "log" { + h.logBacklog = append(h.logBacklog, msg) + if len(h.logBacklog) > logBacklogSize { + h.logBacklog = h.logBacklog[len(h.logBacklog)-logBacklogSize:] + } + } else { + h.lastByEvent[event] = msg + } for ch := range h.clients { select { case ch <- msg: @@ -202,7 +233,7 @@ func handleSaveRulesHTTP(w http.ResponseWriter, r *http.Request) { if SaveRulesHandler != nil { SaveRulesHandler(rf) } - writeJSON(w, getRulesData()) + writeJSON(w, rulesResponseFor(rf)) } func handleGetBrowsersHTTP(w http.ResponseWriter, _ *http.Request) { @@ -245,9 +276,14 @@ func handleSSE(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "keep-alive") ch := make(chan string, 32) - hub.register(ch) + backlog := hub.registerWithBacklog(ch) defer hub.unregister(ch) + for _, msg := range backlog { + fmt.Fprint(w, msg) + } + flusher.Flush() + for { select { case msg, ok := <-ch: @@ -268,6 +304,13 @@ func getRulesData() interface{} { slog.Error("Failed to load rules", "error", err) return map[string]interface{}{"defaultBrowser": "", "rules": []interface{}{}} } + return rulesResponseFor(rf) +} + +// rulesResponseFor builds the REST response shape for an already-loaded +// RulesFile, without re-reading it from disk (e.g. right after a save, +// where the caller already has the just-written content in memory). +func rulesResponseFor(rf rules.RulesFile) interface{} { path, _ := rules.GetPath() var rulesPath string if _, statErr := os.Stat(path); statErr == nil { diff --git a/packages/finicky-ui/package-lock.json b/packages/finicky-ui/package-lock.json index b7d8ef4..9e69c55 100644 --- a/packages/finicky-ui/package-lock.json +++ b/packages/finicky-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "finicky-ui", "version": "0.0.0", "dependencies": { + "clsx": "^2.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.0" @@ -1332,6 +1333,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1772,7 +1782,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/packages/finicky-ui/package.json b/packages/finicky-ui/package.json index c221c1e..3a69d31 100644 --- a/packages/finicky-ui/package.json +++ b/packages/finicky-ui/package.json @@ -11,13 +11,14 @@ "check": "tsc -b" }, "devDependencies": { - "@vitejs/plugin-react": "^4.5.0", "@types/react": "^18.3.20", "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.5.0", "typescript": "~5.7.2", "vite": "^6.3.6" }, "dependencies": { + "clsx": "^2.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.30.0" diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.tsx b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx index 394eff8..24a1fac 100644 --- a/packages/finicky-ui/src/components/BrowserProfileSelector.tsx +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { XIcon } from "./icons/X"; import type { BrowserProfile, BrowserOptions, BrowserProfileCustom } from "../types"; import styles from "./BrowserProfileSelector.module.css"; @@ -47,7 +48,7 @@ function BrowserField({ browser, isCustom, required, disabled, placeholder, inst ); } return ( -
+
{ if (e.key === "Enter" || e.key === " ") { diff --git a/packages/finicky-ui/src/components/PageContainer.tsx b/packages/finicky-ui/src/components/PageContainer.tsx index 5056347..35f5b10 100644 --- a/packages/finicky-ui/src/components/PageContainer.tsx +++ b/packages/finicky-ui/src/components/PageContainer.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import clsx from "clsx"; import styles from "./PageContainer.module.css"; interface Props { @@ -10,7 +11,7 @@ interface Props { export function PageContainer({ children, center, title, description }: Props) { return ( -
+
{title && (

{title}

diff --git a/packages/finicky-ui/src/components/TabBar.tsx b/packages/finicky-ui/src/components/TabBar.tsx index 96479ca..f5578ef 100644 --- a/packages/finicky-ui/src/components/TabBar.tsx +++ b/packages/finicky-ui/src/components/TabBar.tsx @@ -1,5 +1,6 @@ import { useSyncExternalStore } from "react"; import { Link, useLocation } from "react-router-dom"; +import clsx from "clsx"; import { appStore } from "../lib/appStore"; import styles from "./TabBar.module.css"; @@ -31,7 +32,7 @@ export function TabBar({ tabs, bottomTabs }: Props) {