Skip to content

Commit 44ba8d6

Browse files
committed
feat: add file-source autoplay and desktop startup
1 parent fd7ec62 commit 44ba8d6

22 files changed

Lines changed: 1623 additions & 65 deletions
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//go:build windows
2+
3+
package main
4+
5+
import (
6+
"log"
7+
"os"
8+
9+
"tvremote/internal/autostart"
10+
11+
"fyne.io/systray"
12+
13+
"tvremote/internal/i18n"
14+
)
15+
16+
// ── Advanced settings: start at login ──
17+
//
18+
// Unlike the custom mpv path, this preference is not stored in config.json and
19+
// has no core route: the Windows startup list itself is the state (see
20+
// internal/autostart). The tray owns it because it is a property of this
21+
// installed copy of TinyPlay, not of the media setup the phone configures.
22+
23+
// launchedAtLogin reports whether Windows started this process from the Run
24+
// key rather than the user double-clicking the app. The QR window is opened on
25+
// an ordinary launch because that is what someone who just started TinyPlay
26+
// wants to see; doing it on every sign-in would put a window in front of
27+
// whatever they were actually there to do.
28+
func launchedAtLogin() bool {
29+
for _, arg := range os.Args[1:] {
30+
if arg == "--autostart" {
31+
return true
32+
}
33+
}
34+
return false
35+
}
36+
37+
// readAutostart reads the current state for the tray checkbox. A read failure
38+
// is reported as "off" — the same thing the user sees for a genuinely absent
39+
// entry, and clicking the item then simply tries to write it.
40+
func readAutostart() bool {
41+
on, err := autostart.Enabled()
42+
if err != nil {
43+
log.Printf("autostart: read failed: %v", err)
44+
return false
45+
}
46+
return on
47+
}
48+
49+
// toggleAutostart flips the startup entry and re-syncs the menu checkbox from
50+
// what the OS reports afterwards, rather than from what we asked for, so a
51+
// failed write leaves the checkbox telling the truth.
52+
func toggleAutostart(item *systray.MenuItem) {
53+
target := !readAutostart()
54+
if err := autostart.SetEnabled(target); err != nil {
55+
log.Printf("autostart: set %v failed: %v", target, err)
56+
messageBoxOK(i18n.System("autostart_failed_title"), i18n.System("autostart_failed_body", err.Error()))
57+
}
58+
applyAutostartCheckbox(item)
59+
}
60+
61+
func applyAutostartCheckbox(item *systray.MenuItem) {
62+
if readAutostart() {
63+
item.Check()
64+
} else {
65+
item.Uncheck()
66+
}
67+
}

cmd/tvremote/shell_windows.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"fyne.io/systray"
2121
webview2 "github.com/jchv/go-webview2"
2222

