diff --git a/.gitignore b/.gitignore index 1ff93c9a..352dab25 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/config/vm.go b/apps/finicky/src/config/vm.go index 3383dd8d..344f3497 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/logger/logger.go b/apps/finicky/src/logger/logger.go index 324bc281..17816eea 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 829a8f27..37c6f137 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" @@ -45,10 +46,20 @@ 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"` +} + +// 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) @@ -62,8 +73,95 @@ var dryRun bool = false var skipJSConfig bool = false var updateInfo UpdateInfo var configInfo *ConfigInfo +var lastConfigPayload 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() 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 interface{}) { + stateMu.Lock() + lastConfigPayload = payload + stateMu.Unlock() +} + +func getShouldKeepRunning() bool { + stateMu.Lock() + defer stateMu.Unlock() + 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 + stateMu.Unlock() +} + func main() { startTime := time.Now() logger.Setup() @@ -123,29 +221,62 @@ 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)) 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(getVM(), 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 getConfigPayload() } - // Set up rules save handler. + window.GetUpdateInfoFunc = func() interface{} { + ui := getUpdateInfo() + if ui.ReleaseInfo == nil && !ui.UpdateCheckEnabled { + return nil + } + return buildUpdateInfoPayload(ui) + } + + // 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 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) + setShouldKeepRunning(true) return } script, err := rules.ToJSConfigScript(rf, namespace) @@ -158,24 +289,28 @@ 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() } } } + 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 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 } @@ -190,7 +325,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 { @@ -202,7 +337,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 @@ -210,20 +345,27 @@ func main() { case <-configChange: startTime := time.Now() - var setupErr error slog.Debug("Config has changed") - vm, setupErr = setupVM(cfw, namespace) + vmBuildMu.Lock() + newVM, setupErr := setupVM(cfw, namespace) 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 vm != nil { - shouldKeepRunning = vm.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 { @@ -237,7 +379,7 @@ func main() { updateChan = time.After(oneDay) case <-windowClosed: - if !shouldKeepRunning { + if !getShouldKeepRunning() { slog.Info("Exiting due to window closed") tearDown() } else { @@ -252,10 +394,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) { @@ -299,32 +441,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 +457,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 @@ -355,8 +466,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 @@ -365,8 +476,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) @@ -374,31 +485,33 @@ 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) } - if updateInfo.ReleaseInfo != nil { - window.SendMessageToWebView("updateInfo", 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": "", - }) + window.BroadcastSSE("updateInfo", buildUpdateInfoPayload(ui)) +} + +func buildUpdateInfoPayload(ui UpdateInfo) map[string]interface{} { + if ui.ReleaseInfo != nil { + return map[string]interface{}{ + "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": ui.UpdateCheckEnabled, + "downloadUrl": "", "releaseUrl": "", } } @@ -468,8 +581,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, @@ -480,19 +594,22 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error opts := newVM.GetAllConfigOptions() logRequests = opts.LogRequests - window.SendMessageToWebView("config", map[string]interface{}{ - "handlers": configInfo.Handlers, - "rewrites": configInfo.Rewrites, - "defaultBrowser": configInfo.DefaultBrowser, - "configPath": util.ShortenPath(configInfo.ConfigPath), - "isJSConfig": newVM.IsJSConfig(), - "options": map[string]interface{}{ - "keepRunning": opts.KeepRunning, - "hideIcon": opts.HideIcon, - "logRequests": opts.LogRequests, - "checkForUpdates": opts.CheckForUpdates, - }, - }) + // publishConfigInfo keeps the previously published ConfigInfo when cs + // (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) + 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) return newVM, nil } diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 30909ec4..33dbb654 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 00000000..0336b9b7 --- /dev/null +++ b/apps/finicky/src/window/server.go @@ -0,0 +1,324 @@ +package window + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "finicky/browser" + "finicky/rules" + "finicky/util" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "sync" +) + +var ( + apiPort int + apiToken string + hub = newSSEHub() + GetVersionFunc func() string + GetConfigFunc func() interface{} + GetUpdateInfoFunc func() interface{} + 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) +} + +// 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{} + 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{}), + lastByEvent: make(map[string]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{}{} + 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) { + 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() + 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: + 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(authMiddleware(mux))) //nolint:errcheck + return nil +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 + } + next.ServeHTTP(w, r) + }) +} + +// 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 { + 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)) + // 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 { + SaveRulesHandler(rf) + } + writeJSON(w, rulesResponseFor(rf)) +} + +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) + backlog := hub.registerWithBacklog(ch) + defer hub.unregister(ch) + + for _, msg := range backlog { + fmt.Fprint(w, msg) + } + flusher.Flush() + + 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{}{}} + } + 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 { + 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 1119946a..cb6ac241 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,18 @@ 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) +//export GetAPIPort +func GetAPIPort() C.int { + return C.int(apiPort) } -func handleGetBrowserProfiles(msg map[string]interface{}) { - browserName, _ := msg["browser"].(string) - profiles := browser.GetProfilesForBrowser(browserName) - SendMessageToWebView("browserProfiles", map[string]interface{}{ - "browser": browserName, - "profiles": profiles, - }) +//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 dc7b90d0..3cf52e61 100644 --- a/apps/finicky/src/window/window.h +++ b/apps/finicky/src/window/window.h @@ -4,20 +4,19 @@ #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); +extern char* GetAPIToken(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 b407acc8..837c3ed8 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,30 @@ - (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 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(); + 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 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 +102,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 +109,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 +139,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 +147,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 +173,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 +185,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 +215,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 +263,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 5e43fde4..96734836 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 f2d07ae6..9e69c55f 100644 --- a/packages/finicky-ui/package-lock.json +++ b/packages/finicky-ui/package-lock.json @@ -8,17 +8,302 @@ "name": "finicky-ui", "version": "0.0.0", "dependencies": { - "svelte-routing": "^2.13.0" + "clsx": "^2.1.1", + "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 +796,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 +1162,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,73 +1214,148 @@ "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/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, + "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/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "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", + "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", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1011,22 +1374,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 +1423,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 +1466,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 +1556,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 +1616,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" }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=14.0.0" + }, + "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 +1730,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 +1759,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", @@ -1415,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" @@ -1424,6 +1790,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 +1897,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 c79e1665..3a69d31f 100644 --- a/packages/finicky-ui/package.json +++ b/packages/finicky-ui/package.json @@ -5,20 +5,22 @@ "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", + "@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": { - "svelte-routing": "^2.13.0" - }, - "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" + "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/App.module.css b/packages/finicky-ui/src/App.module.css new file mode 100644 index 00000000..c44e48d3 --- /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 9578c49c..00000000 --- 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 00000000..e51178d2 --- /dev/null +++ b/packages/finicky-ui/src/App.tsx @@ -0,0 +1,93 @@ +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 { toast } from "./lib/toast"; +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(() => { + 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[]) ?? [], + rulesFile: (data.rules as any) ?? { defaultBrowser: "", rules: [] }, + ...(data.config ? { hasConfig: true, config: data.config as any } : {}), + ...(data.updateInfo ? { updateInfo: data.updateInfo as any } : {}), + }); + } 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.module.css b/packages/finicky-ui/src/components/BrowserProfileSelector.module.css new file mode 100644 index 00000000..dc95d428 --- /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 61f158d1..00000000 --- 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 00000000..24a1facd --- /dev/null +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.tsx @@ -0,0 +1,159 @@ +import clsx from "clsx"; +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 || custom.profile) && ( + 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 00000000..6d79f18a --- /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 00000000..f965c340 --- /dev/null +++ b/packages/finicky-ui/src/components/Footer.tsx @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from "react"; +import clsx from "clsx"; +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 00000000..73b5f712 --- /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 421e5f6b..00000000 --- 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 00000000..26e06946 --- /dev/null +++ b/packages/finicky-ui/src/components/LogContent.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +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 00000000..0c0a47b6 --- /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 6db94e24..00000000 --- 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 00000000..1cb37e15 --- /dev/null +++ b/packages/finicky-ui/src/components/OptionRow.tsx @@ -0,0 +1,75 @@ +import clsx from "clsx"; +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) { + // A - - - {/snippet} - - - - - diff --git a/packages/finicky-ui/src/pages/LogViewer.tsx b/packages/finicky-ui/src/pages/LogViewer.tsx new file mode 100644 index 00000000..406ca321 --- /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 00000000..a8b573c9 --- /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 ccc97c55..00000000 --- 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 00000000..881ad9a7 --- /dev/null +++ b/packages/finicky-ui/src/pages/Rules.tsx @@ -0,0 +1,322 @@ +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; +import clsx from "clsx"; +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) { + const missing = [...new Set(rules.map((r) => r.browser).filter((b) => b && browsers.profiles[b] === undefined))]; + await Promise.all( + missing.map(async (b) => { + try { + appStore.addBrowserProfiles(b, await api.getBrowserProfiles(b)); + } 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={clsx(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([]); + + // save()/scheduleSave() can flush immediately (synchronously, in the same + // tick as the setRules() call below it), before React has re-rendered and + // produced a fresh `rules` value. rulesRef is updated in lockstep with + // every setRules() call so useRulesSave always reads the latest rules + // regardless of render timing, instead of a stale pre-edit closure. + const rulesRef = useRef(rules); + function updateRules(next: Rule[] | ((prev: Rule[]) => Rule[])) { + const resolved = typeof next === "function" ? (next as (prev: Rule[]) => Rule[])(rulesRef.current) : next; + rulesRef.current = resolved; + setRules(resolved); + } + + const { save, scheduleSave, isPending } = useRulesSave( + () => ({ ...rulesFile, rules: rulesRef.current }), + SAVE_DEBOUNCE + ); + const drag = useDragSort( + (from, to) => { + const move = (arr: T[]) => { const a = [...arr]; a.splice(to, 0, ...a.splice(from, 1)); return a; }; + updateRules(move); + setRowStates(move); + }, + scheduleSave + ); + const focus = usePendingFocus(); + + // The installed-browser list only otherwise comes from the one-time + // /api/initial-data fetch at app launch, and the WebView page is never + // reloaded for the life of the process, so without this a browser + // installed while Finicky is running would never show up here. + useEffect(() => { + api.getBrowsers().then((installed) => appStore.update({ installedBrowsers: installed })).catch(() => {}); + }, []); + + useEffect(() => { + if (isPending.current) return; + const newRules = normalizeRules(rulesFile.rules); + updateRules(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; + updateRules(rules.map((r, idx) => (idx === i ? { ...r, match: newMatch } : r))); + scheduleSave(); + } + + function addPattern(i: number) { + focus.set(i, rules[i].match.length); + updateRules(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); + updateRules(rules.map((r, idx) => (idx === i ? { ...r, match: newMatch.length > 0 ? newMatch : [""] } : r))); + save(); + } + + function addRule() { + focus.set(rules.length, 0); + updateRules([...rules, { match: [""], browser: "", profile: "" }]); + setRowStates((prev) => [...prev, { browser: false, profile: false }]); + } + + function removeRule(i: number) { + updateRules(rules.filter((_, idx) => idx !== i)); + setRowStates((prev) => prev.filter((_, idx) => idx !== i)); + save(); + } + + function handleChange(i: number, { browser, profile }: BrowserProfile, custom: BrowserProfileCustom) { + updateRules(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 00000000..81e5c34b --- /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 30582bfb..00000000 --- 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 00000000..f4727afd --- /dev/null +++ b/packages/finicky-ui/src/pages/StartPage.tsx @@ -0,0 +1,222 @@ +import { useState, useEffect, useRef, useSyncExternalStore } from "react"; +import { useRulesSave } from "../lib/useRulesSave"; +import { Link } from "react-router-dom"; +import clsx from "clsx"; +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 }; + + // The installed-browser list only otherwise comes from the one-time + // /api/initial-data fetch at app launch, and the WebView page is never + // reloaded for the life of the process, so without this a browser + // installed while Finicky is running would never show up here. + useEffect(() => { + api.getBrowsers().then((installed) => appStore.update({ installedBrowsers: installed })).catch(() => {}); + }, []); + + const { + save: saveNow, + scheduleSave: scheduleSaveNow, + isPending, + } = useRulesSave( + () => { + const { options, bp, rulesFile } = pendingRef.current; + return { ...rulesFile, defaultBrowser: bp.browser, defaultProfile: bp.profile, options }; + }, + SAVE_DEBOUNCE, + "Failed to save preferences" + ); + + useEffect(() => { + if (isPending.current) return; + setOptions(resolveOptions(rulesFile, config)); + setBp(initialBp(rulesFile, config, hasJsConfig)); + }, [rulesFile, config, hasJsConfig]); + + const defaultBrowserIsCustom = resolveBrowserIsCustom(bp.browserCustom, bp.browser, installedBrowsers); + const defaultProfileIsCustom = resolveProfileIsCustom(bp.profileCustom, bp.profile, bp.browser, profilesByBrowser); + + function save() { if (!hasJsConfig) saveNow(); } + function scheduleSave() { if (!hasJsConfig) scheduleSaveNow(); } + + 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 00000000..0a45ad30 --- /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 95cee7c9..00000000 --- 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 00000000..fc383585 --- /dev/null +++ b/packages/finicky-ui/src/pages/TestUrl.tsx @@ -0,0 +1,149 @@ +import { useState, useEffect } from "react"; +import clsx from "clsx"; +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; + } + + // A slower earlier request can otherwise resolve after a faster later + // one and overwrite its result with stale data for a URL that's no + // longer in the input. `cancelled` guards every state update so only + // the most recent (non-superseded) request's response is ever applied. + let cancelled = false; + let loadingTimer: ReturnType | undefined; + const debounceTimer = setTimeout(async () => { + loadingTimer = setTimeout(() => { + if (!cancelled) setLoading(true); + }, LOADING_DELAY - DEBOUNCE_DELAY); + try { + const testResult = (await api.testUrl(normalizeUrl(testUrl))) as TestUrlResult; + if (!cancelled) setResult(testResult); + } catch { + if (!cancelled) setResult(null); + } finally { + clearTimeout(loadingTimer); + if (!cancelled) setLoading(false); + } + }, DEBOUNCE_DELAY); + + return () => { + cancelled = true; + 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 2eeb589f..c2a8704b 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,20 @@ 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; + __FINICKY_API_TOKEN__?: string; } } @@ -55,7 +66,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 4078e747..11f02fe2 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 de2ddd65..00000000 --- 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 55a2f9b6..00441f86 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 8c5ca74c..1eac326b 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: {