Skip to content

Commit 323c777

Browse files
authored
Add systray (#3)
* feat: add systray * refat: update log * feat: add custom icon * feat: add custom logger * doc: update readme * ci: add PR action
1 parent 1f1baf4 commit 323c777

File tree

13 files changed

+6131
-212
lines changed

13 files changed

+6131
-212
lines changed

.github/workflows/pull_request.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Pull Request
2+
3+
on: [pull_request, workflow_dispatch]
4+
5+
jobs:
6+
7+
validate:
8+
runs-on:
9+
- self-hosted
10+
- ubuntu-latest
11+
timeout-minutes: 15
12+
steps:
13+
- name: Checkout Code
14+
uses: actions/checkout@v4
15+
- name: Set up Go
16+
uses: actions/setup-go@v5
17+
with:
18+
go-version-file: 'go.mod'
19+
- name: Vet
20+
run: go vet ./...
21+
- name: Build
22+
run: go build -v ./cmd
23+
24+
vulnerability-check:
25+
runs-on:
26+
- self-hosted
27+
- ubuntu-latest
28+
steps:
29+
- name: govulncheck
30+
uses: golang/govulncheck-action@v1
31+
with:
32+
go-version-file: './go.mod'
33+
go-package: ./...
34+
check-latest: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,6 @@ go.work
3737
# Built Visual Studio Code Extensions
3838
*.vsix
3939

40+
# project specific
4041

42+
*.log

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# RunSyncedApp
22

3-
This small application allows to start multiple applications at once and if one of them is closed, the others are killed.
3+
This small application allows to start multiple applications at once and if one of them is closed, the others are killed. A system tray icon allows the using to exit the application without killing the child processes.
44

55
## Usage
66

77
```shell
8-
runsyncedapp.exe --config=myconfig.json --verbose
8+
runsyncedapp.exe --config=myconfig.json --log
99
```
1010

1111
- `config` : path of the config file
12-
- `verbose` : show all logs
12+
- `log` : log events in a `trace_<timestamp>.log` file
1313

1414
## JSON configuration
1515

@@ -21,17 +21,17 @@ The configuration file looks like this:
2121
"waitExit": 10,
2222
"applications": [
2323
{
24-
"path": "C:\\Windows\\notepad.exe",
25-
"useExistingInstance": true,
24+
"path": "C:\\Windows\\System32\\dxdiag.exe",
25+
"useExistingInstance": false,
2626
"killOnExit": true
2727
},
2828
{
29-
"path": "C:\\Windows\\System32\\calc.exe",
29+
"path": "C:\\Windows\\System32\\charmap.exe",
3030
"useExistingInstance": false,
3131
"killOnExit": true
3232
},
3333
{
34-
"path": "C:\\Windows\\write.exe",
34+
"path": "C:\\Windows\\System32\\msinfo32.exe",
3535
"useExistingInstance": false,
3636
"killOnExit": false
3737
}

config.json renamed to cmd/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"killOnExit": true
99
},
1010
{
11-
"path": "C:\\Windows\\System32\\ftp.exe",
11+
"path": "C:\\Windows\\System32\\charmap.exe",
1212
"useExistingInstance": false,
1313
"killOnExit": true
1414
},

cmd/main.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"os"
10+
"os/signal"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
i "github.com/clemthi/runsyncapps/internal"
16+
"github.com/getlantern/systray"
17+
)
18+
19+
const (
20+
traceFile string = "trace.log"
21+
)
22+
23+
func main() {
24+
configFile := flag.String("config", "config.json", "path to a configuration file")
25+
enableLog := flag.Bool("log", false, "enable logging")
26+
flag.Parse()
27+
28+
// Init default log handler
29+
var logHandler slog.Handler
30+
if *enableLog {
31+
slog.SetLogLoggerLevel(slog.LevelDebug)
32+
f, _ := os.Create(addTimeSuffix(traceFile))
33+
defer f.Close()
34+
logHandler = i.NewCustomLogHandler(f, nil)
35+
} else {
36+
logHandler = slog.NewTextHandler(io.Discard, nil)
37+
}
38+
39+
ctx := context.Background()
40+
ctx, cancel := context.WithCancel(ctx)
41+
42+
// Handle interrupt
43+
signalChan := make(chan os.Signal, 1)
44+
signal.Notify(signalChan, os.Interrupt)
45+
defer func() {
46+
signal.Stop(signalChan)
47+
cancel()
48+
}()
49+
50+
// Init systray icon
51+
go systray.Run(i.OnReadyUI, func() { os.Exit(0) })
52+
53+
if err := run(ctx, *configFile, logHandler); err != nil {
54+
fmt.Fprintf(os.Stderr, "%s\n", err)
55+
os.Exit(1)
56+
}
57+
}
58+
59+
func run(ctx context.Context, configFile string, logHandler slog.Handler) error {
60+
logger := slog.New(logHandler)
61+
62+
config, err := i.LoadConfigFile(configFile)
63+
if err != nil {
64+
logger.Error("cannot load config file", "error", err)
65+
return err
66+
}
67+
68+
p := i.NewProcessHander(logger)
69+
runningProcs, err := p.StartProcesses(config.Applications)
70+
if err != nil {
71+
logger.Error("cannot start app", "error", err)
72+
return fmt.Errorf("error launching apps : %w", err)
73+
}
74+
75+
time.Sleep(time.Duration(config.WaitCheck) * time.Second)
76+
p.CheckRunningProcesses(runningProcs)
77+
78+
time.Sleep(time.Duration(config.WaitExit) * time.Second)
79+
p.KillProcesses(runningProcs)
80+
81+
return nil
82+
}
83+
84+
func addTimeSuffix(filePath string) string {
85+
dir := filepath.Dir(filePath)
86+
ext := filepath.Ext(filePath)
87+
name := strings.TrimSuffix(filepath.Base(filePath), ext)
88+
89+
return filepath.Join(dir, fmt.Sprintf("%s_%s%s", name, time.Now().Format("20060102150405"), ext))
90+
}

