Sfoglia il codice sorgente

Merge pull request #1552 from binwiederhier/windows-server

Support "ntfy serve" on Windows
Philipp C. Heckel 1 mese fa
parent
commit
603273ab9d

+ 8 - 6
.goreleaser.yml

@@ -48,13 +48,15 @@ builds:
   - id: ntfy_windows_amd64
     binary: ntfy
     env:
-      - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
-    tags: [ noserver ] # don't include server files
+      - CGO_ENABLED=1 # required for go-sqlite3
+      - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
+    tags: [ sqlite_omit_load_extension,osusergo,netgo ]
     ldflags:
-      - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
+      - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
     goos: [ windows ]
-    goarch: [ amd64 ]
-  - id: ntfy_darwin_all
+    goarch: [amd64 ]
+  -
+    id: ntfy_darwin_all
     binary: ntfy
     env:
       - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
@@ -201,4 +203,4 @@ docker_manifests:
       - *amd64_image
       - *arm64v8_image
       - *armv7_image
-      - *armv6_image
+      - *armv6_image

+ 16 - 1
Makefile

@@ -31,6 +31,7 @@ help:
 	@echo "Build server & client (without GoReleaser):"
 	@echo "  make cli-linux-server           - Build client & server (no GoReleaser, current arch, Linux)"
 	@echo "  make cli-darwin-server          - Build client & server (no GoReleaser, current arch, macOS)"
+	@echo "  make cli-windows-server         - Build client & server (no GoReleaser, amd64 only, Windows)"
 	@echo "  make cli-client                 - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
 	@echo
 	@echo "Build dev Docker:"
@@ -106,6 +107,7 @@ build-deps-ubuntu:
 		curl \
 		gcc-aarch64-linux-gnu \
 		gcc-arm-linux-gnueabi \
+		gcc-mingw-w64-x86-64 \
 		python3 \
 		python3-venv \
 		jq
@@ -201,6 +203,16 @@ cli-darwin-server: cli-deps-static-sites
 		-ldflags \
 		"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
 
+cli-windows-server: cli-deps-static-sites
+	# This is a target to build the CLI (including the server) for Windows.
+	# Use this for Windows development, if you really don't want to install GoReleaser ...
+	mkdir -p dist/ntfy_windows_server server/docs
+	CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
+		-o dist/ntfy_windows_server/ntfy.exe \
+		-tags sqlite_omit_load_extension,osusergo,netgo \
+		-ldflags \
+		"-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
+
 cli-client: cli-deps-static-sites
 	# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
 	# Use this for development, if you really don't want to install GoReleaser ...
@@ -213,7 +225,7 @@ cli-client: cli-deps-static-sites
 
 cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
 
-cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
+cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
 
 cli-deps-static-sites:
 	mkdir -p server/docs server/site
@@ -228,6 +240,9 @@ cli-deps-gcc-armv6-armv7:
 cli-deps-gcc-arm64:
 	which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
 
+cli-deps-gcc-windows:
+	which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
+
 cli-deps-update:
 	go get -u
 	go install honnef.co/go/tools/cmd/staticcheck@latest

+ 3 - 0
client/config.go

@@ -11,6 +11,9 @@ const (
 	DefaultBaseURL = "https://ntfy.sh"
 )
 
