app: Magpie is in the Start menu on Windows (#158: Windows 便携版首次运行后,无法在开始菜单 / 系统搜索中找到 Magpie — @Aimer779) — the Windows build is one portable exe with no installer, so nothing put it anywhere Windows search looks. Each start of the desktop app (not the CLI, TUI or serve) now makes sure %APPDATA%\Microsoft\Windows\Start Menu\Programs\Magpie.lnk (the Programs known folder) opens the exe it runs as, symlinks resolved, in its folder, with its icon and "Magpie" as its description; one that already does is left be, one for where the exe was before it was moved is pointed here again. It is the shell's own link, made through WScript.Shell with go-ole (already in the build through Wails, now a direct require), in the background beside the magpie:// registration, and a failure is only logged. An update replaces the exe in place, so the shortcut stays good; a magpie left running from magpie.exe.old(-N) (or .new) points it at magpie.exe, never at what was moved aside. HKCU App Paths\magpie.exe is set to the exe (and its folder) so Win+R magpie opens it too. No uninstall entry, nothing copied. MAGPIE_NO_SHORTCUT=1 skips it (tests, sandboxes); other systems do nothing. Tested with the Windows test built here and run on Windows 11: a shortcut made, left as it was on a second start, pointed at the moved exe. Run on Windows 11 as the desktop app: the shortcut made with the exe's folder and icon, pointed anew after the exe moved, left as it was on a start with nothing changed; App Paths set

This commit is contained in:
yetone committed 2026-09-28 22:44:36 +08:00
1 parent 63f9fa7465
commit 839c762098
7 files changed
+250 -1

No files matched your search

+1 -1
View File
@@ -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
+3
View File
@@ -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 := ""
+58
View File
@@ -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))
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package shortcut
func ensure(string) error { return nil }
+40
View File
@@ -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)
}
}
}
+108
View File
@@ -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
}
@@ -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)
}
}