Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ coverage.out
coverage.html
results.sarif
.DS_Store
__debug_bin*
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,25 @@ sudo ./scripts/linux-install-deps.sh ./iptw
```
The script detects your distro (Fedora, RHEL, Debian/Ubuntu, Arch, openSUSE) and installs the correct packages automatically. Pass the path to the `iptw` binary as the first argument so it can verify all libraries resolved correctly after installation.

**Tray icon not visible on GNOME (Wayland)**

Stock GNOME does not ship a StatusNotifierItem host (system tray), so the iptw tray icon will not appear by default. Install the AppIndicator extension to restore it:

- **Fedora:**
```bash
sudo dnf install gnome-shell-extension-appindicator
```
- **Ubuntu / Debian:**
```bash
sudo apt install gnome-shell-extension-appindicator
```
- **From the GNOME Extension website:**
Visit [extensions.gnome.org/extension/615/appindicator-support](https://extensions.gnome.org/extension/615/appindicator-support/) and click the toggle to install.

After installation, enable the extension and restart GNOME Shell (`Alt+F2` → `r` on X11, or log out and back in on Wayland), then re-launch iptw. The globe tray icon will appear in the top bar.

> **Note:** The HTTP map UI (`http://127.0.0.1:<port>/map.html`) works regardless of whether the tray icon is visible — check the terminal output for the exact URL when running with `--foreground`.

**Permission Denied**
- Make the binary executable: `chmod +x iptw`

Expand Down
15 changes: 15 additions & 0 deletions cmd/iptw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"flag"
"fmt"
"log/slog"
"net/http"
_ "net/http/pprof"

