Skip to content

Commit

Permalink
Showing 12 changed files with 1,231 additions and 27 deletions.
42 changes: 42 additions & 0 deletions cmd/sni/apps.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# This file defines application shortcuts for the Applications menu.
# To install, copy this file to your configuration directory:
# On MacOS and Linux, this is "$HOME/.sni/"
# On Windows, this is "%LOCALAPPDATA%/sni/"

# `apps` is a list of application shortcuts
apps:

- # `name` is what is displayed in the Applications menu:
name: RetroArch
# `os` specifies that this app is only launchable on this particular operating system
# if `os` is missing/empty, then the Application will appear on all operating systems.
# valid values are:
# "windows" = Windows
# "linux" = Linux
# "darwin" = MacOS
os: darwin
# `path` is the location of the executable to open:
# On MacOS, app bundle folders that end in .app are launched via `open -a <path> <args...>`
# On Windows and Linux, executable files are launched normally
path: /Applications/RetroArch.app

- name: RetroArch
os: windows
path: C:\RetroArch-Win64\RetroArch.exe

- name: OpenTracker
os: darwin
# `dir` is the current directory that the executable is launched from:
dir: $HOME/Developer/me/alttpo/OpenTracker/OpenTracker/bin/Debug/net5.0
# `path` does not have to be an absolute path, and could be found in the system lookup $PATH
path: dotnet
# `args` is the list of arguments passed to the application:
args:
- OpenTracker.dll

- name: SNI Home Page
# `url` will open the given URL when clicked in the menu:
# On MacOS, URLs are launched via `open <url>`
# On Windows, URLs are launched via `start <url>`
# On Linux, URLs are launched via `xdg-open <url>`
url: https://github.com/alttpo/sni
121 changes: 121 additions & 0 deletions cmd/sni/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package config

import (
"fmt"
"github.com/fsnotify/fsnotify"
"github.com/spf13/viper"
"log"
"os"
"path/filepath"
"runtime"
"sni/ob"
)

var (
ConfigObservable ob.Observable
configObservable = ob.NewObservable()
ConfigPath string

AppsObservable ob.Observable
appsObservable = ob.NewObservable()
AppsPath string
)

var VerboseLogging bool = false

var (
Config *viper.Viper = viper.New()
Apps *viper.Viper = viper.New()
)

func Load() {
log.Printf("config: load\n")

loadConfig()
loadApps()
}

func Save() {
var err error

log.Printf("config: save\n")

err = Config.WriteConfigAs(ConfigPath)
if err != nil {
log.Printf("config: save: %s\n", err)
return
}
}

func loadConfig() {
ConfigObservable = configObservable

// load configuration:
Config.SetEnvPrefix("SNI")
configFilename := "config"
Config.SetConfigName(configFilename)
Config.SetConfigType("yaml")
if runtime.GOOS == "windows" {
ConfigPath = os.ExpandEnv("$LOCALAPPDATA/sni/")
_ = os.Mkdir(ConfigPath, 0644|os.ModeDir)
Config.AddConfigPath(ConfigPath)
} else {
ConfigPath = os.ExpandEnv("$HOME/.sni/")
Config.AddConfigPath(ConfigPath)
}
ConfigPath = filepath.Join(ConfigPath, fmt.Sprintf("%s.yaml", configFilename))

Config.OnConfigChange(func(_ fsnotify.Event) {
log.Printf("config: %s.yaml modified\n", configFilename)
configObservable.ObjectPublish(Config)
})
Config.WatchConfig()

err := Config.ReadInConfig()
if err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// no problem.
} else {
log.Printf("%s\n", err)
}
return
}

configObservable.ObjectPublish(Config)
}

func loadApps() {
AppsObservable = appsObservable

// load configuration:
appsFilename := "apps"
Apps.SetConfigName(appsFilename)
Apps.SetConfigType("yaml")
if runtime.GOOS == "windows" {
AppsPath = os.ExpandEnv("$LOCALAPPDATA/sni/")
_ = os.Mkdir(AppsPath, 0644|os.ModeDir)
Apps.AddConfigPath(AppsPath)
} else {
AppsPath = os.ExpandEnv("$HOME/.sni/")
Apps.AddConfigPath(AppsPath)
}
AppsPath = filepath.Join(ConfigPath, fmt.Sprintf("%s.yaml", appsFilename))

Apps.OnConfigChange(func(_ fsnotify.Event) {
log.Printf("config: %s.yaml modified\n", appsFilename)
appsObservable.ObjectPublish(Apps)
})
Apps.WatchConfig()

err := Apps.ReadInConfig()
if err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// no problem.
} else {
log.Printf("%s\n", err)
}
return
}