23+
"tvremote/internal/autostart"
2324
"tvremote/internal/config"
2425
"tvremote/internal/i18n"
2526
)
@@ -79,6 +80,7 @@ func runShell(localURL string, httpSrv *http.Server) {
7980
mAdvanced := systray.AddMenuItem(i18n.System("advanced_settings"), i18n.System("advanced_settings_tip"))
8081
mMPVCustom := mAdvanced.AddSubMenuItem(i18n.System("mpv_custom_menu"), i18n.System("mpv_custom_menu_tip"))
8182
mMPVRestore := mAdvanced.AddSubMenuItem(i18n.System("mpv_restore_default_menu"), i18n.System("mpv_restore_default_menu_tip"))
83+
mAutostart := mAdvanced.AddSubMenuItemCheckbox(i18n.System("autostart_menu"), i18n.System("autostart_menu_tip"), readAutostart())
8284
systray.AddSeparator()
8385
mFeedback := systray.AddMenuItem(i18n.System("feedback"), i18n.System("feedback_tip"))
8486
systray.AddSeparator()
@@ -97,6 +99,15 @@ func runShell(localURL string, httpSrv *http.Server) {
9799
}
98100
go refreshMPVTooltip()
99101

102+
// An in-place upgrade can land TinyPlay.exe in a different directory
103+
// than the one recorded when autostart was switched on. Repair the
104+
// entry here rather than leaving the user with a checkbox that reads
105+
// "on" and a startup command that points at nothing.
106+
if err := autostart.Sync(); err != nil {
107+
log.Printf("autostart: sync failed: %v", err)
108+
}
109+
applyAutostartCheckbox(mAutostart)
110+
100111
applyLanguage := func(language string) {
101112
config.SetLanguage(language)
102113
mOpen.SetTitle(i18n.System("open_main"))
@@ -108,6 +119,8 @@ func runShell(localURL string, httpSrv *http.Server) {
108119
mMPVCustom.SetTooltip(i18n.System("mpv_custom_menu_tip"))
109120
mMPVRestore.SetTitle(i18n.System("mpv_restore_default_menu"))
110121
mMPVRestore.SetTooltip(i18n.System("mpv_restore_default_menu_tip"))
122+
mAutostart.SetTitle(i18n.System("autostart_menu"))
123+
mAutostart.SetTooltip(i18n.System("autostart_menu_tip"))
111124
refreshMPVTooltip()
112125
mFeedback.SetTitle(i18n.System("feedback"))
113126
mFeedback.SetTooltip(i18n.System("feedback_tip"))
@@ -140,6 +153,10 @@ func runShell(localURL string, httpSrv *http.Server) {
140153
}()
141154
case <-mMPVRestore.ClickedCh:
142155
go runRestoreDefaultMPV(coreURL, func(mpvStatusResponse) { refreshMPVTooltip() })
156+
case <-mAutostart.ClickedCh:
157+
// Registry write plus a MessageBox on failure: off the
158+
// menu-event loop, like the mpv items above.
159+
go toggleAutostart(mAutostart)
143160
case <-mFeedback.ClickedCh:
144161
openFeedbackEmail()
145162
case <-mQuit.ClickedCh:
@@ -178,8 +195,12 @@ func runShell(localURL string, httpSrv *http.Server) {
178195
checkForTinyPlayUpdates(false)
179196
}()
180197

181-
// Show the window once on first launch so users see the QR immediately.
182-
go openWindow(desktopURL())
198+
// Show the window once on first launch so users see the QR immediately —
199+
// except when Windows started us at sign-in, where the point of the
200+
// feature is to be ready in the tray without interrupting anyone.
201+
if !launchedAtLogin() {
202+
go openWindow(desktopURL())
203+
}
183204
}
184205

185206
onExit := func() {

internal/autostart/autostart.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Package autostart toggles "start TinyPlay when this computer starts".
2+
//
3+
// This is a desktop-shell preference, not a phone-facing one: it is reachable
4+
// only from the tray / menu-bar "高级设置" submenu, next to the custom mpv
5+
// player entry, and it is off until the user turns it on.
6+
//
7+
// The operating system — not config.json — is the source of truth. On Windows
8+
// that is the per-user HKCU Run key; on macOS the menu-bar shell owns the
9+
// equivalent through SMAppService (a login item must be registered by the app
10+
// bundle itself, which the Go core, a child process, cannot do), so this
11+
// package reports itself unsupported there. Keeping the OS authoritative means
12+
// a value the user removed by hand — in Task Manager's Startup tab, or in
13+
// System Settings → Login Items — is never silently resurrected by a stale
14+
// copy of the flag on disk.
15+
package autostart
16+
17+
// Supported reports whether this platform has an implementation. Callers must
18+
// hide the menu entry entirely when it is false rather than show a control
19+
// that cannot do anything.
20+
func Supported() bool { return supported }
21+
22+
// Enabled reports whether TinyPlay is currently registered to start at login.
23+
func Enabled() (bool, error) { return enabled() }
24+
25+
// SetEnabled registers or unregisters TinyPlay for login start. Enabling
26+
// always (re)writes the current executable path, so calling it again after a
27+
// move or an upgrade repairs a stale entry.
28+
func SetEnabled(on bool) error { return setEnabled(on) }
29+
30+
// Sync repairs an entry that points at an executable which has since moved —
31+
// an in-place upgrade into a different directory, or a portable copy relocated
32+
// by hand. It is a no-op when autostart is off, and best-effort: a failure to
33+
// repair must never keep the app from starting, so callers ignore the error.
34+
func Sync() error { return syncPath() }
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//go:build !windows
2+
3+
package autostart
4+
5+
// Not implemented in Go outside Windows. On macOS the login item belongs to
6+
// the app bundle and is registered by the AppKit shell through SMAppService
7+
// (see macos/Sources/main.swift); the Go core runs as that bundle's child
8+
// process and must not try to register itself as a separate login item.
9+
const supported = false
10+
11+
func enabled() (bool, error) { return false, nil }
12+
13+
func setEnabled(bool) error { return nil }
14+
15+
func syncPath() error { return nil }
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//go:build windows
2+
3+
package autostart
4+
5+
import (
6+
"errors"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"golang.org/x/sys/windows/registry"
12+
)
13+
14+
const supported = true
15+
16+
// runKeyPath is the per-user startup list. HKCU rather than HKLM on purpose:
17+
// TinyPlay installs per-user (see windows/TinyPlay.iss, PrivilegesRequired=lowest),
18+
// so writing a machine-wide entry would both need elevation and start the app
19+
// for accounts that never installed it.
20+
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
21+
22+
// runValueName is the value under that key. It doubles as the label Windows
23+
// shows in Task Manager's Startup tab, so it stays the product name. A var,
24+
// not a const, so the test can use a throwaway name instead of touching the
25+
// real user's startup list.
26+
var runValueName = "TinyPlay"
27+
28+
// autostartFlag marks a login-triggered launch so the shell can stay quietly
29+
// in the tray instead of opening the QR window on every sign-in. See
30+
// launchedAtLogin in cmd/tvremote/shell_windows.go.
31+
const autostartFlag = "--autostart"
32+
33+
// startupCommand is what gets written to the Run value: the absolute path to
34+
// this executable, quoted (install paths contain spaces — "Program Files",
35+
// and per-user installs land under a folder named after the account), plus
36+
// the flag that tells the shell it was started by the system.
37+
func startupCommand() (string, error) {
38+
exe, err := os.Executable()
39+
if err != nil {
40+
return "", err
41+
}
42+
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
43+
exe = resolved
44+
}
45+
exe, err = filepath.Abs(exe)
46+
if err != nil {
47+
return "", err
48+
}
49+
return `"` + exe + `" ` + autostartFlag, nil
50+
}
51+
52+
// readValue returns the current Run value, or "" when autostart is off. A
53+
// missing key and a missing value are both simply "off" — the Run key does not
54+
// exist at all on a fresh account until something registers.
55+
func readValue() (string, error) {
56+
k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE)
57+
if err != nil {
58+
if errors.Is(err, registry.ErrNotExist) {
59+
return "", nil
60+
}
61+
return "", err
62+
}
63+
defer k.Close()
64+
value, _, err := k.GetStringValue(runValueName)
65+
if err != nil {
66+
if errors.Is(err, registry.ErrNotExist) {
67+
return "", nil
68+
}
69+
return "", err
70+
}
71+
return value, nil
72+
}
73+
74+
func enabled() (bool, error) {
75+
value, err := readValue()
76+
if err != nil {
77+
return false, err
78+
}
79+
return strings.TrimSpace(value) != "", nil
80+
}
81+
82+
func setEnabled(on bool) error {
83+
k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE)
84+
if err != nil {
85+
return err
86+
}
87+
defer k.Close()
88+
if !on {
89+
if err := k.DeleteValue(runValueName); err != nil && !errors.Is(err, registry.ErrNotExist) {
90+
return err
91+
}
92+
return nil
93+
}
94+
command, err := startupCommand()
95+
if err != nil {
96+
return err
97+
}
98+
return k.SetStringValue(runValueName, command)
99+
}
100+
101+
func syncPath() error {
102+
current, err := readValue()
103+
if err != nil || strings.TrimSpace(current) == "" {
104+
return err
105+
}
106+
want, err := startupCommand()
107+
if err != nil {
108+
return err
109+
}
110+
if current == want {
111+
return nil
112+
}
113+
return setEnabled(true)
114+
}

0 commit comments

Comments
 (0)