go.mod

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@ module github.com/clemthi/runsyncapps
22

33
go 1.22
44

5-
require github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19
5+
require (
6+
github.com/getlantern/systray v1.2.2
7+
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19
8+
)
69

7-
require github.com/stretchr/testify v1.9.0 // indirect
10+
require (
11+
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
12+
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
13+
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
14+
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
15+
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
16+
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
17+
github.com/go-stack/stack v1.8.0 // indirect
18+
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
19+
github.com/stretchr/testify v1.9.0 // indirect
20+
golang.org/x/sys v0.1.0 // indirect
21+
)

go.sum

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,38 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
23
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
5+
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
6+
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
7+
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
8+
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
9+
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
10+
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
11+
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
12+
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
13+
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
14+
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
15+
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
16+
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
17+
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
18+
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
19+
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
320
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19 h1:WjT3fLi9n8YWh/Ih8Q1LHAPsTqGddPcHqscN+PJ3i68=
421
github.com/keybase/go-ps v0.0.0-20190827175125-91aafc93ba19/go.mod h1:hY+WOq6m2FpbvyrI93sMaypsttvaIL5nhVR92dTMUcQ=
22+
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
23+
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
24+
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
25+
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
526
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
627
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
28+
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
29+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
30+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
731
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
832
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
33+
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
34+
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
35+
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
36+
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
937
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
1038
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package internal
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
)
7+
8+
type AppConfig struct {
9+
Path string `json:"path"`
10+
UseExistingInstance bool `json:"useExistingInstance"`
11+
KillOnExit bool `json:"killOnExit"`
12+
}
13+
14+
type ConfigFile struct {
15+
WaitCheck int `json:"waitCheck"`
16+
WaitExit int `json:"waitExit"`
17+
Applications []AppConfig `json:"applications"`
18+
}
19+
20+
func LoadConfigFile(configFile string) (*ConfigFile, error) {
21+
file, err := os.ReadFile(configFile)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
var jsonData ConfigFile
27+
err = json.Unmarshal([]byte(file), &jsonData)
28+
29+
return &jsonData, err
30+
}