+// DefaultConfigFile is the default path to the client config file (set in config_*.go)
+var DefaultConfigFile string
+
 // Config is the config struct for a Client
 type Config struct {
 	DefaultHost     string      `yaml:"default-host"`

+ 18 - 0
client/config_darwin.go

@@ -0,0 +1,18 @@
+//go:build darwin
+
+package client
+
+import (
+	"os"
+	"os/user"
+	"path/filepath"
+)
+
+func init() {
+	u, err := user.Current()
+	if err == nil && u.Uid == "0" {
+		DefaultConfigFile = "/etc/ntfy/client.yml"
+	} else if configDir, err := os.UserConfigDir(); err == nil {
+		DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+	}
+}

+ 18 - 0
client/config_unix.go

@@ -0,0 +1,18 @@
+//go:build linux || dragonfly || freebsd || netbsd || openbsd
+
+package client
+
+import (
+	"os"
+	"os/user"
+	"path/filepath"
+)
+
+func init() {
+	u, err := user.Current()
+	if err == nil && u.Uid == "0" {
+		DefaultConfigFile = "/etc/ntfy/client.yml"
+	} else if configDir, err := os.UserConfigDir(); err == nil {
+		DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+	}
+}

+ 14 - 0
client/config_windows.go

@@ -0,0 +1,14 @@
+//go:build windows
+
+package client
+
+import (
+	"os"
+	"path/filepath"
+)
+
+func init() {
+	if configDir, err := os.UserConfigDir(); err == nil {
+		DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
+	}
+}

+ 11 - 41
cmd/serve.go

@@ -10,10 +10,8 @@ import (
 	"net"
 	"net/netip"
 	"net/url"
-	"os"
-	"os/signal"
+	"runtime"
 	"strings"
-	"syscall"
 	"text/template"
 	"time"
 
@@ -350,6 +348,8 @@ func execServe(c *cli.Context) error {
 		return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
 	} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
 		return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
+	} else if runtime.GOOS == "windows" && listenUnix != "" {
+		return errors.New("listen-unix is not supported on Windows")
 	}
 
 	// Backwards compatibility
@@ -503,6 +503,14 @@ func execServe(c *cli.Context) error {
 	conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
 	conf.Version = c.App.Version
 
+	// Check if we should run as a Windows service
+	if ranAsService, err := maybeRunAsService(conf); err != nil {
+		log.Fatal("%s", err.Error())
+	} else if ranAsService {
+		log.Info("Exiting.")
+		return nil
+	}
+
 	// Set up hot-reloading of config
 	go sigHandlerConfigReload(config)
 
@@ -517,22 +525,6 @@ func execServe(c *cli.Context) error {
 	return nil
 }
 
-func sigHandlerConfigReload(config string) {
-	sigs := make(chan os.Signal, 1)
-	signal.Notify(sigs, syscall.SIGHUP)
-	for range sigs {
-		log.Info("Partially hot reloading configuration ...")
-		inputSource, err := newYamlSourceFromFile(config, flagsServe)
-		if err != nil {
-			log.Warn("Hot reload failed: %s", err.Error())
-			continue
-		}
-		if err := reloadLogLevel(inputSource); err != nil {
-			log.Warn("Reloading log level failed: %s", err.Error())
-		}
-	}
-}
-
 func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
 	// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
 	prefix, err := netip.ParsePrefix(host)
@@ -663,25 +655,3 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
 	}
 	return tokens, nil
 }
-
-func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
-	newLevelStr, err := inputSource.String("log-level")
-	if err != nil {
-		return fmt.Errorf("cannot load log level: %s", err.Error())
-	}
-	overrides, err := inputSource.StringSlice("log-level-overrides")
-	if err != nil {
-		return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
-	}
-	log.ResetLevelOverrides()
-	if err := applyLogLevelOverrides(overrides); err != nil {
-		return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
-	}
-	log.SetLevel(log.ToLevel(newLevelStr))
-	if len(overrides) > 0 {
-		log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
-	} else {
-		log.Info("Log level is %v", strings.ToUpper(newLevelStr))
-	}
-	return nil
-}

+ 55 - 0
cmd/serve_unix.go

@@ -0,0 +1,55 @@
+//go:build linux || dragonfly || freebsd || netbsd || openbsd
+
+package cmd
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/urfave/cli/v2/altsrc"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/server"
+)
+
+func sigHandlerConfigReload(config string) {
+	sigs := make(chan os.Signal, 1)
+	signal.Notify(sigs, syscall.SIGHUP)
+	for range sigs {
+		log.Info("Partially hot reloading configuration ...")
+		inputSource, err := newYamlSourceFromFile(config, flagsServe)
+		if err != nil {
+			log.Warn("Hot reload failed: %s", err.Error())
+			continue
+		}
+		if err := reloadLogLevel(inputSource); err != nil {
+			log.Warn("Reloading log level failed: %s", err.Error())
+		}
+	}
+}
+
+func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
+	newLevelStr, err := inputSource.String("log-level")
+	if err != nil {
+		return err
+	}
+	overrides, err := inputSource.StringSlice("log-level-overrides")
+	if err != nil {
+		return err
+	}
+	log.ResetLevelOverrides()
+	if err := applyLogLevelOverrides(overrides); err != nil {
+		return err
+	}
+	log.SetLevel(log.ToLevel(newLevelStr))
+	if len(overrides) > 0 {
+		log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
+	} else {
+		log.Info("Log level is %v", newLevelStr)
+	}
+	return nil
+}
+
+func maybeRunAsService(conf *server.Config) (bool, error) {
+	return false, nil
+}

+ 100 - 0
cmd/serve_windows.go

