From 839c762098f2f34dc68aa73cb693af7b9aba4aba Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 22:42:00 +0800 Subject: [PATCH] =?UTF-8?q?app:=20Magpie=20is=20in=20the=20Start=20menu=20?= =?UTF-8?q?on=20Windows=20(#158:=20Windows=20=E4=BE=BF=E6=90=BA=E7=89=88?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E8=BF=90=E8=A1=8C=E5=90=8E=EF=BC=8C=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=9C=A8=E5=BC=80=E5=A7=8B=E8=8F=9C=E5=8D=95=20/=20?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=90=9C=E7=B4=A2=E4=B8=AD=E6=89=BE=E5=88=B0?= =?UTF-8?q?=20Magpie=20=E2=80=94=20@Aimer779)=20=E2=80=94=20the=20Windows?= =?UTF-8?q?=20build=20is=20one=20portable=20exe=20with=20no=20installer,?= =?UTF-8?q?=20so=20nothing=20put=20it=20anywhere=20Windows=20search=20look?= =?UTF-8?q?s.=20Each=20start=20of=20the=20desktop=20app=20(not=20the=20CLI?= =?UTF-8?q?,=20TUI=20or=20serve)=20now=20makes=20sure=20%APPDATA%\Microsof?= =?UTF-8?q?t\Windows\Start=20Menu\Programs\Magpie.lnk=20(the=20Programs=20?= =?UTF-8?q?known=20folder)=20opens=20the=20exe=20it=20runs=20as,=20symlink?= =?UTF-8?q?s=20resolved,=20in=20its=20folder,=20with=20its=20icon=20and=20?= =?UTF-8?q?"Magpie"=20as=20its=20description;=20one=20that=20already=20doe?= =?UTF-8?q?s=20is=20left=20be,=20one=20for=20where=20the=20exe=20was=20bef?= =?UTF-8?q?ore=20it=20was=20moved=20is=20pointed=20here=20again.=20It=20is?= =?UTF-8?q?=20the=20shell's=20own=20link,=20made=20through=20WScript.Shell?= =?UTF-8?q?=20with=20go-ole=20(already=20in=20the=20build=20through=20Wail?= =?UTF-8?q?s,=20now=20a=20direct=20require),=20in=20the=20background=20bes?= =?UTF-8?q?ide=20the=20magpie://=20registration,=20and=20a=20failure=20is?= =?UTF-8?q?=20only=20logged.=20An=20update=20replaces=20the=20exe=20in=20p?= =?UTF-8?q?lace,=20so=20the=20shortcut=20stays=20good;=20a=20magpie=20left?= =?UTF-8?q?=20running=20from=20magpie.exe.old(-N)=20(or=20.new)=20points?= =?UTF-8?q?=20it=20at=20magpie.exe,=20never=20at=20what=20was=20moved=20as?= =?UTF-8?q?ide.=20HKCU=20App=20Paths\magpie.exe=20is=20set=20to=20the=20ex?= =?UTF-8?q?e=20(and=20its=20folder)=20so=20Win+R=20magpie=20opens=20it=20t?= =?UTF-8?q?oo.=20No=20uninstall=20entry,=20nothing=20copied.=20MAGPIE=5FNO?= =?UTF-8?q?=5FSHORTCUT=3D1=20skips=20it=20(tests,=20sandboxes);=20other=20?= =?UTF-8?q?systems=20do=20nothing.=20Tested=20with=20the=20Windows=20test?= =?UTF-8?q?=20built=20here=20and=20run=20on=20Windows=2011:=20a=20shortcut?= =?UTF-8?q?=20made,=20left=20as=20it=20was=20on=20a=20second=20start,=20po?= =?UTF-8?q?inted=20at=20the=20moved=20exe.=20Run=20on=20Windows=2011=20as?= =?UTF-8?q?=20the=20desktop=20app:=20the=20shortcut=20made=20with=20the=20?= =?UTF-8?q?exe's=20folder=20and=20icon,=20pointed=20anew=20after=20the=20e?= =?UTF-8?q?xe=20moved,=20left=20as=20it=20was=20on=20a=20start=20with=20no?= =?UTF-8?q?thing=20changed;=20App=20Paths=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- internal/gui/app.go | 3 + internal/shortcut/shortcut.go | 58 +++++++++++ internal/shortcut/shortcut_other.go | 5 + internal/shortcut/shortcut_test.go | 40 ++++++++ internal/shortcut/shortcut_windows.go | 108 +++++++++++++++++++++ internal/shortcut/shortcut_windows_test.go | 35 +++++++ 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 internal/shortcut/shortcut.go create mode 100644 internal/shortcut/shortcut_other.go create mode 100644 internal/shortcut/shortcut_test.go create mode 100644 internal/shortcut/shortcut_windows.go create mode 100644 internal/shortcut/shortcut_windows_test.go diff --git a/go.mod b/go.mod index d69ad305..7172e466 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/charmbracelet/bubbles v0.21.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/go-ole/go-ole v1.3.0 github.com/godbus/dbus/v5 v5.2.2 github.com/klauspost/compress v1.18.3 github.com/pelletier/go-toml/v2 v2.2.4 @@ -32,7 +33,6 @@ require ( github.com/coder/websocket v1.8.14 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/internal/gui/app.go b/internal/gui/app.go index cb041a90..c9ec7c05 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -21,6 +21,7 @@ import ( "github.com/yetone/magpie/internal/library" "github.com/yetone/magpie/internal/settings" + "github.com/yetone/magpie/internal/shortcut" "github.com/yetone/magpie/internal/stats" "github.com/yetone/magpie/internal/update" ) @@ -160,6 +161,8 @@ func Run(version string, showMain bool, link string) error { if err := registerScheme(); err != nil { log.Println("magpie:// links:", err) } + // Windows has no installer to put magpie in the Start menu + shortcut.Ensure() }() // MAGPIE_THEME=light|dark forces the palette; handy for screenshots. theme := "" diff --git a/internal/shortcut/shortcut.go b/internal/shortcut/shortcut.go new file mode 100644 index 00000000..543f5816 --- /dev/null +++ b/internal/shortcut/shortcut.go @@ -0,0 +1,58 @@ +// Package shortcut puts magpie in the Start menu on Windows. There it is +// one portable exe with no installer, so nothing else would: each start of +// the desktop app points a Magpie shortcut in the user's Start menu at the +// exe it runs as, and Windows search finds it by that. Elsewhere it does +// nothing — the Mac has the app in Applications, Linux its own ways. +package shortcut + +import ( + "log" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Name is what the shortcut is called, and what Windows search finds. +const Name = "Magpie" + +// Ensure has the shortcut point at this magpie, when it doesn't yet. It +// fails quietly — magpie runs the same without one — and is skipped with +// MAGPIE_NO_SHORTCUT=1 (tests, sandboxes). +func Ensure() { + if os.Getenv("MAGPIE_NO_SHORTCUT") == "1" { + return + } + exe, err := os.Executable() + if err != nil { + return + } + if p, err := filepath.EvalSymlinks(exe); err == nil { + exe = p + } + if err := ensure(Target(exe)); err != nil { + log.Println("start menu shortcut:", err) + } +} + +// aside is what an update moves the exe it replaces to, or stages the new +// one as: magpie.exe.old, magpie.exe.old-2…, magpie.exe.new +var aside = regexp.MustCompile(`(?i)(\.exe)\.(old(-\d+)?|new)$`) + +// Target is what the shortcut opens for exe: exe itself, but for a magpie +// left running from where an update moved it aside, the exe in its place, +// which is what the next start runs. An update replaces the exe where it +// is, so the shortcut stays good through it. +func Target(exe string) string { + return aside.ReplaceAllString(exe, "$1") +} + +// Stale reports whether a shortcut opening target in dir is not one for +// exe: there is none yet, or the exe was moved. Windows paths are the same +// in any case. +func Stale(target, dir, exe string) bool { + same := func(a, b string) bool { + return a != "" && strings.EqualFold(filepath.Clean(a), filepath.Clean(b)) + } + return !same(target, exe) || !same(dir, filepath.Dir(exe)) +} diff --git a/internal/shortcut/shortcut_other.go b/internal/shortcut/shortcut_other.go new file mode 100644 index 00000000..b4948e31 --- /dev/null +++ b/internal/shortcut/shortcut_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package shortcut + +func ensure(string) error { return nil } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go new file mode 100644 index 00000000..3598f2d7 --- /dev/null +++ b/internal/shortcut/shortcut_test.go @@ -0,0 +1,40 @@ +package shortcut + +import "testing" + +func TestTarget(t *testing.T) { + for in, want := range map[string]string{ + `C:\Tools\magpie-windows-amd64.exe`: `C:\Tools\magpie-windows-amd64.exe`, + `C:\Tools\magpie.exe.old`: `C:\Tools\magpie.exe`, + `C:\Tools\magpie.exe.old-3`: `C:\Tools\magpie.exe`, + `C:\Tools\magpie.exe.new`: `C:\Tools\magpie.exe`, + `C:\Tools\MAGPIE.EXE.OLD`: `C:\Tools\MAGPIE.EXE`, + `C:\old\magpie.exe`: `C:\old\magpie.exe`, + `C:\Tools\magpie.exe.older`: `C:\Tools\magpie.exe.older`, + `/usr/local/bin/magpie`: `/usr/local/bin/magpie`, + `C:\Users\me\Downloads\magpie (1).exe.old-2`: `C:\Users\me\Downloads\magpie (1).exe`, + } { + if got := Target(in); got != want { + t.Errorf("Target(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStale(t *testing.T) { + exe := "/tools/magpie.exe" + for _, c := range []struct { + target, dir string + stale bool + }{ + {"", "", true}, // no shortcut yet + {"/tools/magpie.exe", "/tools", false}, // already this one + {"/TOOLS/Magpie.EXE", "/Tools/", false}, // the same, in another case + {"/downloads/magpie.exe", "/tools", true}, // the exe was moved + {"/tools/magpie.exe", "/downloads", true}, // starts in another folder + {"/tools/magpie.exe", "", true}, // in no folder + } { + if got := Stale(c.target, c.dir, exe); got != c.stale { + t.Errorf("Stale(%q, %q) = %v, want %v", c.target, c.dir, got, c.stale) + } + } +} diff --git a/internal/shortcut/shortcut_windows.go b/internal/shortcut/shortcut_windows.go new file mode 100644 index 00000000..c9fa6428 --- /dev/null +++ b/internal/shortcut/shortcut_windows.go @@ -0,0 +1,108 @@ +package shortcut + +import ( + "errors" + "os" + "path/filepath" + "runtime" + + ole "github.com/go-ole/go-ole" + "github.com/go-ole/go-ole/oleutil" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// appPath is where Win+R and `start magpie` look magpie.exe up: the user's +// own, no administrator needed +const appPath = `Software\Microsoft\Windows\CurrentVersion\App Paths\magpie.exe` + +func ensure(exe string) error { + if k, _, err := registry.CreateKey(registry.CURRENT_USER, appPath, registry.SET_VALUE); err == nil { + _ = k.SetStringValue("", exe) + _ = k.SetStringValue("Path", filepath.Dir(exe)) + k.Close() + } + dir, err := programs() + if err != nil { + return err + } + _, err = write(filepath.Join(dir, Name+".lnk"), exe) + return err +} + +// programs is the user's Start menu Programs folder. +func programs() (string, error) { + if p, err := windows.KnownFolderPath(windows.FOLDERID_Programs, windows.KF_FLAG_CREATE); err == nil && p != "" { + return p, nil + } + a := os.Getenv("APPDATA") + if a == "" { + return "", errors.New("no Start menu folder") + } + p := filepath.Join(a, `Microsoft\Windows\Start Menu\Programs`) + return p, os.MkdirAll(p, 0o755) +} + +// write has the shortcut at lnk open exe, in exe's folder, with its icon, +// and reports whether it had to: one already opening exe is left as it is. +// It is Windows' own shell link, made by the shell's WScript.Shell. +func write(lnk, exe string) (bool, error) { + // COM is set up per thread; this one keeps the goroutine + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil { + var oe *ole.OleError + switch { + case !errors.As(err, &oe): + return false, err + case oe.Code() == 1: // S_FALSE: set up already, still ours to undo + defer ole.CoUninitialize() + case oe.Code() != 0x80010106: // RPC_E_CHANGED_MODE: set up otherwise, usable + return false, err + } + } else { + defer ole.CoUninitialize() + } + unk, err := oleutil.CreateObject("WScript.Shell") + if err != nil { + return false, err + } + defer unk.Release() + shell, err := unk.QueryInterface(ole.IID_IDispatch) + if err != nil { + return false, err + } + defer shell.Release() + // the shortcut at lnk, read in when there is one + v, err := oleutil.CallMethod(shell, "CreateShortcut", lnk) + if err != nil { + return false, err + } + sc := v.ToIDispatch() + defer sc.Release() + prop := func(name string) string { + p, err := oleutil.GetProperty(sc, name) + if err != nil { + return "" + } + defer p.Clear() + return p.ToString() + } + if !Stale(prop("TargetPath"), prop("WorkingDirectory"), exe) { + return false, nil + } + for name, val := range map[string]any{ + "TargetPath": exe, + "WorkingDirectory": filepath.Dir(exe), + "IconLocation": exe + ",0", + "Description": Name, + } { + if _, err := oleutil.PutProperty(sc, name, val); err != nil { + return false, err + } + } + if _, err := oleutil.CallMethod(sc, "Save"); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/shortcut/shortcut_windows_test.go b/internal/shortcut/shortcut_windows_test.go new file mode 100644 index 00000000..a3762537 --- /dev/null +++ b/internal/shortcut/shortcut_windows_test.go @@ -0,0 +1,35 @@ +package shortcut + +import ( + "os" + "path/filepath" + "testing" +) + +// A shortcut is made, left be while it opens this exe, and pointed at the +// exe again once that was moved. +func TestWrite(t *testing.T) { + dir := t.TempDir() + lnk := filepath.Join(dir, Name+".lnk") + exe := filepath.Join(dir, "a", "magpie.exe") + moved := filepath.Join(dir, "b", "magpie.exe") + for _, f := range []string{exe, moved} { + os.MkdirAll(filepath.Dir(f), 0o755) + os.WriteFile(f, []byte("MZ"), 0o755) + } + for i, c := range []struct { + exe string + written bool + }{{exe, true}, {exe, false}, {moved, true}, {moved, false}} { + written, err := write(lnk, c.exe) + if err != nil { + t.Fatal(i, err) + } + if written != c.written { + t.Fatalf("%d: written = %v, want %v", i, written, c.written) + } + } + if _, err := os.Stat(lnk); err != nil { + t.Fatal("no shortcut:", err) + } +}