"iptw/internal/config"
"iptw/internal/geoip"
Expand All @@ -24,9 +26,11 @@ func main() {
var forceStart bool
var showVersion bool
var foreground bool
var pprofAddr string
flag.BoolVar(&forceStart, "force", false, "Force start even if another instance appears to be running")
flag.BoolVar(&showVersion, "version", false, "Show version information")
flag.BoolVar(&foreground, "foreground", false, "Run in the foreground (keep terminal attached)")
flag.StringVar(&pprofAddr, "pprof", "", "Enable pprof profiling server on the given address (e.g. 127.0.0.1:6060)")
flag.Parse()

// Handle version request
Expand All @@ -42,6 +46,17 @@ func main() {
// the parent exits immediately. This is a no-op on Windows.
maybeDaemonize(foreground)

// Start pprof server if requested.
if pprofAddr != "" {
go func() {
slog.Info("pprof profiling server starting", "addr", pprofAddr,
"hint", "go tool pprof http://"+pprofAddr+"/debug/pprof/profile")
if err := http.ListenAndServe(pprofAddr, nil); err != nil { //nolint:gosec
slog.Error("pprof server stopped", "error", err)
}
}()
}

// On Windows GUI builds, set up file logging immediately so that any
// startup failure is recorded even before the config is read.
if closer := logging.SetupWindowsFileLogger(slog.LevelDebug); closer != nil {
Expand Down
57 changes: 34 additions & 23 deletions internal/background/background.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,31 +81,42 @@ func setMacOSBackground(imagePath string) error {
func setLinuxBackground(imagePath string) error {
slog.Debug("🖼️ Setting Linux desktop background:", "imagePath", imagePath)

// Try different desktop environments
commands := [][]string{
// GNOME/Ubuntu
{"gsettings", "set", "org.gnome.desktop.background", "picture-uri", "file://" + imagePath},
// KDE
{"qdbus", "org.kde.plasmashell", "/PlasmaShell", "org.kde.PlasmaShell.evaluateScript",
fmt.Sprintf(`
var allDesktops = desktops();
for (i=0;i<allDesktops.length;i++) {
d = allDesktops[i];
d.wallpaperPlugin = "org.kde.image";
d.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General");
d.writeConfig("Image", "file://%s");
}`, imagePath)},
// XFCE
{"xfconf-query", "-c", "xfce4-desktop", "-p", "/backdrop/screen0/monitor0/workspace0/last-image", "-s", imagePath},
// Fallback: feh (works with many window managers)
{"feh", "--bg-scale", imagePath},
uri := "file://" + imagePath

// GNOME / Ubuntu: gsettings sets both light and dark wallpaper URIs.
// GNOME 42+ requires picture-uri-dark for dark-mode desktops; the key is
// silently ignored on older releases where it does not exist.
if err := exec.Command("gsettings", "set", "org.gnome.desktop.background", "picture-uri", uri).Run(); err == nil {
// picture-uri succeeded — we're on GNOME. Also update the dark variant.
_ = exec.Command("gsettings", "set", "org.gnome.desktop.background", "picture-uri-dark", uri).Run()
log.Printf("✅ Linux desktop background set successfully using gsettings")
return nil
}

for _, cmd := range commands {
if err := exec.Command(cmd[0], cmd[1:]...).Run(); err == nil {
log.Printf("✅ Linux desktop background set successfully using %s", cmd[0])
return nil
}
// KDE Plasma
kdeScript := fmt.Sprintf(`
var allDesktops = desktops();
for (i=0;i<allDesktops.length;i++) {
d = allDesktops[i];
d.wallpaperPlugin = "org.kde.image";
d.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General");
d.writeConfig("Image", "file://%s");
}`, imagePath)
if err := exec.Command("qdbus", "org.kde.plasmashell", "/PlasmaShell", "org.kde.PlasmaShell.evaluateScript", kdeScript).Run(); err == nil {
log.Printf("✅ Linux desktop background set successfully using qdbus")
return nil
}

// XFCE
if err := exec.Command("xfconf-query", "-c", "xfce4-desktop", "-p", "/backdrop/screen0/monitor0/workspace0/last-image", "-s", imagePath).Run(); err == nil {
log.Printf("✅ Linux desktop background set successfully using xfconf-query")
return nil
}

// Fallback: feh (works with many lightweight window managers)
if err := exec.Command("feh", "--bg-scale", imagePath).Run(); err == nil {
log.Printf("✅ Linux desktop background set successfully using feh")
return nil
}

return fmt.Errorf("failed to set Linux background: no supported desktop environment found")
Expand Down
114 changes: 114 additions & 0 deletions internal/factdb/factdb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Package factdb provides a "Did you know?" fact database of obscure,
// genuinely interesting facts about countries and cities.
//
// Facts are stored at two levels:
// - Country-level: unusual records, historical quirks, counterintuitive
// geography, cultural inversions, little-known firsts.
// - City-level: facts specific to a city or sub-national location.
//
// The seed database is embedded in the binary (seed_facts.json), so facts
// are always available offline with zero network activity. City-level facts
// are tried first; if none exist, a country-level fact is returned.
package factdb

import (
_ "embed"
"encoding/json"
"fmt"
"math/rand"
"sync"
)

//go:embed seed_facts.json
var seedJSON []byte

// seedData is the raw JSON shape of seed_facts.json.
type seedData struct {
Countries map[string][]string `json:"countries"`
Cities map[string][]string `json:"cities"`
Regions map[string][]string `json:"regions"`
}

// Fact is a single "Did you know?" entry.
type Fact struct {
// Text is the fact sentence.
Text string `json:"text"`
// Level is "country", "city", or "region".
Level string `json:"level"`
// Place is the name of the country/city/region this fact is about.
Place string `json:"place"`
}

// IsZero returns true when the Fact has no content.
func (f Fact) IsZero() bool { return f.Text == "" }

// DB is a fact database that returns random obscure facts for countries
// and cities. All data is loaded from the embedded seed JSON at construction
// time and is safe for concurrent use.
type DB struct {
mu sync.RWMutex
data seedData
}

// New creates a DB loaded from the embedded seed_facts.json.
func New() (*DB, error) {
var data seedData
if err := json.Unmarshal(seedJSON, &data); err != nil {
return nil, fmt.Errorf("factdb: failed to parse seed data: %w", err)
}
if data.Countries == nil {
data.Countries = make(map[string][]string)
}
if data.Cities == nil {
data.Cities = make(map[string][]string)
}
if data.Regions == nil {
data.Regions = make(map[string][]string)
}
return &DB{
data: data,
}, nil
}

// GetFact returns a random interesting fact for the given country and,
// optionally, city. The lookup order is: city → country.
// Returns a zero Fact{} if no entry is found.
func (db *DB) GetFact(country, city string) Fact {
db.mu.RLock()
defer db.mu.RUnlock()

if city != "" {
if f, ok := db.pick(db.data.Cities, city); ok {
return Fact{Text: f, Level: "city", Place: city}
}
}
if country != "" {
if f, ok := db.pick(db.data.Countries, country); ok {
return Fact{Text: f, Level: "country", Place: country}
}
}
Comment on lines +73 to +89
return Fact{}
}

// GetCountryFact is a convenience wrapper that ignores city.
func (db *DB) GetCountryFact(country string) Fact {
return db.GetFact(country, "")
}

// CountryCount returns the number of countries with at least one fact.
func (db *DB) CountryCount() int {
db.mu.RLock()
defer db.mu.RUnlock()
return len(db.data.Countries)
}

// pick selects a uniformly random entry from m[key], returning ("", false)
// if the key does not exist or its slice is empty. The global rand source is
// used because it is concurrency-safe (locked internally since Go 1).
func (db *DB) pick(m map[string][]string, key string) (string, bool) {
facts, ok := m[key]
if !ok || len(facts) == 0 {
return "", false
}
return facts[rand.Intn(len(facts))], true //nolint:gosec
}
Comment on lines +108 to +114
Loading
Loading