@@ -0,0 +1,100 @@
+//go:build windows && !noserver
+
+package cmd
+
+import (
+	"fmt"
+	"sync"
+
+	"golang.org/x/sys/windows/svc"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/server"
+)
+
+const serviceName = "ntfy"
+
+// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
+// Windows users can restart the service to reload configuration.
+func sigHandlerConfigReload(config string) {
+	log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
+}
+
+// runAsWindowsService runs the ntfy server as a Windows service
+func runAsWindowsService(conf *server.Config) error {
+	return svc.Run(serviceName, &windowsService{conf: conf})
+}
+
+// windowsService implements the svc.Handler interface
+type windowsService struct {
+	conf   *server.Config
+	server *server.Server
+	mu     sync.Mutex
+}
+
+// Execute is the main entry point for the Windows service
+func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
+	const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
+	status <- svc.Status{State: svc.StartPending}
+
+	// Create and start the server
+	var err error
+	s.mu.Lock()
+	s.server, err = server.New(s.conf)
+	s.mu.Unlock()
+	if err != nil {
+		log.Error("Failed to create server: %s", err.Error())
+		return true, 1
+	}
+
+	// Start server in a goroutine
+	serverErrChan := make(chan error, 1)
+	go func() {
+		serverErrChan <- s.server.Run()
+	}()
+
+	status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
+	log.Info("Windows service started")
+
+	for {
+		select {
+		case err := <-serverErrChan:
+			if err != nil {
+				log.Error("Server error: %s", err.Error())
+				return true, 1
+			}
+			return false, 0
+		case req := <-requests:
+			switch req.Cmd {
+			case svc.Interrogate:
+				status <- req.CurrentStatus
+			case svc.Stop, svc.Shutdown:
+				log.Info("Windows service stopping...")
+				status <- svc.Status{State: svc.StopPending}
+				s.mu.Lock()
+				if s.server != nil {
+					s.server.Stop()
+				}
+				s.mu.Unlock()
+				return false, 0
+			default:
+				log.Warn("Unexpected service control request: %d", req.Cmd)
+			}
+		}
+	}
+}
+
+// maybeRunAsService checks if the process is running as a Windows service,
+// and if so, runs the server as a service. Returns true if it ran as a service.
+func maybeRunAsService(conf *server.Config) (bool, error) {
+	isService, err := svc.IsWindowsService()
+	if err != nil {
+		return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
+	} else if !isService {
+		return false, nil
+	}
+	log.Info("Running as Windows service")
+	if err := runAsWindowsService(conf); err != nil {
+		return true, fmt.Errorf("failed to run as Windows service: %w", err)
+	}
+	return true, nil
+}

+ 9 - 45
cmd/subscribe.go

@@ -3,28 +3,21 @@ package cmd
 import (
 	"errors"
 	"fmt"
-	"github.com/urfave/cli/v2"
-	"heckel.io/ntfy/v2/client"
-	"heckel.io/ntfy/v2/log"
-	"heckel.io/ntfy/v2/util"
 	"os"
 	"os/exec"
-	"os/user"
-	"path/filepath"
 	"sort"
 	"strings"
+
+	"github.com/urfave/cli/v2"
+	"heckel.io/ntfy/v2/client"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/util"
 )
 
 func init() {
 	commands = append(commands, cmdSubscribe)
 }
 
