// Package main is the entry point for wis-free-v3, a voice dictation application // that provides global hotkey-triggered audio recording and transcription. package main import ( "embed" "os" "path/filepath" "runtime" "strconv" "wis-free-v3/internal/logger" "wis-free-v3/internal/platform" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" ) // Application constants const ( appName = "wis-free-v3" appTitle = "wis-free-v3 Settings" appWidth = 800 appHeight = 700 lockFile = "wis-free-v3.lock" configDir = ".wis-free-v3" ) // Background color for the application window (matches new deep dark theme #0b0f1a) var windowBackground = &options.RGBA{R: 11, G: 15, B: 26, A: 255} //go:embed all:frontend/dist var assets embed.FS // Global lock file handle for single instance management var instanceLock *os.File // AppVersion is set at link time by scripts/build.bat and scripts/build-linux.sh // from scripts/VERSION. Default is used for plain `go build` / `wails build`. var AppVersion = "dev" func main() { // systray's package init calls runtime.LockOSThread() on the program's startup // thread. Undo that so the main goroutine is not permanently bound; the tray // goroutine locks itself in tray.Start instead (see internal/ui/tray/tray.go). runtime.UnlockOSThread() // Ensure only one instance of the application is running if !acquireInstanceLock() { os.Exit(0) } // Initialize the application app := NewApp() // Configure and run the Wails application err := wails.Run(&options.App{ Title: appTitle, Width: appWidth, Height: appHeight, AssetServer: &assetserver.Options{Assets: assets}, BackgroundColour: windowBackground, OnStartup: app.startup, OnShutdown: app.Shutdown, OnBeforeClose: app.beforeClose, // On Linux, starting hidden makes it look like "nothing happens" if the tray icon // fails to initialize (missing libayatana-appindicator, etc) or is hidden by the DE. StartHidden: runtime.GOOS == "windows", Bind: []interface{}{app}, }) if err != nil { logger.Error("Application error: %v", err) } // Clean up resources on exit releaseInstanceLock() } // acquireInstanceLock attempts to acquire an exclusive lock to prevent multiple instances. // It stores the current process ID in the lock file and checks if any existing lock // belongs to a still-running process. // // Returns true if the lock was acquired successfully, false if another instance is running. func acquireInstanceLock() bool { lockPath, err := getLockPath() if err != nil { logger.Error("Failed to get lock path: %v", err) return true // Allow running if we can't determine the path } // Ensure the config directory exists if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { logger.Error("Failed to create config directory: %v", err) return true } // Check for existing lock file if data, err := os.ReadFile(lockPath); err == nil { if pid, err := strconv.Atoi(string(data)); err == nil { if platform.IsProcessRunning(pid) { logger.Info("Another instance is already running (PID: %d)", pid) return false } } // Stale lock file from a crashed process - remove it os.Remove(lockPath) } // Create new lock file with our PID instanceLock, err = os.OpenFile(lockPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { logger.Error("Failed to create lock file: %v", err) return true } // Write current process ID pid := os.Getpid() if _, err := instanceLock.WriteString(strconv.Itoa(pid)); err != nil { logger.Error("Failed to write PID to lock file: %v", err) } instanceLock.Sync() logger.Info("Instance lock acquired (PID: %d)", pid) return true } // releaseInstanceLock removes the lock file and closes the file handle. func releaseInstanceLock() { if instanceLock != nil { instanceLock.Close() instanceLock = nil } if lockPath, err := getLockPath(); err == nil { os.Remove(lockPath) logger.Info("Instance lock released") } } // getLockPath returns the full path to the instance lock file. func getLockPath() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(homeDir, configDir, lockFile), nil }