appsObservable.ObjectPublish(Apps)
}
7 changes: 4 additions & 3 deletions cmd/sni/main.go
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"sni/cmd/sni/config"
"sni/cmd/sni/tray"
"sni/snes/services/grpcimpl"
"sni/snes/services/usb2snes"
@@ -38,7 +39,7 @@ var (
cpuprofile = flag.String("cpuprofile", "", "start pprof profiler on addr:port")
)

func init() {
func main() {
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)

ts := time.Now().Format("2006-01-02T15:04:05.000Z")
@@ -53,9 +54,7 @@ func init() {
log.Printf("sni %s %s built on %s by %s", version, commit, date, builtBy)
log.Printf("logging to '%s'\n", logPath)
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
}

func main() {
flag.Parse()
if *cpuprofile != "" {
go func() {
@@ -64,6 +63,8 @@ func main() {
}()
}

config.Load()

// explicitly initialize all the drivers:
fxpakpro.DriverInit()
luabridge.DriverInit()
77 changes: 77 additions & 0 deletions cmd/sni/tray/launch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package tray

import (
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
)

type appConfig struct {
Name string
Tooltip string

Os string

Dir string
Path string
Args []string

Url string
}

func launch(app *appConfig) {
path := app.Path
path = os.ExpandEnv(path)
cleanPath := filepath.Clean(path)

args := app.Args[:]
// expand environment variables like `$SNI_USB2SNES_LISTEN_HOST`:
for j, arg := range args {
args[j] = os.ExpandEnv(arg)
}

dir := app.Dir
dir = os.ExpandEnv(dir)

if app.Url != "" {
log.Printf("open: %s\n", app.Url)

var cmd *exec.Cmd
if runtime.GOOS == "darwin" {
cmd = exec.Command("open", app.Url)
} else if runtime.GOOS == "windows" {
cmd = exec.Command("start", app.Url)
} else {
cmd = exec.Command("xdg-open", app.Url)
}

err := cmd.Start()
if err != nil {
log.Printf("open: %s\n", err)
return
}

return
}

if runtime.GOOS == "darwin" {
if filepath.Ext(cleanPath) == ".app" {
// open app bundles with "open" command:
if fi, err := os.Stat(cleanPath); err == nil && fi.IsDir() {
args = append([]string{"-a", path}, args...)
path = "open"
}
}
}

log.Printf("open: %s %s\n", path, args)
cmd := exec.Command(path, args...)
cmd.Dir = dir
err := cmd.Start()
if err != nil {
log.Printf("open: %s\n", err)
return
}
}
123 changes: 118 additions & 5 deletions cmd/sni/tray/tray.go
Original file line number Diff line number Diff line change
@@ -3,14 +3,17 @@ package tray
import (
"fmt"
"github.com/getlantern/systray"
"github.com/spf13/viper"
"log"
"runtime"
"sni/cmd/sni/config"
"sni/cmd/sni/icon"
"sni/ob"
"sni/snes"
"strings"
"time"
)

var VerboseLogging bool = false

const maxItems = 10

var deviceMenuItems [maxItems]*systray.MenuItem
@@ -68,19 +71,126 @@ func trayStart() {
systray.AddMenuItem(versionText, versionTooltip)
systray.AddSeparator()
devicesMenu := systray.AddMenuItem("Devices", "")
appsMenu := systray.AddMenuItem("Applications", "")
systray.AddSeparator()
disconnectAll := systray.AddMenuItem("Disconnect SNES", "Disconnect from all connected SNES devices")
systray.AddSeparator()
toggleVerbose := systray.AddMenuItemCheckbox("Log all requests", "Enable logging of all incoming requests", VerboseLogging)
toggleVerbose := systray.AddMenuItemCheckbox("Log all requests", "Enable logging of all incoming requests", config.VerboseLogging)
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Quit")

// subscribe to configuration changes:
config.ConfigObservable.Subscribe(ob.NewObserver("logging", func(object interface{}) {
if object == nil {
return
}

v, ok := object.(*viper.Viper)
if !ok || v == nil {
return
}

config.VerboseLogging = v.GetBool("verboseLogging")
if config.VerboseLogging {
toggleVerbose.Check()
} else {
toggleVerbose.Uncheck()
}
}))

refresh := devicesMenu.AddSubMenuItem("Refresh", "Refresh list of devices")
for i := range deviceMenuItems {
deviceMenuItems[i] = devicesMenu.AddSubMenuItemCheckbox("_", "_", false)
deviceMenuItems[i].Hide()
}

appsMenuItems := make([]*systray.MenuItem, 0, 10)
appConfigs := make([]*appConfig, 0, 10)
appsMenuTooltipNone := fmt.Sprintf("Update apps.yaml to define application shortcuts: %s", config.AppsPath)
appsMenuTooltipSome := fmt.Sprintf("Application shortcuts defined by: %s", config.AppsPath)
appsMenu.SetTooltip(appsMenuTooltipNone)

// subscribe to configuration changes:
config.AppsObservable.Subscribe(ob.NewObserver("tray", func(object interface{}) {
if object == nil {
return
}

v, ok := object.(*viper.Viper)
if !ok || v == nil {
return
}

// build the apps menu:

// parse new apps config:
newApps := make([]*appConfig, 0, 10)
err := v.UnmarshalKey("apps", &newApps)
if err != nil {
log.Printf("%s\n", err)
return
}

// filter apps by OS:
filteredApps := make([]*appConfig, 0, len(newApps))
for _, app := range newApps {
if app.Os != "" {
if !strings.EqualFold(app.Os, runtime.GOOS) {
continue
}
}

filteredApps = append(filteredApps, app)
}

// replace:
appConfigs = filteredApps
if len(appConfigs) == 0 {
appsMenu.SetTooltip(appsMenuTooltipNone)
} else {
appsMenu.SetTooltip(appsMenuTooltipSome)
}

for len(appsMenuItems) < len(appConfigs) {
i := len(appsMenuItems)
menuItem := appsMenu.AddSubMenuItem("", "")
appsMenuItems = append(appsMenuItems, menuItem)

// run a click handler goroutine for this menu item:
go func() {
defer func() {
recover()
}()

for range menuItem.ClickedCh {
// skip the action if this menu item no longer exists:
if i >= len(appConfigs) {
continue
}

app := appConfigs[i]
launch(app)
}
}()
}

// set menu items:
for i, app := range appConfigs {
tooltip := app.Tooltip
if tooltip == "" {
tooltip = fmt.Sprintf("Click to launch %s at %s with args %s", app.Name, app.Path, app.Args)
}
appsMenuItems[i].SetTitle(app.Name)
appsMenuItems[i].SetTooltip(tooltip)
appsMenuItems[i].Show()
}

// hide extra menu items:
for i := len(appConfigs); i < len(appsMenuItems); i++ {
appsMenuItems[i].Hide()
}
}))

// Menu item click handler:
go func() {
refreshPeriod := time.Tick(time.Second * 2)
@@ -97,14 +207,17 @@ func trayStart() {
}
break
case <-toggleVerbose.ClickedCh:
VerboseLogging = !VerboseLogging
if VerboseLogging {
config.VerboseLogging = !config.VerboseLogging
if config.VerboseLogging {
log.Println("enable verbose logging")
toggleVerbose.Check()
} else {
log.Println("disable verbose logging")
toggleVerbose.Uncheck()
}
// update config file:
config.Config.Set("verboseLogging", config.VerboseLogging)
config.Save()
break
case <-refresh.ClickedCh:
RefreshDeviceList()
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -3,10 +3,12 @@ module sni
go 1.16

require (
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/getlantern/systray v1.2.0
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.0.4
github.com/spf13/viper v1.8.1
go.bug.st/serial v1.1.3
golang.org/x/sys v0.0.0-20210603125802-9665404d3644 // indirect
google.golang.org/grpc v1.38.0
630 changes: 622 additions & 8 deletions go.sum

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions ob/interfaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package ob

type Observable interface {
Type() ObservableType
Subscribe(observer Observer)
Unsubscribe(observer Observer)
}

type Observer interface {
Observe(object interface{})

Equals(other Observer) bool
}
194 changes: 194 additions & 0 deletions ob/observable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package ob

import (
"fmt"
"sync"
)

type ObservableType int

func (o ObservableType) String() string {
switch o {
case ObservableUnknown:
return "Unknown"
case ObservableObject:
return "Object"
case ObservableList:
return "List"
}
return fmt.Sprintf("(unexpected ObservableType value %d)", o)
}

const (
ObservableUnknown ObservableType = iota
ObservableObject
ObservableList
)

type ListOperation string

const (
// ListInit initializes the contents of the list (or replaces an existing list with new contents)
ListInit ListOperation = "init"
// ListConcat appends elements to the end of the list
ListConcat ListOperation = "concat"
)

type ListEvent struct {
// Operation denotes what happened to the list
Operation ListOperation `json:"op"`
// Elements is the data supporting the operation
Elements []interface{} `json:"e"`
}

// ObservableImpl represents either an observable Object or observable List.
// The first call to ObjectPublish() establishes the type as Object.
// The first call to ListAppendOne(), ListConcat(), or ListInit() establishes the type as List.
// The observable type cannot be changed once initialized; the call will panic if attempted.
// Calling Object() or List() will return nil until the type is established.
// List types publish ListEvent instances, reflecting the type of change made to the List.
// On first Subscribe(), an Object type will publish its last published state.
// On first Subscribe(), a List type will publish the entire list.
type ObservableImpl struct {
lock sync.Mutex
observers []Observer

observableType ObservableType

object interface{}
list []interface{}
}

func NewObservable() *ObservableImpl {
return &ObservableImpl{}
}

func (o *ObservableImpl) Type() ObservableType {
return o.observableType
}

func (o *ObservableImpl) Subscribe(observer Observer) {
if observer == nil {
return
}

defer o.lock.Unlock()
o.lock.Lock()

// make sure only one instance is subscribed:
o.unsubscribe(observer)
o.observers = append(o.observers, observer)

// send last published state:
switch o.observableType {
case ObservableUnknown:
// intentionally do nothing here since the observable is not initialized yet
break
case ObservableObject:
// objects publish the last published state on first subscribe:
observer.Observe(o.object)
break
case ObservableList:
// lists publish the entire list contents on first subscribe:
observer.Observe(ListEvent{
Operation: ListInit,
Elements: o.list,
})
break
}
}

func (o *ObservableImpl) Unsubscribe(observer Observer) {
if observer == nil {
return
}

defer o.lock.Unlock()
o.lock.Lock()

if o.observers == nil {
return
}

o.unsubscribe(observer)
}

func (o *ObservableImpl) unsubscribe(observer Observer) {
for i := len(o.observers) - 1; i >= 0; i-- {
if observer.Equals(o.observers[i]) {
o.observers = append(o.observers[0:i], o.observers[i+1:]...)
}
}
}

func (o *ObservableImpl) enforceType(mustType ObservableType) {
if o.observableType == ObservableUnknown {
// set new type:
o.observableType = mustType
} else if o.observableType != mustType {
// panic otherwise:
panic(fmt.Errorf("observable attempted to change type from %s to %s", o.observableType, mustType))
}
}

func (o *ObservableImpl) Object() interface{} {
return o.object
}

func (o *ObservableImpl) ObjectPublish(object interface{}) {
defer o.lock.Unlock()
o.lock.Lock()

o.enforceType(ObservableObject)
o.object = object
for _, observer := range o.observers {
observer.Observe(object)
}
}

func (o *ObservableImpl) List() []interface{} {
return o.list
}

func (o *ObservableImpl) ListAppendOne(newElement interface{}) {
defer o.lock.Unlock()
o.lock.Lock()

o.enforceType(ObservableList)
o.list = append(o.list, newElement)
newElements := []interface{}{newElement}
for _, observer := range o.observers {
observer.Observe(ListEvent{
Operation: ListConcat,
Elements: newElements,
})
}
}

func (o *ObservableImpl) ListAppendMany(newElements []interface{}) {
defer o.lock.Unlock()
o.lock.Lock()

o.enforceType(ObservableList)
o.list = append(o.list, newElements...)
for _, observer := range o.observers {
observer.Observe(ListEvent{
Operation: ListConcat,
Elements: newElements,
})
}
}

func (o *ObservableImpl) ListInit(newList []interface{}) {
defer o.lock.Unlock()
o.lock.Lock()

o.enforceType(ObservableList)
o.list = newList
for _, observer := range o.observers {
observer.Observe(ListEvent{
Operation: ListInit,
Elements: newList,
})
}
}
26 changes: 26 additions & 0 deletions ob/observer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ob

type ObserverFunc func(object interface{})

type observerImpl struct {
key string
observer ObserverFunc
}

func NewObserver(key string, observer ObserverFunc) Observer {
return &observerImpl{
key: key,
observer: observer,
}
}

func (o *observerImpl) Equals(other Observer) bool {
if otherImpl, ok := other.(*observerImpl); ok {
return o.key == otherImpl.key
}
return false
}

func (o *observerImpl) Observe(object interface{}) {
o.observer(object)
}
6 changes: 3 additions & 3 deletions snes/services/grpcimpl/grpc.go
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ import (
"google.golang.org/grpc/reflection"
"log"
"net"
"sni/cmd/sni/tray"
"sni/cmd/sni/config"
"sni/protos/sni"
"sni/util/env"
"strconv"
@@ -86,7 +86,7 @@ func logTimingInterceptor(
tEnd := time.Now()

var reqStr, rspStr string
if err != nil || tray.VerboseLogging {
if err != nil || config.VerboseLogging {
// format request message as string:
if reqStringer, ok := info.Server.(methodRequestStringer); ok {
reqStr = reqStringer.MethodRequestString(info.FullMethod, req)
@@ -98,7 +98,7 @@ func logTimingInterceptor(
if err != nil {
// log method, time taken, request, and error:
log.Printf(fullMethodFormatter+": %10d ns: req=`%s`, err=`%v`\n", info.FullMethod, tEnd.Sub(tStart).Nanoseconds(), reqStr, err)
} else if tray.VerboseLogging {
} else if config.VerboseLogging {
// only log normal requests+responses when verbose mode on:

// format response message as string:
17 changes: 9 additions & 8 deletions snes/services/usb2snes/usb2snes.go
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ import (
"net"
"net/http"
"net/url"
"sni/cmd/sni/config"
"sni/cmd/sni/tray"
"sni/protos/sni"
"sni/snes"
@@ -222,12 +223,12 @@ serverLoop:
}
var results response

if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s %s [%s]\n", clientName, cmd.Opcode, cmd.Space, strings.Join(cmd.Operands, ","))
}

replyJson := func() bool {
if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s REPLY: %+v\n", clientName, cmd.Opcode, results)
}

@@ -401,7 +402,7 @@ serverLoop:
break serverLoop
}
}
if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s REPLY: %+v\n", clientName, cmd.Opcode, rsps)
}

@@ -496,7 +497,7 @@ serverLoop:
log.Printf("usb2snes: %s: %s error: %s\n", clientName, cmd.Opcode, err)
break serverLoop
}
if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s REPLY: %+v\n", clientName, cmd.Opcode, rsps)
}

@@ -628,7 +629,7 @@ serverLoop:
}

var progress snes.ProgressReportFunc = nil
if tray.VerboseLogging {
if config.VerboseLogging {
progress = func(current uint32, total uint32) {
log.Printf("usb2snes: %s: %s: progress $%08x/$%08x\n", clientName, cmd.Opcode, current, total)
}
@@ -652,7 +653,7 @@ serverLoop:
log.Printf("usb2snes: %s: %s error: %s\n", clientName, cmd.Opcode, err)
break serverLoop
}
if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s REPLY: $%x bytes\n", clientName, cmd.Opcode, n)
}
if err = wb.Flush(); err != nil {
@@ -681,7 +682,7 @@ serverLoop:
size := uint32(size64)

var progress snes.ProgressReportFunc = nil
if tray.VerboseLogging {
if config.VerboseLogging {
progress = func(current uint32, total uint32) {
log.Printf("usb2snes: %s: %s: progress $%08x/$%08x\n", clientName, cmd.Opcode, current, total)
}
@@ -694,7 +695,7 @@ serverLoop:
log.Printf("usb2snes: %s: %s error: %s\n", clientName, cmd.Opcode, err)
break serverLoop
}
if tray.VerboseLogging {
if config.VerboseLogging {
log.Printf("usb2snes: %s: %s REPLY: $%x bytes\n", clientName, cmd.Opcode, n)
}
if err = wb.Flush(); err != nil {

0 comments on commit 2c7edac

Please sign in to comment.