-const (
-	clientRootConfigFileUnixAbsolute    = "/etc/ntfy/client.yml"
-	clientUserConfigFileUnixRelative    = "ntfy/client.yml"
-	clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
-)
-
 var flagsSubscribe = append(
 	append([]cli.Flag{}, flagsDefault...),
 	&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
 	if filename != "" {
 		return client.LoadConfig(filename)
 	}
-	configFile, err := defaultClientConfigFile()
-	if err != nil {
-		log.Warn("Could not determine default client config file: %s", err.Error())
-	} else {
-		if s, _ := os.Stat(configFile); s != nil {
-			return client.LoadConfig(configFile)
+	if client.DefaultConfigFile != "" {
+		if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
+			return client.LoadConfig(client.DefaultConfigFile)
 		}
-		log.Debug("Config file %s not found", configFile)
+		log.Debug("Config file %s not found", client.DefaultConfigFile)
 	}
 	log.Debug("Loading default config")
 	return client.NewConfig(), nil
 }
 
-//lint:ignore U1000 Conditionally used in different builds
-func defaultClientConfigFileUnix() (string, error) {
-	u, err := user.Current()
-	if err != nil {
-		return "", fmt.Errorf("could not determine current user: %w", err)
-	}
-	configFile := clientRootConfigFileUnixAbsolute
-	if u.Uid != "0" {
-		homeDir, err := os.UserConfigDir()
-		if err != nil {
-			return "", fmt.Errorf("could not determine user config dir: %w", err)
-		}
-		return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
-	}
-	return configFile, nil
-}
-
-//lint:ignore U1000 Conditionally used in different builds
-func defaultClientConfigFileWindows() (string, error) {
-	homeDir, err := os.UserConfigDir()
-	if err != nil {
-		return "", fmt.Errorf("could not determine user config dir: %w", err)
-	}
-	return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
-}
-
 func logMessagePrefix(m *client.Message) string {
 	return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
 }

+ 2 - 4
cmd/subscribe_darwin.go

@@ -1,3 +1,5 @@
+//go:build darwin
+
 package cmd
 
 const (
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
 var (
 	scriptLauncher = []string{"sh", "-c"}
 )
-
-func defaultClientConfigFile() (string, error) {
-	return defaultClientConfigFileUnix()
-}

+ 0 - 4
cmd/subscribe_unix.go

@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
 var (
 	scriptLauncher = []string{"sh", "-c"}
 )
-
-func defaultClientConfigFile() (string, error) {
-	return defaultClientConfigFileUnix()
-}

+ 2 - 4
cmd/subscribe_windows.go

@@ -1,3 +1,5 @@
+//go:build windows
+
 package cmd
 
 const (
@@ -9,7 +11,3 @@ const (
 var (
 	scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
 )
-
-func defaultClientConfigFile() (string, error) {
-	return defaultClientConfigFileWindows()
-}

+ 18 - 8
docs/install.md

@@ -228,19 +228,29 @@ brew install ntfy
 ```
 
 ## Windows
-The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
-To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
+The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
+To install, you can either
+
+* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
 extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 
+* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
 
-The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
+Once installed, you can run the ntfy CLI commands like so:
 
-Also available in [Scoop's](https://scoop.sh) Main repository:
+```
+ntfy.exe -h
+```
 
-`scoop install ntfy`
+The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
+for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
 
-!!! info
-    There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
-    [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
+To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
+elevated command prompt (adjust the path to `ntfy.exe` accordingly):
+
+```
+sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
+sc start ntfy
+```
 
 ## Docker
 The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should 

+ 2 - 1
docs/releases.md

@@ -1605,7 +1605,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 * Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
   [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
-* Support for a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) 
+* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) 
+* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
 
 ### ntfy Android app v1.22.x (UNRELEASED)
 

+ 1 - 1
go.mod

@@ -35,6 +35,7 @@ require (
 	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/prometheus/client_golang v1.23.2
 	github.com/stripe/stripe-go/v74 v74.30.0
+	golang.org/x/sys v0.40.0
 	golang.org/x/text v0.33.0
 )
 
@@ -93,7 +94,6 @@ require (
 	go.opentelemetry.io/otel/trace v1.39.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.3 // indirect
 	golang.org/x/net v0.49.0 // indirect
-	golang.org/x/sys v0.40.0 // indirect
 	google.golang.org/appengine/v2 v2.0.6 // indirect
 	google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect

+ 6 - 2
server/config.go

@@ -12,8 +12,6 @@ import (
 // Defines default config settings (excluding limits, see below)
 const (
 	DefaultListenHTTP                           = ":80"
-	DefaultConfigFile                           = "/etc/ntfy/server.yml"
-	DefaultTemplateDir                          = "/etc/ntfy/templates"
 	DefaultCacheDuration                        = 12 * time.Hour
 	DefaultCacheBatchTimeout                    = time.Duration(0)
 	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
@@ -27,6 +25,12 @@ const (
 	DefaultStripePriceCacheDuration             = 3 * time.Hour    // Time to keep Stripe prices cached in memory before a refresh is needed
 )
 
+// Platform-specific default paths (set in config_unix.go or config_windows.go)
+var (
+	DefaultConfigFile  string
+	DefaultTemplateDir string
+)
+
 // Defines default Web Push settings
 const (
 	DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour

+ 8 - 0
server/config_unix.go

@@ -0,0 +1,8 @@
+//go:build !windows
+
+package server
+
+func init() {
+	DefaultConfigFile = "/etc/ntfy/server.yml"
+	DefaultTemplateDir = "/etc/ntfy/templates"
+}

+ 17 - 0
server/config_windows.go

@@ -0,0 +1,17 @@
+//go:build windows
+
+package server
+
+import (
+	"os"
+	"path/filepath"
+)
+
+func init() {
+	programData := os.Getenv("ProgramData")
+	if programData == "" {
+		programData = `C:\ProgramData`
+	}
+	DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
+	DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
+}