···1515go install github.com/pressly/goose/v3/cmd/goose@latest
1616```
17171818+### Quick Start (Wizard)
1919+2020+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.
2121+2222+```bash
2323+cd server
2424+go run ./cmd/wizard
2525+```
2626+2727+Your choices are saved so next time you just press Enter to relaunch with the same settings.
2828+1829### Regenerate protobuf stubs
19302031```bash
+5-2
server/Makefile
···33# (generating all would put them in one package and cause name clashes).
44PROTO_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
5566+PROTOC ?= protoc
77+GOOSE ?= goose
88+69EXE =
710ifeq ($(OS),Windows_NT)
811 EXE = .exe
912endif
10131114proto:
1212- protoc -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server
1515+ $(PROTOC) -I . $(PROTO_USED) --go_out=. --go_opt=module=lunar-tear/server --go-grpc_out=. --go-grpc_opt=module=lunar-tear/server
1316 @echo "Generated in gen/proto/"
14171518build:
···3639else
3740 mkdir -p db
3841endif
3939- goose -dir migrations -allow-missing sqlite3 db/game.db up
4242+ $(GOOSE) -dir migrations -allow-missing sqlite3 db/game.db up
40434144import:
4245ifndef SNAPSHOT
+722
server/cmd/wizard/main.go
···11+package main
22+33+import (
44+ "encoding/json"
55+ "flag"
66+ "fmt"
77+ "net"
88+ "os"
99+ "os/exec"
1010+ "os/signal"
1111+ "path/filepath"
1212+ "runtime"
1313+ "strings"
1414+ "syscall"
1515+1616+ "charm.land/huh/v2"
1717+ "charm.land/huh/v2/spinner"
1818+ "charm.land/lipgloss/v2"
1919+)
2020+2121+const banner = `
2222+ _ _____
2323+ | | _ _ _ _ __ _ _ _ |_ _|___ __ _ _ _
2424+ | |_| || | ' \/ _` + "`" + ` | '_| | |/ -_)/ _` + "`" + ` | '_|
2525+ |____\_,_|_||_\__,_|_| |_|\___|\__,_|_|
2626+2727+`
2828+2929+const (
3030+ configFile = ".wizard.json"
3131+ exitVal = "__exit__"
3232+)
3333+3434+type config struct {
3535+ IP string `json:"ip"`
3636+ Device string `json:"device"`
3737+ Detail string `json:"detail"`
3838+ Summary string `json:"summary"`
3939+}
4040+4141+func main() {
4242+ setupOnly := flag.Bool("setup-only", false, "show patching instructions and exit without building or launching")
4343+ flag.Parse()
4444+4545+ lipgloss.EnableLegacyWindowsANSI(os.Stdout)
4646+ lipgloss.EnableLegacyWindowsANSI(os.Stderr)
4747+4848+ fmt.Print(banner)
4949+5050+ if !*setupOnly {
5151+ validateAssets()
5252+ validateTools()
5353+ validateProtocIncludes()
5454+ runProtoc()
5555+ runMigrate()
5656+ downloadDeps()
5757+ }
5858+5959+ ip, cfg, firstRun := resolveIP()
6060+6161+ saveConfig(cfg)
6262+6363+ labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(14)
6464+ addrStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
6565+6666+ fmt.Println()
6767+ fmt.Printf(" %s %s\n", labelStyle.Render("Game server:"), addrStyle.Render(ip+":8003"))
6868+ fmt.Printf(" %s %s\n", labelStyle.Render("CDN:"), addrStyle.Render(ip+":8080"))
6969+ fmt.Printf(" %s %s\n", labelStyle.Render("Auth:"), addrStyle.Render(ip+":3000"))
7070+ fmt.Println()
7171+7272+ if firstRun || *setupOnly {
7373+ showPatcherHint(ip, !*setupOnly)
7474+ }
7575+7676+ if *setupOnly {
7777+ return
7878+ }
7979+8080+ launchDev(ip)
8181+}
8282+8383+type assetCheck struct {
8484+ path string
8585+ dir bool
8686+}
8787+8888+var requiredAssets = []assetCheck{
8989+ {"assets", true},
9090+ {"assets/release/20240404193219.bin.e", false},
9191+ {"assets/revisions/0/list.bin", false},
9292+ {"assets/revisions/0/assetbundle", true},
9393+ {"assets/revisions/0/resources", true},
9494+}
9595+9696+func validateAssets() {
9797+ var missing []string
9898+ for _, a := range requiredAssets {
9999+ info, err := os.Stat(a.path)
100100+ if err != nil {
101101+ missing = append(missing, a.path)
102102+ continue
103103+ }
104104+ if a.dir && !info.IsDir() {
105105+ missing = append(missing, a.path+string(filepath.Separator))
106106+ } else if !a.dir && info.IsDir() {
107107+ missing = append(missing, a.path)
108108+ }
109109+ }
110110+111111+ if len(missing) == 0 {
112112+ return
113113+ }
114114+115115+ errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
116116+ pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
117117+ dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
118118+ hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
119119+120120+ var b strings.Builder
121121+ b.WriteString(errStyle.Render(" Required game assets are missing."))
122122+ b.WriteString("\n\n")
123123+ for _, p := range missing {
124124+ b.WriteString(pathStyle.Render(" ✗ " + p))
125125+ b.WriteString("\n")
126126+ }
127127+ b.WriteString("\n")
128128+ b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
129129+ b.WriteString("\n")
130130+ 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"))
131131+ b.WriteString("\n")
132132+133133+ fmt.Fprintln(os.Stderr, b.String())
134134+ os.Exit(1)
135135+}
136136+137137+type toolReq struct {
138138+ bin string
139139+ install string // human-readable hint shown when tool must be installed manually
140140+ goInstall string // `go install` package path; non-empty means auto-installable
141141+}
142142+143143+var requiredTools = []toolReq{
144144+ {"make", "https://www.gnu.org/software/make/", ""},
145145+ {"protoc", "https://protobuf.dev/installation/", ""},
146146+ {"protoc-gen-go", "", "google.golang.org/protobuf/cmd/protoc-gen-go@latest"},
147147+ {"protoc-gen-go-grpc", "", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"},
148148+ {"goose", "", "github.com/pressly/goose/v3/cmd/goose@latest"},
149149+}
150150+151151+var toolPaths = map[string]string{}
152152+153153+// findTool looks for a tool on PATH first, then falls back to the current
154154+// directory (for Windows users who drop .exe files into server/).
155155+func findTool(name string) (string, error) {
156156+ if p, err := exec.LookPath(name); err == nil {
157157+ return p, nil
158158+ }
159159+ local := name
160160+ if runtime.GOOS == "windows" {
161161+ local += ".exe"
162162+ }
163163+ if _, err := os.Stat(local); err == nil {
164164+ abs, err := filepath.Abs(local)
165165+ if err != nil {
166166+ return local, nil
167167+ }
168168+ return abs, nil
169169+ }
170170+ return "", fmt.Errorf("%s not found", name)
171171+}
172172+173173+func validateTools() {
174174+ var manual []toolReq
175175+ var installable []toolReq
176176+177177+ for _, t := range requiredTools {
178178+ if p, err := findTool(t.bin); err == nil {
179179+ toolPaths[t.bin] = p
180180+ } else if t.goInstall == "" {
181181+ manual = append(manual, t)
182182+ } else {
183183+ installable = append(installable, t)
184184+ }
185185+ }
186186+187187+ if len(manual) > 0 {
188188+ errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
189189+ nameStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
190190+ hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
191191+192192+ var b strings.Builder
193193+ b.WriteString(errStyle.Render(" Required tools are not installed."))
194194+ b.WriteString("\n\n")
195195+ for _, t := range manual {
196196+ b.WriteString(nameStyle.Render(fmt.Sprintf(" ✗ %-22s", t.bin)) + hintStyle.Render(t.install))
197197+ b.WriteString("\n")
198198+ }
199199+ b.WriteString("\n")
200200+201201+ fmt.Fprintln(os.Stderr, b.String())
202202+ os.Exit(1)
203203+ }
204204+205205+ for _, t := range installable {
206206+ fmt.Printf(" Installing %s...\n", t.bin)
207207+ cmd := exec.Command("go", "install", t.goInstall)
208208+ cmd.Stdout = os.Stdout
209209+ cmd.Stderr = os.Stderr
210210+ if err := cmd.Run(); err != nil {
211211+ fmt.Fprintf(os.Stderr, " Failed to install %s: %v\n", t.bin, err)
212212+ os.Exit(1)
213213+ }
214214+ p, err := findTool(t.bin)
215215+ if err != nil {
216216+ fmt.Fprintf(os.Stderr, " %s installed but not found on PATH — is $(go env GOPATH)/bin in your PATH?\n", t.bin)
217217+ os.Exit(1)
218218+ }
219219+ toolPaths[t.bin] = p
220220+ }
221221+}
222222+223223+func validateProtocIncludes() {
224224+ if _, err := exec.LookPath("protoc"); err == nil {
225225+ return
226226+ }
227227+ // protoc is local (not on PATH) -- verify well-known types are present.
228228+ wkt := filepath.Join("google", "protobuf", "empty.proto")
229229+ if _, err := os.Stat(wkt); err == nil {
230230+ return
231231+ }
232232+233233+ errStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9"))
234234+ pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
235235+ dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
236236+237237+ var b strings.Builder
238238+ b.WriteString(errStyle.Render(" protoc well-known types are missing."))
239239+ b.WriteString("\n\n")
240240+ b.WriteString(pathStyle.Render(" ✗ " + wkt))
241241+ b.WriteString("\n\n")
242242+ b.WriteString(dimStyle.Render(" Extract the google/ folder from the protoc release zip into server/."))
243243+ b.WriteString("\n")
244244+ b.WriteString(dimStyle.Render(" Download: https://github.com/protocolbuffers/protobuf/releases"))
245245+ b.WriteString("\n")
246246+247247+ fmt.Fprintln(os.Stderr, b.String())
248248+ os.Exit(1)
249249+}
250250+251251+func runProtoc() {
252252+ _ = spinner.New().Title(" Running protoc...").Action(func() {
253253+ runQuiet(exec.Command(toolPaths["make"], "proto", "PROTOC="+toolPaths["protoc"]), "protoc generation")
254254+ }).Run()
255255+}
256256+257257+func runMigrate() {
258258+ _ = spinner.New().Title(" Running migrations...").Action(func() {
259259+ runQuiet(exec.Command(toolPaths["make"], "migrate", "GOOSE="+toolPaths["goose"]), "database migration")
260260+ }).Run()
261261+}
262262+263263+func downloadDeps() {
264264+ _ = spinner.New().Title(" Downloading dependencies...").Action(func() {
265265+ runQuiet(exec.Command("go", "mod", "download"), "dependency download")
266266+ }).Run()
267267+}
268268+269269+func runQuiet(cmd *exec.Cmd, label string) {
270270+ var buf strings.Builder
271271+ cmd.Stdout = &buf
272272+ cmd.Stderr = &buf
273273+ if err := cmd.Run(); err != nil {
274274+ fmt.Fprintln(os.Stderr)
275275+ fmt.Fprint(os.Stderr, buf.String())
276276+ fmt.Fprintf(os.Stderr, "\n %s failed: %v\n", label, err)
277277+ os.Exit(1)
278278+ }
279279+}
280280+281281+func resolveIP() (string, config, bool) {
282282+ if cfg, err := loadConfig(); err == nil {
283283+ ip, cfg, done := handleSavedConfig(cfg)
284284+ if done {
285285+ return ip, cfg, false
286286+ }
287287+ }
288288+289289+ ip, cfg := runWizard()
290290+ return ip, cfg, true
291291+}
292292+293293+func handleSavedConfig(cfg config) (string, config, bool) {
294294+ reuse := true
295295+ err := huh.NewConfirm().
296296+ Title(fmt.Sprintf("Use same settings as last time? (%s — %s)", cfg.IP, cfg.Summary)).
297297+ Affirmative("Yes").
298298+ Negative("No, reconfigure").
299299+ Value(&reuse).
300300+ Run()
301301+ if err != nil {
302302+ os.Exit(1)
303303+ }
304304+ if !reuse {
305305+ return "", config{}, false
306306+ }
307307+308308+ if isLANBased(cfg) {
309309+ if ip, updated, ok := recheckLANIP(cfg); ok {
310310+ return ip, updated, true
311311+ }
312312+ return "", config{}, false
313313+ }
314314+315315+ return cfg.IP, cfg, true
316316+}
317317+318318+func isLANBased(cfg config) bool {
319319+ if cfg.Detail == "wifi" {
320320+ return true
321321+ }
322322+ switch cfg.Detail {
323323+ case "android-studio", "bluestacks", "genymotion":
324324+ return false
325325+ }
326326+ return cfg.Device == "emulator"
327327+}
328328+329329+func recheckLANIP(cfg config) (string, config, bool) {
330330+ current := detectLANIP()
331331+ if current == "" || current == cfg.IP {
332332+ return cfg.IP, cfg, true
333333+ }
334334+335335+ var action string
336336+ err := huh.NewSelect[string]().
337337+ Title(fmt.Sprintf("Your LAN IP changed: %s → %s", cfg.IP, current)).
338338+ Options(
339339+ huh.NewOption("Use new IP ("+current+")", "update"),
340340+ huh.NewOption("Keep saved IP ("+cfg.IP+")", "keep"),
341341+ huh.NewOption("Reconfigure from scratch", "reconfig"),
342342+ ).
343343+ Value(&action).
344344+ Run()
345345+ if err != nil {
346346+ os.Exit(1)
347347+ }
348348+349349+ switch action {
350350+ case "update":
351351+ warnRepatch(cfg.IP, current)
352352+ var ack bool
353353+ _ = huh.NewConfirm().
354354+ Title("Continue launching the server?").
355355+ Affirmative("Yes, start").
356356+ Negative("No, exit").
357357+ Value(&ack).
358358+ Run()
359359+ if !ack {
360360+ os.Exit(0)
361361+ }
362362+ cfg.IP = current
363363+ return current, cfg, true
364364+ case "keep":
365365+ return cfg.IP, cfg, true
366366+ default:
367367+ return "", config{}, false
368368+ }
369369+}
370370+371371+func warnRepatch(oldIP, newIP string) {
372372+ warnStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
373373+ dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
374374+ hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
375375+376376+ repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts"
377377+ repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL)
378378+379379+ var b strings.Builder
380380+ b.WriteString("\n")
381381+ b.WriteString(warnStyle.Render(" ⚠ Your APK was patched for the old IP."))
382382+ b.WriteString("\n")
383383+ b.WriteString(dimStyle.Render(" Re-patch using ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":"))
384384+ b.WriteString("\n\n")
385385+ b.WriteString(" " + repoLink)
386386+ b.WriteString("\n\n")
387387+ b.WriteString(dimStyle.Render(" Update the Configuration cell with the new addresses:"))
388388+ b.WriteString("\n\n")
389389+ b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", newIP)))
390390+ b.WriteString("\n")
391391+ b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", newIP)))
392392+ b.WriteString("\n")
393393+ b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", newIP)))
394394+ b.WriteString("\n\n")
395395+ fmt.Print(b.String())
396396+}
397397+398398+func showPatcherHint(ip string, askLaunch bool) {
399399+ headStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11"))
400400+ dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
401401+ hlStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
402402+403403+ repoURL := "https://gitlab.com/walter-sparrow-group/lunar-scripts"
404404+ discordURL := "https://discord.com/invite/MZAf5aVkJG"
405405+ repoLink := hlStyle.Hyperlink(repoURL).Render(repoURL)
406406+ discordLink := hlStyle.Hyperlink(discordURL).Render(discordURL)
407407+408408+ var b strings.Builder
409409+ b.WriteString(headStyle.Render(" Next step: patch your APK"))
410410+ b.WriteString("\n\n")
411411+ b.WriteString(dimStyle.Render(" The game client must be patched to connect to your server."))
412412+ b.WriteString("\n")
413413+ b.WriteString(dimStyle.Render(" Open ") + hlStyle.Render("lunar_tear_patcher.ipynb") + dimStyle.Render(" from the scripts repo in ") + hlStyle.Render("Google Colab") + dimStyle.Render(":"))
414414+ b.WriteString("\n\n")
415415+ b.WriteString(" " + repoLink)
416416+ b.WriteString("\n\n")
417417+ b.WriteString(dimStyle.Render(" Get the APK and master data links from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord:"))
418418+ b.WriteString("\n\n")
419419+ b.WriteString(" " + discordLink)
420420+ b.WriteString("\n\n")
421421+ b.WriteString(dimStyle.Render(" Set these in the notebook's Configuration cell:"))
422422+ b.WriteString("\n\n")
423423+ b.WriteString(hlStyle.Render(fmt.Sprintf(" grpc_addr = \"%s:8003\"", ip)))
424424+ b.WriteString("\n")
425425+ b.WriteString(hlStyle.Render(fmt.Sprintf(" http_addr = \"%s:8080\"", ip)))
426426+ b.WriteString("\n")
427427+ b.WriteString(hlStyle.Render(fmt.Sprintf(" auth_host = \"%s:3000\"", ip)))
428428+ b.WriteString("\n\n")
429429+ b.WriteString(dimStyle.Render(" Then run all cells — a patched APK will download automatically."))
430430+ b.WriteString("\n\n")
431431+ fmt.Print(b.String())
432432+433433+ if !askLaunch {
434434+ return
435435+ }
436436+437437+ launch := true
438438+ _ = huh.NewConfirm().
439439+ Title("Launch the server?").
440440+ Affirmative("Yes, start").
441441+ Negative("No, exit").
442442+ Value(&launch).
443443+ Run()
444444+ if !launch {
445445+ os.Exit(0)
446446+ }
447447+}
448448+449449+func runWizard() (string, config) {
450450+ var device, emu, conn string
451451+452452+ form := huh.NewForm(
453453+ huh.NewGroup(
454454+ huh.NewSelect[string]().
455455+ Title("Where is the game running?").
456456+ Options(
457457+ huh.NewOption("Android Emulator on this PC", "emulator"),
458458+ huh.NewOption("Phone / Tablet on the same network", "phone"),
459459+ huh.NewOption("Exit", exitVal),
460460+ ).
461461+ Value(&device),
462462+ ).WithShowHelp(true),
463463+464464+ huh.NewGroup(
465465+ huh.NewSelect[string]().
466466+ Title("Which emulator are you using? ").
467467+ Options(
468468+ huh.NewOption("Android Studio Emulator", "android-studio"),
469469+ huh.NewOption("BlueStacks", "bluestacks"),
470470+ huh.NewOption("Genymotion", "genymotion"),
471471+ huh.NewOption("Nox / LDPlayer / MEmu", "nox-ld-memu"),
472472+ huh.NewOption("Other / Not sure", "other"),
473473+ huh.NewOption("Exit", exitVal),
474474+ ).
475475+ Value(&emu),
476476+ ).WithHideFunc(func() bool { return device != "emulator" }).WithShowHelp(true),
477477+478478+ huh.NewGroup(
479479+ huh.NewSelect[string]().
480480+ Title("How is your phone connected to this PC?").
481481+ Options(
482482+ huh.NewOption("Same Wi-Fi network", "wifi"),
483483+ huh.NewOption("Tailscale / ZeroTier / VPN", "vpn"),
484484+ huh.NewOption("Something else / I'll type the IP", "manual"),
485485+ huh.NewOption("Exit", exitVal),
486486+ ).
487487+ Value(&conn),
488488+ ).WithHideFunc(func() bool { return device != "phone" }).WithShowHelp(true),
489489+ )
490490+491491+ if err := form.Run(); err != nil {
492492+ os.Exit(1)
493493+ }
494494+495495+ if device == exitVal || emu == exitVal || conn == exitVal {
496496+ os.Exit(0)
497497+ }
498498+499499+ return buildResult(device, emu, conn)
500500+}
501501+502502+func buildResult(device, emu, conn string) (string, config) {
503503+ if device == "emulator" {
504504+ return buildEmulatorResult(emu)
505505+ }
506506+ return buildPhoneResult(conn)
507507+}
508508+509509+func buildEmulatorResult(emu string) (string, config) {
510510+ switch emu {
511511+ case "android-studio", "bluestacks":
512512+ return "10.0.2.2", config{
513513+ IP: "10.0.2.2", Device: "emulator", Detail: emu,
514514+ Summary: emu + " emulator",
515515+ }
516516+ case "genymotion":
517517+ return "10.0.3.2", config{
518518+ IP: "10.0.3.2", Device: "emulator", Detail: emu,
519519+ Summary: "Genymotion emulator",
520520+ }
521521+ default:
522522+ ip := detectAndConfirmIP(detectLANIP(), "emulator (LAN IP)")
523523+ return ip, config{
524524+ IP: ip, Device: "emulator", Detail: emu,
525525+ Summary: emu + " emulator (LAN IP)",
526526+ }
527527+ }
528528+}
529529+530530+func buildPhoneResult(conn string) (string, config) {
531531+ switch conn {
532532+ case "wifi":
533533+ ip := detectAndConfirmIP(detectLANIP(), "Wi-Fi")
534534+ return ip, config{IP: ip, Device: "phone", Detail: "wifi", Summary: "phone (Wi-Fi)"}
535535+ case "vpn":
536536+ ip := detectAndConfirmIP(detectVPNIP(), "VPN")
537537+ return ip, config{IP: ip, Device: "phone", Detail: "vpn", Summary: "phone (VPN)"}
538538+ default:
539539+ ip := promptIP("")
540540+ return ip, config{IP: ip, Device: "phone", Detail: "manual", Summary: "phone (manual IP)"}
541541+ }
542542+}
543543+544544+func detectAndConfirmIP(detected, label string) string {
545545+ if detected == "" {
546546+ fmt.Printf(" Could not auto-detect your %s IP address.\n", label)
547547+ return promptIP("")
548548+ }
549549+ return promptIP(detected)
550550+}
551551+552552+func promptIP(defaultVal string) string {
553553+ ip := defaultVal
554554+555555+ title := "Enter your PC's IP address"
556556+ if defaultVal != "" {
557557+ title = fmt.Sprintf("Enter your PC's IP address (detected: %s)", defaultVal)
558558+ }
559559+560560+ err := huh.NewInput().
561561+ Title(title).
562562+ Description("Press Enter to accept, or type a different address.").
563563+ Validate(func(s string) error {
564564+ if net.ParseIP(strings.TrimSpace(s)) == nil {
565565+ return fmt.Errorf("not a valid IP address")
566566+ }
567567+ return nil
568568+ }).
569569+ Value(&ip).
570570+ Run()
571571+ if err != nil {
572572+ os.Exit(1)
573573+ }
574574+ return strings.TrimSpace(ip)
575575+}
576576+577577+func detectLANIP() string {
578578+ ifaces, err := net.Interfaces()
579579+ if err != nil {
580580+ return ""
581581+ }
582582+583583+ skipPrefixes := []string{"docker", "veth", "br-", "virbr", "tailscale", "zt", "tun", "utun"}
584584+ for _, iface := range ifaces {
585585+ if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
586586+ continue
587587+ }
588588+ name := strings.ToLower(iface.Name)
589589+ skip := false
590590+ for _, prefix := range skipPrefixes {
591591+ if strings.HasPrefix(name, prefix) {
592592+ skip = true
593593+ break
594594+ }
595595+ }
596596+ if skip {
597597+ continue
598598+ }
599599+600600+ addrs, err := iface.Addrs()
601601+ if err != nil {
602602+ continue
603603+ }
604604+ for _, addr := range addrs {
605605+ ipNet, ok := addr.(*net.IPNet)
606606+ if !ok {
607607+ continue
608608+ }
609609+ ip := ipNet.IP.To4()
610610+ if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
611611+ continue
612612+ }
613613+ return ip.String()
614614+ }
615615+ }
616616+ return ""
617617+}
618618+619619+func detectVPNIP() string {
620620+ if path, err := exec.LookPath("tailscale"); err == nil {
621621+ out, err := exec.Command(path, "ip", "-4").Output()
622622+ if err == nil {
623623+ ip := strings.TrimSpace(string(out))
624624+ if net.ParseIP(ip) != nil {
625625+ return ip
626626+ }
627627+ }
628628+ }
629629+630630+ _, tailscaleNet, _ := net.ParseCIDR("100.64.0.0/10")
631631+ vpnPrefixes := []string{"tailscale", "zt", "tun", "utun", "wg"}
632632+633633+ ifaces, err := net.Interfaces()
634634+ if err != nil {
635635+ return ""
636636+ }
637637+ for _, iface := range ifaces {
638638+ if iface.Flags&net.FlagUp == 0 {
639639+ continue
640640+ }
641641+ name := strings.ToLower(iface.Name)
642642+ isVPN := false
643643+ for _, prefix := range vpnPrefixes {
644644+ if strings.HasPrefix(name, prefix) {
645645+ isVPN = true
646646+ break
647647+ }
648648+ }
649649+650650+ addrs, err := iface.Addrs()
651651+ if err != nil {
652652+ continue
653653+ }
654654+ for _, addr := range addrs {
655655+ ipNet, ok := addr.(*net.IPNet)
656656+ if !ok {
657657+ continue
658658+ }
659659+ ip := ipNet.IP.To4()
660660+ if ip == nil {
661661+ continue
662662+ }
663663+ if isVPN || tailscaleNet.Contains(ip) {
664664+ return ip.String()
665665+ }
666666+ }
667667+ }
668668+ return ""
669669+}
670670+671671+func loadConfig() (config, error) {
672672+ data, err := os.ReadFile(configFile)
673673+ if err != nil {
674674+ return config{}, err
675675+ }
676676+ var cfg config
677677+ if err := json.Unmarshal(data, &cfg); err != nil {
678678+ return config{}, err
679679+ }
680680+ if cfg.IP == "" {
681681+ return config{}, fmt.Errorf("empty config")
682682+ }
683683+ return cfg, nil
684684+}
685685+686686+func saveConfig(cfg config) {
687687+ data, err := json.MarshalIndent(cfg, "", " ")
688688+ if err != nil {
689689+ return
690690+ }
691691+ _ = os.WriteFile(configFile, append(data, '\n'), 0644)
692692+}
693693+694694+func launchDev(ip string) {
695695+ cmd := exec.Command("go", "run", "./cmd/dev",
696696+ "--cdn.public-addr", ip+":8080",
697697+ "--grpc.public-addr", ip+":8003",
698698+ )
699699+ cmd.Stdout = os.Stdout
700700+ cmd.Stderr = os.Stderr
701701+ cmd.Stdin = os.Stdin
702702+703703+ sigCh := make(chan os.Signal, 1)
704704+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
705705+706706+ if err := cmd.Start(); err != nil {
707707+ fmt.Fprintf(os.Stderr, "Failed to start dev server: %v\n", err)
708708+ os.Exit(1)
709709+ }
710710+711711+ go func() {
712712+ <-sigCh
713713+ _ = cmd.Process.Signal(os.Interrupt)
714714+ }()
715715+716716+ if err := cmd.Wait(); err != nil {
717717+ if exitErr, ok := err.(*exec.ExitError); ok {
718718+ os.Exit(exitErr.ExitCode())
719719+ }
720720+ os.Exit(1)
721721+ }
722722+}