internal/custom_log.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package internal
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"log/slog"
10+
"sync"
11+
)
12+
13+
const (
14+
timeFormat = "[15:04:05.000]"
15+
logFormat = "%v\t%v\t%v"
16+
attrFormat = "%v=%v"
17+
attrSep = "\t"
18+
attrsFormat = "\t{%v}"
19+
)
20+
21+
type CustomLogFileHandler struct {
22+
handler slog.Handler
23+
file io.Writer
24+
buf *bytes.Buffer
25+
mu *sync.Mutex
26+
}
27+
28+
func (h *CustomLogFileHandler) Enabled(ctx context.Context, level slog.Level) bool {
29+
return h.handler.Enabled(ctx, level)
30+
}
31+
32+
func (h *CustomLogFileHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
33+
return &CustomLogFileHandler{handler: h.handler.WithAttrs(attrs), file: h.file, buf: h.buf, mu: h.mu}
34+
}
35+
36+
func (h *CustomLogFileHandler) WithGroup(name string) slog.Handler {
37+
return &CustomLogFileHandler{handler: h.handler.WithGroup(name), file: h.file, buf: h.buf, mu: h.mu}
38+
}
39+
40+
func (h *CustomLogFileHandler) Handle(ctx context.Context, r slog.Record) error {
41+
// Fetch attributes
42+
attrs, err := h.computeAttrs(ctx, r)
43+
if err != nil {
44+
return err
45+
}
46+
47+
attrLogs := ""
48+
for k, v := range attrs {
49+
if len(attrLogs) > 0 {
50+
attrLogs += attrSep
51+
}
52+
attrLogs += fmt.Sprintf(attrFormat, k, v)
53+
}
54+
55+
logLine := fmt.Sprintf(logFormat, r.Time.Format(timeFormat), r.Level, r.Message)
56+
if len(attrLogs) > 0 {
57+
logLine += fmt.Sprintf(attrsFormat, attrLogs)
58+
}
59+
logLine += "\n"
60+
io.WriteString(h.file, logLine)
61+
62+
return nil
63+
}
64+
65+
func (h *CustomLogFileHandler) computeAttrs(ctx context.Context, r slog.Record) (map[string]any, error) {
66+
h.mu.Lock()
67+
defer func() {
68+
h.buf.Reset()
69+
h.mu.Unlock()
70+
}()
71+
72+
if err := h.handler.Handle(ctx, r); err != nil {
73+
return nil, fmt.Errorf("error when calling default handle: %w", err)
74+
}
75+
76+
var attrs map[string]any
77+
err := json.Unmarshal(h.buf.Bytes(), &attrs)
78+
if err != nil {
79+
return nil, fmt.Errorf("error when parsing default handle result: %w", err)
80+
}
81+
return attrs, nil
82+
}
83+
84+
func NewCustomLogHandler(w io.Writer, opts *slog.HandlerOptions) *CustomLogFileHandler {
85+
if opts == nil {
86+
opts = &slog.HandlerOptions{}
87+
}
88+
89+
b := &bytes.Buffer{}
90+
return &CustomLogFileHandler{
91+
file: w,
92+
buf: b,
93+
handler: slog.NewJSONHandler(b, &slog.HandlerOptions{
94+
Level: opts.Level,
95+
AddSource: opts.AddSource,
96+
ReplaceAttr: suppressDefaultAttrs(opts.ReplaceAttr),
97+
}),
98+
mu: &sync.Mutex{},
99+
}
100+
}
101+
102+
// Ignore the default log attributes
103+
func suppressDefaultAttrs(next func([]string, slog.Attr) slog.Attr) func([]string, slog.Attr) slog.Attr {
104+
return func(groups []string, a slog.Attr) slog.Attr {
105+
if a.Key == slog.TimeKey ||
106+
a.Key == slog.LevelKey ||
107+
a.Key == slog.MessageKey {
108+
return slog.Attr{}
109+
}
110+
if next == nil {
111+
return a
112+
}
113+
return next(groups, a)
114+
}
115+
}

0 commit comments

Comments
 (0)