diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 0000000..9892eda --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// App struct +type App struct { + ctx context.Context + port int + debug bool +} + +// NewApp creates a new App struct +func NewApp(debug bool) *App { + return &App{ + debug: debug, + } +} + +func (a *App) logDebug(format string, args ...any) { + if a != nil && a.debug { + fmt.Printf("[debug] "+format+"\n", args...) + } +} + +// logHandler wraps HTTP handlers to log requests when debug is enabled. +func (a *App) logHandler(next http.Handler) http.Handler { + if !a.debug { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + fmt.Printf("[debug] http %s %s %dms\n", r.Method, r.URL.Path, time.Since(start).Milliseconds()) + }) +} + +// startup is called when the app starts. The context is saved +// so we can call the runtime methods +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.logDebug("startup begin") + + // Start local API server on a random port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Printf("Error starting local server: %v\n", err) + return + } + + a.port = listener.Addr().(*net.TCPAddr).Port + a.logDebug("local API listener bound on %s", listener.Addr().String()) + + mux := http.NewServeMux() + + // CORS middleware for local desktop API + cors := func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + } + } + + mux.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok"}`) + })) + + server := &http.Server{ + Addr: listener.Addr().String(), + Handler: a.logHandler(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + fmt.Printf("Error serving desktop API: %v\n", err) + } + }() + fmt.Printf("Desktop API server started on port %d\n", a.port) + a.logDebug("startup complete") +} + +// GetAPIPort returns the port the local server is running on +func (a *App) GetAPIPort() int { + a.logDebug("GetAPIPort -> %d", a.port) + return a.port +} + +// LogFrontend allows the frontend to log to the terminal +func (a *App) LogFrontend(message string) { + fmt.Printf("[frontend] %s\n", message) +} + +// SaveFile shows a save dialog and writes the content to the selected file +func (a *App) SaveFile(filename string, content string) error { + a.logDebug("SaveFile filename=%s", filename) + filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + DefaultFilename: filename, + Title: "Save Graph", + Filters: []runtime.FileFilter{ + { + DisplayName: "JSON Files (*.json)", + Pattern: "*.json", + }, + }, + }) + if err != nil { + return err + } + if filePath == "" { + return nil // Cancelled + } + + return os.WriteFile(filePath, []byte(content), 0644) +} + +// LoadFile shows an open dialog and returns the content of the selected file +func (a *App) LoadFile() (string, error) { + a.logDebug("LoadFile") + filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Open Graph", + Filters: []runtime.FileFilter{ + { + DisplayName: "JSON Files (*.json)", + Pattern: "*.json", + }, + }, + }) + if err != nil { + return "", err + } + if filePath == "" { + return "", nil // Cancelled + } + + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return string(content), nil +} + diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..f2ee1ae --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "embed" + "os" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend_dist +var assets embed.FS + +func debugEnabled() bool { + for _, arg := range os.Args[1:] { + if arg == "--debug" || arg == "-d" { + return true + } + } + return false +} + +func main() { + debug := debugEnabled() + if debug { + println("Debug logging enabled") + } + + // Create an instance of the app structure + app := NewApp(debug) + + // Create application with options + err := wails.Run(&options.App{ + Title: "Linking Tool", + Width: 1280, + Height: 800, + AssetServer: &assetserver.Options{ + Assets: assets, + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + OnStartup: app.startup, + Bind: []interface{}{ + app, + }, + EnableDefaultContextMenu: true, + }) + + if err != nil { + println("Error:", err.Error()) + } +} + diff --git a/main.go b/main.go new file mode 100644 index 0000000..91cf10f --- /dev/null +++ b/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "embed" + "flag" + "io/fs" + "log" + "net" + "net/http" + "os" + "strings" + "time" +) + +//go:embed build/* +var buildAssets embed.FS + +func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + next.ServeHTTP(w, r) + return + } + + allowed := false + if len(allowedOrigins) == 0 { + allowed = true + } else { + for _, o := range allowedOrigins { + if o == "*" || o == origin { + allowed = true + break + } + } + } + + if allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + } + + if r.Method == "OPTIONS" { + if allowed { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusForbidden) + } + return + } + + if !allowed && len(allowedOrigins) > 0 { + log.Printf("Blocked CORS request from origin: %s", origin) + http.Error(w, "CORS Origin Not Allowed", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + } + } +} + +func main() { + frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)") + host := flag.String("host", "0.0.0.0", "Host to bind the server to") + port := flag.String("port", "", "Port to listen on (overrides PORT env var)") + allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins") + + flag.Parse() + + var allowedOrigins []string + if *allowedOriginsStr != "" { + origins := strings.Split(*allowedOriginsStr, ",") + for _, o := range origins { + allowedOrigins = append(allowedOrigins, strings.TrimSpace(o)) + } + } + + if *port == "" { + *port = os.Getenv("PORT") + if *port == "" { + *port = "8080" + } + } + + // Middleware chains + cors := corsMiddleware(allowedOrigins) + + http.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + })) + + // Static Assets + var staticFS fs.FS + if *frontendPath != "" { + log.Printf("Using custom frontend from: %s\n", *frontendPath) + staticFS = os.DirFS(*frontendPath) + } else { + sub, err := fs.Sub(buildAssets, "build") + if err != nil { + log.Fatal(err) + } + staticFS = sub + } + + fileServer := http.FileServer(http.FS(staticFS)) + + // SPA Handler + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + + _, err := staticFS.Open(path) + if err != nil { + // If file doesn't exist, serve index.html for SPA routing + r.URL.Path = "/" + } + fileServer.ServeHTTP(w, r) + }) + + addr := net.JoinHostPort(*host, *port) + log.Printf("Linking Tool server starting on %s...\n", addr) + + server := &http.Server{ + Addr: addr, + Handler: nil, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} +