From 1d4bed415f2623a3ee10b304b97b7fdabafba5b3 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 04:17:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E5=A4=9A=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=A3=80=E6=B5=8B/=E8=B7=AF=E5=BE=84/=E4=BF=A1=E5=8F=B7/?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E4=BF=A1=E6=81=AF=EF=BC=88=E5=90=AB=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=9E=84=E5=BB=BA=E9=AA=8C=E8=AF=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/platform/path.go | 32 +++++++++ internal/platform/platform.go | 108 ++++++++++++++++++++++++++++ internal/platform/platform_test.go | 99 +++++++++++++++++++++++++ internal/platform/signal.go | 14 ++++ internal/platform/signal_unix.go | 12 ++++ internal/platform/signal_windows.go | 11 +++ 6 files changed, 276 insertions(+) create mode 100644 internal/platform/path.go create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/platform_test.go create mode 100644 internal/platform/signal.go create mode 100644 internal/platform/signal_unix.go create mode 100644 internal/platform/signal_windows.go diff --git a/internal/platform/path.go b/internal/platform/path.go new file mode 100644 index 0000000..fc0bc2b --- /dev/null +++ b/internal/platform/path.go @@ -0,0 +1,32 @@ +package platform + +import ( + "os" + "path/filepath" + "runtime" +) + +// DefaultDataDir returns the OS-appropriate default data directory when the +// user has not configured one. It never returns an empty path; on failure it +// falls back to a relative "./data" directory. +func DefaultDataDir() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".", "data") + } + + switch runtime.GOOS { + case "windows": + if appdata := os.Getenv("APPDATA"); appdata != "" { + return filepath.Join(appdata, "Cyrene") + } + return filepath.Join(home, "AppData", "Roaming", "Cyrene") + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Cyrene") + default: + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "cyrene") + } + return filepath.Join(home, ".local", "share", "cyrene") + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..000c7e4 --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,108 @@ +// Package platform centralizes OS detection, path resolution, signal handling, +// and runtime system information. Other packages must never call syscall or +// hard-code OS paths; they resolve everything through this package. +package platform + +import ( + "os" + "runtime" + "strconv" +) + +// Info describes the host platform. +type Info struct { + Name string // human-readable platform name + GOOS string + GOARCH string + Kind string // platform family: desktop / server / mobile / unknown + CPUs int + Host string + GoVer string +} + +// Detect returns runtime-derived platform information. +func Detect() Info { + return Info{ + Name: platformName(runtime.GOOS), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Kind: platformKind(runtime.GOOS), + CPUs: runtime.NumCPU(), + Host: hostname(), + GoVer: runtime.Version(), + } +} + +// GOOS returns the operating system name (runtime.GOOS). +func GOOS() string { return runtime.GOOS } + +// GOARCH returns the CPU architecture name (runtime.GOARCH). +func GOARCH() string { return runtime.GOARCH } + +// CPUs returns the number of logical CPUs visible to the runtime. +func CPUs() int { return runtime.NumCPU() } + +// IsWindows reports whether the host is Windows. +func IsWindows() bool { return runtime.GOOS == "windows" } + +// IsDarwin reports whether the host is macOS. +func IsDarwin() bool { return runtime.GOOS == "darwin" } + +// IsLinux reports whether the host is Linux. +func IsLinux() bool { return runtime.GOOS == "linux" } + +// IsUnix reports whether the host is a Unix-like OS (linux, darwin, etc.). +func IsUnix() bool { return runtime.GOOS != "windows" } + +// CPUArch converts runtime.GOARCH to a compact architecture label. +func CPUArch() string { return archLabel(runtime.GOARCH) } + +// archLabel maps a GOARCH value to a compact human-readable label. Unknown +// values pass through unchanged. +func archLabel(goarch string) string { + switch goarch { + case "amd64": + return "x86_64" + case "386": + return "x86" + default: + return goarch + } +} + +// Describe renders a short log-ready summary of the runtime. +func (i Info) Describe() string { + return i.Name + "/" + i.GOOS + "/" + i.GOARCH + " cpu=" + strconv.Itoa(i.CPUs) + " go=" + i.GoVer +} + +func platformName(goos string) string { + switch goos { + case "windows": + return "Windows" + case "darwin": + return "macOS" + case "linux": + return "Linux" + default: + return goos + } +} + +func platformKind(goos string) string { + switch goos { + case "windows", "darwin": + return "desktop" + case "linux": + return "server" + default: + return "unknown" + } +} + +func hostname() string { + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..cf11685 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,99 @@ +package platform + +import ( + "context" + "path/filepath" + "runtime" + "testing" +) + +func TestDetect_MatchesRuntime(t *testing.T) { + t.Parallel() + + info := Detect() + if info.GOOS != runtime.GOOS { + t.Errorf("Detect().GOOS = %q, want %q", info.GOOS, runtime.GOOS) + } + if info.GOARCH != runtime.GOARCH { + t.Errorf("Detect().GOARCH = %q, want %q", info.GOARCH, runtime.GOARCH) + } + if info.CPUs <= 0 { + t.Errorf("Detect().CPUs = %d, want > 0", info.CPUs) + } + if info.Name == "" { + t.Error("Detect().Name is empty") + } +} + +func TestOSPredicates_ConsistentWithRuntime(t *testing.T) { + t.Parallel() + + wantWindows := runtime.GOOS == "windows" + wantDarwin := runtime.GOOS == "darwin" + wantLinux := runtime.GOOS == "linux" + + if IsWindows() != wantWindows { + t.Errorf("IsWindows() = %v, want %v", IsWindows(), wantWindows) + } + if IsDarwin() != wantDarwin { + t.Errorf("IsDarwin() = %v, want %v", IsDarwin(), wantDarwin) + } + if IsLinux() != wantLinux { + t.Errorf("IsLinux() = %v, want %v", IsLinux(), wantLinux) + } + if IsUnix() != (runtime.GOOS != "windows") { + t.Errorf("IsUnix() = %v, want %v", IsUnix(), runtime.GOOS != "windows") + } +} + +func TestArchLabel_Mapping(t *testing.T) { + t.Parallel() + + tests := []struct { + goarch string + want string + }{ + {goarch: "amd64", want: "x86_64"}, + {goarch: "arm64", want: "arm64"}, + {goarch: "386", want: "x86"}, + {goarch: "riscv64", want: "riscv64"}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.goarch, func(t *testing.T) { + t.Parallel() + if got := archLabel(tt.goarch); got != tt.want { + t.Fatalf("archLabel(%q) = %q, want %q", tt.goarch, got, tt.want) + } + }) + } +} + +func TestDefaultDataDir_XDGOnLinux(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("XDG_DATA_HOME only applies on linux") + } + + t.Setenv("XDG_DATA_HOME", "/tmp/cyrene-xdg") + got := DefaultDataDir() + want := filepath.Join("/tmp/cyrene-xdg", "cyrene") + if got != want { + t.Errorf("DefaultDataDir() = %q, want %q", got, want) + } +} + +func TestSignalContext_Cancelable(t *testing.T) { + t.Parallel() + + ctx, cancel := SignalContext() + if ctx == nil { + t.Fatal("SignalContext() returned nil context") + } + if cancel == nil { + t.Fatal("SignalContext() returned nil cancel func") + } + cancel() + if ctx.Err() != context.Canceled { + t.Errorf("ctx.Err() = %v, want context.Canceled", ctx.Err()) + } +} diff --git a/internal/platform/signal.go b/internal/platform/signal.go new file mode 100644 index 0000000..c9ff9ab --- /dev/null +++ b/internal/platform/signal.go @@ -0,0 +1,14 @@ +package platform + +import ( + "context" + "os/signal" +) + +// SignalContext returns a context canceled when an interactive termination +// signal arrives: Ctrl+C on every OS, plus SIGTERM on Unix. It is the single +// graceful-shutdown hook for the whole process. +func SignalContext() (context.Context, context.CancelFunc) { + ctx, cancel := signal.NotifyContext(context.Background(), shutdownSignals()...) + return ctx, cancel +} diff --git a/internal/platform/signal_unix.go b/internal/platform/signal_unix.go new file mode 100644 index 0000000..6e64d88 --- /dev/null +++ b/internal/platform/signal_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package platform + +import ( + "os" + "syscall" +) + +func shutdownSignals() []os.Signal { + return []os.Signal{os.Interrupt, syscall.SIGTERM} +} diff --git a/internal/platform/signal_windows.go b/internal/platform/signal_windows.go new file mode 100644 index 0000000..bfc8312 --- /dev/null +++ b/internal/platform/signal_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package platform + +import "os" + +func shutdownSignals() []os.Signal { + // Windows has no SIGTERM equivalent; Ctrl+C (os.Interrupt) is the only + // portable interactive termination signal. + return []os.Signal{os.Interrupt} +}