mirror of Walter-Sparrow / lunar-tear
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add wizard cli

+808 -3
+2
.gitignore
··· 10 10 server/claim-account 11 11 server/octo-cdn 12 12 server/dev 13 + server/wizard 14 + server/.wizard.json 13 15 14 16 __pycache__/ 15 17
+11
README.md
··· 15 15 go install github.com/pressly/goose/v3/cmd/goose@latest 16 16 ``` 17 17 18 + ### Quick Start (Wizard) 19 + 20 + The interactive wizard walks you through setup with a few simple questions — no flags or networking knowledge needed. It auto-detects the right IP address for your emulator or phone and launches all services. 21 + 22 + ```bash 23 + cd server 24 + go run ./cmd/wizard 25 + ``` 26 + 27 + Your choices are saved so next time you just press Enter to relaunch with the same settings. 28 + 18 29 ### Regenerate protobuf stubs 19 30 20 31 ```bash
+5 -2
server/Makefile
··· 3 3 # (generating all would put them in one package and cause name clashes). 4 4 PROTO_USED = proto/banner.proto proto/battle.proto proto/bighunt.proto proto/cageornament.proto proto/character.proto proto/characterboard.proto proto/characterviewer.proto proto/companion.proto proto/config.proto proto/consumableitem.proto proto/contentsstory.proto proto/costume.proto proto/data.proto proto/deck.proto proto/dokan.proto proto/explore.proto proto/friend.proto proto/gacha.proto proto/gameplay.proto proto/gift.proto proto/gimmick.proto proto/labyrinth.proto proto/loginbonus.proto proto/material.proto proto/mission.proto proto/movie.proto proto/navicutin.proto proto/omikuji.proto proto/notification.proto proto/parts.proto proto/portalcage.proto proto/pvp.proto proto/quest.proto proto/reward.proto proto/shop.proto proto/sidestoryquest.proto proto/tutorial.proto proto/user.proto proto/weapon.proto 5 5 6 + PROTOC ?= protoc 7 + GOOSE ?= goose 8 + 6 9 EXE = 7 10 ifeq ($(OS),Windows_NT) 8 11 EXE = .exe 9 12 endif 10 13 11 14 proto: 12 - protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server 15 + $(PROTOC) -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server 13 16 @echo "Generated in gen/proto/" 14 17 15 18 build: ··· 36 39 else 37 40 mkdir -p db 38 41 endif 39 - goose -dir migrations -allow-missing sqlite3 db/game.db up 42 + $(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up 40 43 41 44 import: 42 45 ifndef SNAPSHOT
+722
server/cmd/wizard/main.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "flag" 6 + "fmt" 7 + "net" 8 + "os" 9 + "os/exec" 10 + "os/signal" 11 + "path/filepath" 12 + "runtime" 13 + "strings" 14 + "syscall" 15 + 16 + "charm.land/huh/v2" 17 + "charm.land/huh/v2/spinner" 18 + "charm.land/lipgloss/v2" 19 + ) 20 + 21 + const banner = ` 22 + _ _____ 23 + | | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _ 24 + | |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_| 25 + |____\_,_|_||_\__,_|_| |_|\___|\__,_|_| 26 + 27 + ` 28 + 29 + const ( 30 + configFile = ".wizard.json" 31 + exitVal = "__exit__" 32 + ) 33 + 34 + type config struct { 35 + IP string `json:"ip"` 36 + Device string `json:"device"` 37 + Detail string `json:"detail"` 38 + Summary string `json:"summary"` 39 + } 40 + 41 + func main() { 42 + setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching") 43 + flag.Parse() 44 + 45 + lipgloss.EnableLegacyWindowsANSI(os.Stdout) 46 + lipgloss.EnableLegacyWindowsANSI(os.Stderr) 47 + 48 + fmt.Print(banner) 49 + 50 + if !*setupOnly { 51 + validateAssets() 52 + validateTools() 53 + validateProtocIncludes() 54 + runProtoc() 55 + runMigrate() 56 + downloadDeps() 57 + } 58 + 59 + ip, cfg, firstRun := resolveIP() 60 + 61 + saveConfig(cfg) 62 + 63 + labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14) 64 + addrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) 65 + 66 + fmt.Println() 67 + fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(ip+":8003")) 68 + fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(ip+":8080")) 69 + fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(ip+":3000")) 70 + fmt.Println() 71 + 72 + if firstRun || *setupOnly { 73 + showPatcherHint(ip, !*setupOnly) 74 + } 75 + 76 + if *setupOnly { 77 + return 78 + } 79 + 80 + launchDev(ip) 81 + } 82 + 83 + type assetCheck struct { 84 + path string 85 + dir bool 86 + } 87 + 88 + var requiredAssets = []assetCheck{ 89 + {"assets", true}, 90 + {"assets/release/20240404193219.bin.e", false}, 91 + {"assets/revisions/0/list.bin", false}, 92 + {"assets/revisions/0/assetbundle", true}, 93 + {"assets/revisions/0/resources", true}, 94 + } 95 + 96 + func validateAssets() { 97 + var missing []string 98 + for _, a := range requiredAssets { 99 + info, err := os.Stat(a.path) 100 + if err != nil { 101 + missing = append(missing, a.path) 102 + continue 103 + } 104 + if a.dir && !info.IsDir() { 105 + missing = append(missing, a.path+string(filepath.Separator)) 106 + } else if !a.dir && info.IsDir() { 107 + missing = append(missing, a.path) 108 + } 109 + } 110 + 111 + if len(missing) == 0 { 112 + return 113 + } 114 + 115 + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) 116 + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 117 + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 118 + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) 119 + 120 + var b strings.Builder 121 + b.WriteString(errStyle.Render(" Required game assets are missing.")) 122 + b.WriteString("\n\n") 123 + for _, p := range missing { 124 + b.WriteString(pathStyle.Render(" ✗ " + p)) 125 + b.WriteString("\n") 126 + } 127 + b.WriteString("\n") 128 + b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again.")) 129 + b.WriteString("\n") 130 + b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG")) 131 + b.WriteString("\n") 132 + 133 + fmt.Fprintln(os.Stderr, b.String()) 134 + os.Exit(1) 135 + } 136 + 137 + type toolReq struct { 138 + bin string 139 + install string // human-readable hint shown when tool must be installed manually 140 + goInstall string // `go install` package path; non-empty means auto-installable 141 + } 142 + 143 + var requiredTools = []toolReq{ 144 + {"make", "https://www.gnu.org/software/make/", ""}, 145 + {"protoc", "https://protobuf.dev/installation/", ""}, 146 + {"protoc-gen-go", "", "google.golang.org/protobuf/cmd/protoc-gen-go@latest"}, 147 + {"protoc-gen-go-grpc", "", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"}, 148 + {"goose", "", "github.com/pressly/goose/v3/cmd/goose@latest"}, 149 + } 150 + 151 + var toolPaths = map[string]string{} 152 + 153 + // findTool looks for a tool on PATH first, then falls back to the current 154 + // directory (for Windows users who drop .exe files into server/). 155 + func findTool(name string) (string, error) { 156 + if p, err := exec.LookPath(name); err == nil { 157 + return p, nil 158 + } 159 + local := name 160 + if runtime.GOOS == "windows" { 161 + local += ".exe" 162 + } 163 + if _, err := os.Stat(local); err == nil { 164 + abs, err := filepath.Abs(local) 165 + if err != nil { 166 + return local, nil 167 + } 168 + return abs, nil 169 + } 170 + return "", fmt.Errorf("%s not found", name) 171 + } 172 + 173 + func validateTools() { 174 + var manual []toolReq 175 + var installable []toolReq 176 + 177 + for _, t := range requiredTools { 178 + if p, err := findTool(t.bin); err == nil { 179 + toolPaths[t.bin] = p 180 + } else if t.goInstall == "" { 181 + manual = append(manual, t) 182 + } else { 183 + installable = append(installable, t) 184 + } 185 + } 186 + 187 + if len(manual) > 0 { 188 + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) 189 + nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 190 + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 191 + 192 + var b strings.Builder 193 + b.WriteString(errStyle.Render(" Required tools are not installed.")) 194 + b.WriteString("\n\n") 195 + for _, t := range manual { 196 + b.WriteString(nameStyle.Render(fmt.Sprintf(" ✗ %-22s", t.bin)) + hintStyle.Render(t.install)) 197 + b.WriteString("\n") 198 + } 199 + b.WriteString("\n") 200 + 201 + fmt.Fprintln(os.Stderr, b.String()) 202 + os.Exit(1) 203 + } 204 + 205 + for _, t := range installable { 206 + fmt.Printf(" Installing %s...\n", t.bin) 207 + cmd := exec.Command("go", "install", t.goInstall) 208 + cmd.Stdout = os.Stdout 209 + cmd.Stderr = os.Stderr 210 + if err := cmd.Run(); err != nil { 211 + fmt.Fprintf(os.Stderr, " Failed to install %s: %v\n", t.bin, err) 212 + os.Exit(1) 213 + } 214 + p, err := findTool(t.bin) 215 + if err != nil { 216 + fmt.Fprintf(os.Stderr, " %s installed but not found on PATH — is $(go env GOPATH)/bin in your PATH?\n", t.bin) 217 + os.Exit(1) 218 + } 219 + toolPaths[t.bin] = p 220 + } 221 + } 222 + 223 + func validateProtocIncludes() { 224 + if _, err := exec.LookPath("protoc"); err == nil { 225 + return 226 + } 227 + // protoc is local (not on PATH) -- verify well-known types are present. 228 + wkt := filepath.Join("google", "protobuf", "empty.proto") 229 + if _, err := os.Stat(wkt); err == nil { 230 + return 231 + } 232 + 233 + errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")) 234 + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 235 + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 236 + 237 + var b strings.Builder 238 + b.WriteString(errStyle.Render(" protoc well-known types are missing.")) 239 + b.WriteString("\n\n") 240 + b.WriteString(pathStyle.Render(" ✗ " + wkt)) 241 + b.WriteString("\n\n") 242 + b.WriteString(dimStyle.Render(" Extract the google/ folder from the protoc release zip into server/.")) 243 + b.WriteString("\n") 244 + b.WriteString(dimStyle.Render(" Download: https://github.com/protocolbuffers/protobuf/releases")) 245 + b.WriteString("\n") 246 + 247 + fmt.Fprintln(os.Stderr, b.String()) 248 + os.Exit(1) 249 + } 250 + 251 + func runProtoc() { 252 + _ = spinner.New().Title(" Running protoc...").Action(func() { 253 + runQuiet(exec.Command(toolPaths["make"], "proto", "PROTOC="+toolPaths["protoc"]), "protoc generation") 254 + }).Run() 255 + } 256 + 257 + func runMigrate() { 258 + _ = spinner.New().Title(" Running migrations...").Action(func() { 259 + runQuiet(exec.Command(toolPaths["make"], "migrate", "GOOSE="+toolPaths["goose"]), "database migration") 260 + }).Run() 261 + } 262 + 263 + func downloadDeps() { 264 + _ = spinner.New().Title(" Downloading dependencies...").Action(func() { 265 + runQuiet(exec.Command("go", "mod", "download"), "dependency download") 266 + }).Run() 267 + } 268 + 269 + func runQuiet(cmd *exec.Cmd, label string) { 270 + var buf strings.Builder 271 + cmd.Stdout = &buf 272 + cmd.Stderr = &buf 273 + if err := cmd.Run(); err != nil { 274 + fmt.Fprintln(os.Stderr) 275 + fmt.Fprint(os.Stderr, buf.String()) 276 + fmt.Fprintf(os.Stderr, "\n %s failed: %v\n", label, err) 277 + os.Exit(1) 278 + } 279 + } 280 + 281 + func resolveIP() (string, config, bool) { 282 + if cfg, err := loadConfig(); err == nil { 283 + ip, cfg, done := handleSavedConfig(cfg) 284 + if done { 285 + return ip, cfg, false 286 + } 287 + } 288 + 289 + ip, cfg := runWizard() 290 + return ip, cfg, true 291 + } 292 + 293 + func handleSavedConfig(cfg config) (string, config, bool) { 294 + reuse := true 295 + err := huh.NewConfirm(). 296 + Title(fmt.Sprintf("Use same settings as last time? (%s — %s)", cfg.IP, cfg.Summary)). 297 + Affirmative("Yes"). 298 + Negative("No, reconfigure"). 299 + Value(&reuse). 300 + Run() 301 + if err != nil { 302 + os.Exit(1) 303 + } 304 + if !reuse { 305 + return "", config{}, false 306 + } 307 + 308 + if isLANBased(cfg) { 309 + if ip, updated, ok := recheckLANIP(cfg); ok { 310 + return ip, updated, true 311 + } 312 + return "", config{}, false 313 + } 314 + 315 + return cfg.IP, cfg, true 316 + } 317 + 318 + func isLANBased(cfg config) bool { 319 + if cfg.Detail == "wifi" { 320 + return true 321 + } 322 + switch cfg.Detail { 323 + case "android-studio", "bluestacks", "genymotion": 324 + return false 325 + } 326 + return cfg.Device == "emulator" 327 + } 328 + 329 + func recheckLANIP(cfg config) (string, config, bool) { 330 + current := detectLANIP() 331 + if current == "" || current == cfg.IP { 332 + return cfg.IP, cfg, true 333 + } 334 + 335 + var action string 336 + err := huh.NewSelect[string](). 337 + Title(fmt.Sprintf("Your LAN IP changed: %s → %s", cfg.IP, current)). 338 + Options( 339 + huh.NewOption("Use new IP ("+current+")", "update"), 340 + huh.NewOption("Keep saved IP ("+cfg.IP+")", "keep"), 341 + huh.NewOption("Reconfigure from scratch", "reconfig"), 342 + ). 343 + Value(&action). 344 + Run() 345 + if err != nil { 346 + os.Exit(1) 347 + } 348 + 349 + switch action { 350 + case "update": 351 + warnRepatch(cfg.IP, current) 352 + var ack bool 353 + _ = huh.NewConfirm(). 354 + Title("Continue launching the server?"). 355 + Affirmative("Yes, start"). 356 + Negative("No, exit"). 357 + Value(&ack). 358 + Run() 359 + if !ack { 360 + os.Exit(0) 361 + } 362 + cfg.IP = current 363 + return current, cfg, true 364 + case "keep": 365 + return cfg.IP, cfg, true 366 + default: 367 + return "", config{}, false 368 + } 369 + } 370 + 371 + func warnRepatch(oldIP, newIP string) { 372 + warnStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) 373 + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 374 + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) 375 + 376 + repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts" 377 + repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL) 378 + 379 + var b strings.Builder 380 + b.WriteString("\n") 381 + b.WriteString(warnStyle.Render(" ⚠ Your APK was patched for the old IP.")) 382 + b.WriteString("\n") 383 + b.WriteString(dimStyle.Render(" Re-patch using ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":")) 384 + b.WriteString("\n\n") 385 + b.WriteString(" " + repoLink) 386 + b.WriteString("\n\n") 387 + b.WriteString(dimStyle.Render(" Update the Configuration cell with the new addresses:")) 388 + b.WriteString("\n\n") 389 + b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", newIP))) 390 + b.WriteString("\n") 391 + b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", newIP))) 392 + b.WriteString("\n") 393 + b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", newIP))) 394 + b.WriteString("\n\n") 395 + fmt.Print(b.String()) 396 + } 397 + 398 + func showPatcherHint(ip string, askLaunch bool) { 399 + headStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) 400 + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 401 + hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) 402 + 403 + repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts" 404 + discordURL := "https://discord.com/invite/MZAf5aVkJG" 405 + repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL) 406 + discordLink := hlStyle.Hyperlink(discordURL).Render(discordURL) 407 + 408 + var b strings.Builder 409 + b.WriteString(headStyle.Render(" Next step: patch your APK")) 410 + b.WriteString("\n\n") 411 + b.WriteString(dimStyle.Render(" The game client must be patched to connect to your server.")) 412 + b.WriteString("\n") 413 + b.WriteString(dimStyle.Render(" Open ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" from the scripts repo in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":")) 414 + b.WriteString("\n\n") 415 + b.WriteString(" " + repoLink) 416 + b.WriteString("\n\n") 417 + b.WriteString(dimStyle.Render(" Get the APK and master data links from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord:")) 418 + b.WriteString("\n\n") 419 + b.WriteString(" " + discordLink) 420 + b.WriteString("\n\n") 421 + b.WriteString(dimStyle.Render(" Set these in the notebook's Configuration cell:")) 422 + b.WriteString("\n\n") 423 + b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", ip))) 424 + b.WriteString("\n") 425 + b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", ip))) 426 + b.WriteString("\n") 427 + b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", ip))) 428 + b.WriteString("\n\n") 429 + b.WriteString(dimStyle.Render(" Then run all cells — a patched APK will download automatically.")) 430 + b.WriteString("\n\n") 431 + fmt.Print(b.String()) 432 + 433 + if !askLaunch { 434 + return 435 + } 436 + 437 + launch := true 438 + _ = huh.NewConfirm(). 439 + Title("Launch the server?"). 440 + Affirmative("Yes, start"). 441 + Negative("No, exit"). 442 + Value(&launch). 443 + Run() 444 + if !launch { 445 + os.Exit(0) 446 + } 447 + } 448 + 449 + func runWizard() (string, config) { 450 + var device, emu, conn string 451 + 452 + form := huh.NewForm( 453 + huh.NewGroup( 454 + huh.NewSelect[string](). 455 + Title("Where is the game running?"). 456 + Options( 457 + huh.NewOption("Android Emulator on this PC", "emulator"), 458 + huh.NewOption("Phone / Tablet on the same network", "phone"), 459 + huh.NewOption("Exit", exitVal), 460 + ). 461 + Value(&device), 462 + ).WithShowHelp(true), 463 + 464 + huh.NewGroup( 465 + huh.NewSelect[string](). 466 + Title("Which emulator are you using? "). 467 + Options( 468 + huh.NewOption("Android Studio Emulator", "android-studio"), 469 + huh.NewOption("BlueStacks", "bluestacks"), 470 + huh.NewOption("Genymotion", "genymotion"), 471 + huh.NewOption("Nox / LDPlayer / MEmu", "nox-ld-memu"), 472 + huh.NewOption("Other / Not sure", "other"), 473 + huh.NewOption("Exit", exitVal), 474 + ). 475 + Value(&emu), 476 + ).WithHideFunc(func() bool { return device != "emulator" }).WithShowHelp(true), 477 + 478 + huh.NewGroup( 479 + huh.NewSelect[string](). 480 + Title("How is your phone connected to this PC?"). 481 + Options( 482 + huh.NewOption("Same Wi-Fi network", "wifi"), 483 + huh.NewOption("Tailscale / ZeroTier / VPN", "vpn"), 484 + huh.NewOption("Something else / I'll type the IP", "manual"), 485 + huh.NewOption("Exit", exitVal), 486 + ). 487 + Value(&conn), 488 + ).WithHideFunc(func() bool { return device != "phone" }).WithShowHelp(true), 489 + ) 490 + 491 + if err := form.Run(); err != nil { 492 + os.Exit(1) 493 + } 494 + 495 + if device == exitVal || emu == exitVal || conn == exitVal { 496 + os.Exit(0) 497 + } 498 + 499 + return buildResult(device, emu, conn) 500 + } 501 + 502 + func buildResult(device, emu, conn string) (string, config) { 503 + if device == "emulator" { 504 + return buildEmulatorResult(emu) 505 + } 506 + return buildPhoneResult(conn) 507 + } 508 + 509 + func buildEmulatorResult(emu string) (string, config) { 510 + switch emu { 511 + case "android-studio", "bluestacks": 512 + return "10.0.2.2", config{ 513 + IP: "10.0.2.2", Device: "emulator", Detail: emu, 514 + Summary: emu + " emulator", 515 + } 516 + case "genymotion": 517 + return "10.0.3.2", config{ 518 + IP: "10.0.3.2", Device: "emulator", Detail: emu, 519 + Summary: "Genymotion emulator", 520 + } 521 + default: 522 + ip := detectAndConfirmIP(detectLANIP(), "emulator (LAN IP)") 523 + return ip, config{ 524 + IP: ip, Device: "emulator", Detail: emu, 525 + Summary: emu + " emulator (LAN IP)", 526 + } 527 + } 528 + } 529 + 530 + func buildPhoneResult(conn string) (string, config) { 531 + switch conn { 532 + case "wifi": 533 + ip := detectAndConfirmIP(detectLANIP(), "Wi-Fi") 534 + return ip, config{IP: ip, Device: "phone", Detail: "wifi", Summary: "phone (Wi-Fi)"} 535 + case "vpn": 536 + ip := detectAndConfirmIP(detectVPNIP(), "VPN") 537 + return ip, config{IP: ip, Device: "phone", Detail: "vpn", Summary: "phone (VPN)"} 538 + default: 539 + ip := promptIP("") 540 + return ip, config{IP: ip, Device: "phone", Detail: "manual", Summary: "phone (manual IP)"} 541 + } 542 + } 543 + 544 + func detectAndConfirmIP(detected, label string) string { 545 + if detected == "" { 546 + fmt.Printf(" Could not auto-detect your %s IP address.\n", label) 547 + return promptIP("") 548 + } 549 + return promptIP(detected) 550 + } 551 + 552 + func promptIP(defaultVal string) string { 553 + ip := defaultVal 554 + 555 + title := "Enter your PC's IP address" 556 + if defaultVal != "" { 557 + title = fmt.Sprintf("Enter your PC's IP address (detected: %s)", defaultVal) 558 + } 559 + 560 + err := huh.NewInput(). 561 + Title(title). 562 + Description("Press Enter to accept, or type a different address."). 563 + Validate(func(s string) error { 564 + if net.ParseIP(strings.TrimSpace(s)) == nil { 565 + return fmt.Errorf("not a valid IP address") 566 + } 567 + return nil 568 + }). 569 + Value(&ip). 570 + Run() 571 + if err != nil { 572 + os.Exit(1) 573 + } 574 + return strings.TrimSpace(ip) 575 + } 576 + 577 + func detectLANIP() string { 578 + ifaces, err := net.Interfaces() 579 + if err != nil { 580 + return "" 581 + } 582 + 583 + skipPrefixes := []string{"docker", "veth", "br-", "virbr", "tailscale", "zt", "tun", "utun"} 584 + for _, iface := range ifaces { 585 + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { 586 + continue 587 + } 588 + name := strings.ToLower(iface.Name) 589 + skip := false 590 + for _, prefix := range skipPrefixes { 591 + if strings.HasPrefix(name, prefix) { 592 + skip = true 593 + break 594 + } 595 + } 596 + if skip { 597 + continue 598 + } 599 + 600 + addrs, err := iface.Addrs() 601 + if err != nil { 602 + continue 603 + } 604 + for _, addr := range addrs { 605 + ipNet, ok := addr.(*net.IPNet) 606 + if !ok { 607 + continue 608 + } 609 + ip := ipNet.IP.To4() 610 + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { 611 + continue 612 + } 613 + return ip.String() 614 + } 615 + } 616 + return "" 617 + } 618 + 619 + func detectVPNIP() string { 620 + if path, err := exec.LookPath("tailscale"); err == nil { 621 + out, err := exec.Command(path, "ip", "-4").Output() 622 + if err == nil { 623 + ip := strings.TrimSpace(string(out)) 624 + if net.ParseIP(ip) != nil { 625 + return ip 626 + } 627 + } 628 + } 629 + 630 + _, tailscaleNet, _ := net.ParseCIDR("100.64.0.0/10") 631 + vpnPrefixes := []string{"tailscale", "zt", "tun", "utun", "wg"} 632 + 633 + ifaces, err := net.Interfaces() 634 + if err != nil { 635 + return "" 636 + } 637 + for _, iface := range ifaces { 638 + if iface.Flags&net.FlagUp == 0 { 639 + continue 640 + } 641 + name := strings.ToLower(iface.Name) 642 + isVPN := false 643 + for _, prefix := range vpnPrefixes { 644 + if strings.HasPrefix(name, prefix) { 645 + isVPN = true 646 + break 647 + } 648 + } 649 + 650 + addrs, err := iface.Addrs() 651 + if err != nil { 652 + continue 653 + } 654 + for _, addr := range addrs { 655 + ipNet, ok := addr.(*net.IPNet) 656 + if !ok { 657 + continue 658 + } 659 + ip := ipNet.IP.To4() 660 + if ip == nil { 661 + continue 662 + } 663 + if isVPN || tailscaleNet.Contains(ip) { 664 + return ip.String() 665 + } 666 + } 667 + } 668 + return "" 669 + } 670 + 671 + func loadConfig() (config, error) { 672 + data, err := os.ReadFile(configFile) 673 + if err != nil { 674 + return config{}, err 675 + } 676 + var cfg config 677 + if err := json.Unmarshal(data, &cfg); err != nil { 678 + return config{}, err 679 + } 680 + if cfg.IP == "" { 681 + return config{}, fmt.Errorf("empty config") 682 + } 683 + return cfg, nil 684 + } 685 + 686 + func saveConfig(cfg config) { 687 + data, err := json.MarshalIndent(cfg, "", " ") 688 + if err != nil { 689 + return 690 + } 691 + _ = os.WriteFile(configFile, append(data, '\n'), 0644) 692 + } 693 + 694 + func launchDev(ip string) { 695 + cmd := exec.Command("go", "run", "./cmd/dev", 696 + "--cdn.public-addr", ip+":8080", 697 + "--grpc.public-addr", ip+":8003", 698 + ) 699 + cmd.Stdout = os.Stdout 700 + cmd.Stderr = os.Stderr 701 + cmd.Stdin = os.Stdin 702 + 703 + sigCh := make(chan os.Signal, 1) 704 + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) 705 + 706 + if err := cmd.Start(); err != nil { 707 + fmt.Fprintf(os.Stderr, "Failed to start dev server: %v\n", err) 708 + os.Exit(1) 709 + } 710 + 711 + go func() { 712 + <-sigCh 713 + _ = cmd.Process.Signal(os.Interrupt) 714 + }() 715 + 716 + if err := cmd.Wait(); err != nil { 717 + if exitErr, ok := err.(*exec.ExitError); ok { 718 + os.Exit(exitErr.ExitCode()) 719 + } 720 + os.Exit(1) 721 + } 722 + }
+24 -1
server/go.mod
··· 1 1 module lunar-tear/server 2 2 3 - go 1.25.0 3 + go 1.25.8 4 4 5 5 require ( 6 6 github.com/google/uuid v1.6.0 ··· 16 16 ) 17 17 18 18 require ( 19 + charm.land/bubbles/v2 v2.0.0 // indirect 20 + charm.land/bubbletea/v2 v2.0.2 // indirect 21 + charm.land/huh/v2 v2.0.3 // indirect 22 + charm.land/lipgloss/v2 v2.0.1 // indirect 23 + github.com/atotto/clipboard v0.1.4 // indirect 24 + github.com/catppuccin/go v0.2.0 // indirect 25 + github.com/charmbracelet/colorprofile v0.4.2 // indirect 26 + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect 27 + github.com/charmbracelet/x/ansi v0.11.6 // indirect 28 + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect 29 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 30 + github.com/charmbracelet/x/term v0.2.2 // indirect 31 + github.com/charmbracelet/x/termios v0.1.1 // indirect 32 + github.com/charmbracelet/x/windows v0.2.2 // indirect 33 + github.com/clipperhouse/displaywidth v0.11.0 // indirect 34 + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect 19 35 github.com/dustin/go-humanize v1.0.1 // indirect 36 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 20 37 github.com/mattn/go-isatty v0.0.20 // indirect 38 + github.com/mattn/go-runewidth v0.0.20 // indirect 39 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 40 + github.com/muesli/cancelreader v0.2.2 // indirect 21 41 github.com/ncruces/go-strftime v1.0.0 // indirect 22 42 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 43 + github.com/rivo/uniseg v0.4.7 // indirect 23 44 github.com/stretchr/testify v1.11.1 // indirect 24 45 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 46 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 25 47 go.opentelemetry.io/otel v1.40.0 // indirect 48 + golang.org/x/sync v0.20.0 // indirect 26 49 golang.org/x/text v0.36.0 // indirect 27 50 google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect 28 51 modernc.org/libc v1.70.0 // indirect
+44
server/go.sum
··· 1 + charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= 2 + charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= 3 + charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= 4 + charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= 5 + charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= 6 + charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= 7 + charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= 8 + charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= 9 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 10 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 11 + github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= 12 + github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 1 13 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 2 14 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 + github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= 16 + github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= 17 + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= 18 + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= 19 + github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= 20 + github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 21 + github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= 22 + github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= 23 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 24 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 25 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 26 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 27 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 28 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 29 + github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= 30 + github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 31 + github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= 32 + github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= 33 + github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= 34 + github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 3 35 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 36 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 37 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= ··· 18 50 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 51 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 20 52 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 53 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 54 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 55 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 56 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 57 + github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= 58 + github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 59 + github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 60 + github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 61 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 62 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 23 63 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 24 64 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 25 65 github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= ··· 28 68 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 69 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 30 70 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 71 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 72 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 31 73 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 32 74 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 33 75 github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 34 76 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 35 77 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 36 78 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 79 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 80 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 37 81 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 38 82 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 39 83 go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=