Skip to content

Commit 1e0c171

Browse files
committed
Major update to add the ability to run calblink as a service.
This also includes one major change and one minor change caused by this. Major change: instead of sleeping for 30 seconds (or, during skip time, up to five minutes), calblink now uses a one-second timer. An internal check is made of when the next API call is needed, so this will reduce the number of API calls made and increase fidelity in the case where a device is slept. Minor change: In service mode, the launch header (with the settings resulting from parsing the config file) will be skipped and showDots will be forced off.
1 parent 9d358d5 commit 1e0c171

File tree

6 files changed

+230
-68
lines changed

6 files changed

+230
-68
lines changed

README.md

+11
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ To use calblink, you need the following:
4949
7. Get an OAuth 2 ID as described in step 1 of the [Google Calendar
5050
Quickstart](https://developers.google.com/google-apps/calendar/quickstart/go).
5151
Put the client\_secret.json file in your GOPATH directory.
52+
53+
7. Make sure the client\_secret.json file is secure by changing its permissions
54+
to only allow the user to read it:
55+
56+
chmod 600 client_secret.json
5257
5358
8. Build the calblink program as appropriate for your environment:
5459
* For a Linux environment or another that doesn't use Homebrew:
@@ -77,6 +82,9 @@ To use calblink, you need the following:
7782
7883
11. Optionally, set up a config file, as below.
7984
85+
12. Once everything is working, you can consider enabling [service mode](SERVICE.md) to
86+
have it run automatically in the background.
87+
8088
## What are the configuration options?
8189
8290
First off, run it with the --help option to see what the command-line options
@@ -185,6 +193,7 @@ To comply with the new security measure and meet the new requirements, please fo
185193
## Known Issues
186194
187195
* Occasionally the shutdown is not as clean as it should be.
196+
* Something seems to cause an occasional crash.
188197
* If the blink(1) becomes disconnected, sometimes the program crashes instead of failing
189198
gracefully.
190199
@@ -214,4 +223,6 @@ To comply with the new security measure and meet the new requirements, please fo
214223
* Calblink contains code from the [Google Calendar API
215224
Quickstart](https://developers.google.com/google-apps/calendar/quickstart/go)
216225
which is licensed under the Apache 2 license.
226+
* Calblink uses the [Go service](https://github.com/kardianos/service/) library for
227+
managing service mode.
217228
* All trademarks are the property of their respective holders.

SERVICE.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Running calblink as a service
2+
3+
## What does this do?
4+
5+
Calblink now supports a mode where it runs as a service. This means that it is managed
6+
by your operating system instead of needing to manually run it. This service can be
7+
turned on and survive reboots.
8+
9+
## What operating systems does this mode support?
10+
11+
It has only been tested on macOS. Theoretically it should work on Linux and other
12+
Unix-style operating systems, and might possibly work on Windows. Try it out and if
13+
you have issues, let me know.
14+
15+
## What potential problems are there for this mode?
16+
17+
calblink currently doesn't cope well with not having a blink(1) installed when it is run.
18+
It will exit after enough failures to control the blink(1), if it doesn't segfault first.
19+
This mode works best for cases where a machine has a blink(1) set up at all times.
20+
Alternately, if you have a way of controlling launch daemons based on USB events
21+
(EventScripts or similar on macOS) you can use that to only run calblink when there
22+
is a blink(1) plugged in.
23+
24+
If you don't disable the launch daemon when there isn't a blink(1) plugged in, calblink
25+
will crash and be automatically restarted every ten seconds or so.
26+
27+
## How do I set this up?
28+
29+
These instructions assume macOS.
30+
31+
1. Install calblink like you normally would, then make sure your configuration
32+
is set up the way you want.
33+
2. Run calblink as follows:
34+
35+
./calblink -runAsService -service install
36+
37+
This will install a launch agent in ~/Library/LaunchAgents.
38+
3. You can then control it with launchctl like any other launch agent, or run
39+
calblink to control the agent:
40+
41+
./calblink -runAsService -service start
42+
43+
Available commands include `start`, `stop`, `restart`, `install`, and `uninstall`.
44+
4. Log messages will go into your home directory, in `calblink.out.log` and
45+
`calblink.err.log`. Unless debug is turned on, there should be minimal logging.
46+
One log line is created at startup and shutdown, and fatal errors will be logged
47+
to the error log.

blinker.go

+5-2
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ func (blinker *BlinkerState) reinitialize() error {
113113
return err
114114
}
115115

116+
func (blinker *BlinkerState) turnOff() {
117+
blinker.device.SetState(blink1.OffState)
118+
}
119+
116120
func (blinker *BlinkerState) setState(state blink1.State) error {
117121
if blinker.failures > 0 {
118122
err := blinker.reinitialize()
@@ -232,8 +236,7 @@ func signalHandler(blinker *BlinkerState) {
232236
continue
233237
}
234238
if blinker.failures == 0 {
235-
blinker.newState <- Black
236-
blinker.device.SetState(blink1.OffState)
239+
blinker.turnOff()
237240
}
238241
log.Fatalf("Quitting due to signal %v", s)
239242
}

calblink.go

+98-65
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"log"
2323
"os"
2424
"time"
25+
26+
"github.com/kardianos/service"
2527
)
2628

2729
// flags
@@ -33,10 +35,19 @@ var pollIntervalFlag = flag.Int("poll_interval", 30, "Number of seconds between
3335
var responseStateFlag = flag.String("response_state", "notRejected", "Which events to consider based on response: all, accepted, or notRejected")
3436
var deviceFailureRetriesFlag = flag.Int("device_failure_retries", 10, "Number of times to retry initializing the device before quitting the program")
3537
var showDotsFlag = flag.Bool("show_dots", true, "Whether to show progress dots after every cycle of checking the calendar")
38+
var runAsServiceFlag = flag.Bool("runAsService", false, "Whether to run as a service or remain live in the current shell")
39+
var serviceFlag = flag.String("service", "", "Control the system service.")
3640

3741
var debugOut io.Writer = ioutil.Discard
3842
var dotOut io.Writer = ioutil.Discard
3943

44+
// Necessary status for running as a service
45+
type program struct {
46+
service service.Service
47+
userPrefs *UserPrefs
48+
exit chan struct{}
49+
}
50+
4051
// Time calculation methods
4152

4253
func tomorrow() time.Time {
@@ -49,18 +60,6 @@ func setHourMinuteFromTime(t time.Time) time.Time {
4960
return time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, now.Location())
5061
}
5162

52-
func sleep(d time.Duration) {
53-
// To fix the 'oversleeping' problem where we sleep too long if the machine goes to
54-
// sleep in the meantime, sleep for no more than 5 minutes at once.
55-
// TODO: Once the AbsoluteNow proposal goes in, replace this with that.
56-
max := time.Duration(5) * time.Minute
57-
if d > max {
58-
fmt.Fprintf(debugOut, "Cutting sleep short from %d to %d", d, max)
59-
d = max
60-
}
61-
time.Sleep(d)
62-
}
63-
6463
// Print output methods
6564

6665
func usage() {
@@ -75,8 +74,10 @@ func main() {
7574
if *debugFlag {
7675
debugOut = os.Stdout
7776
}
78-
77+
7978
userPrefs := readUserPrefs()
79+
isService := false
80+
serviceCmd := ""
8081

8182
// Overrides from command-line
8283
flag.Visit(func(myFlag *flag.Flag) {
@@ -94,13 +95,32 @@ func main() {
9495
userPrefs.DeviceFailureRetries = myFlag.Value.(flag.Getter).Get().(int)
9596
case "show_dots":
9697
userPrefs.ShowDots = myFlag.Value.(flag.Getter).Get().(bool)
98+
case "runAsService":
99+
isService = myFlag.Value.(flag.Getter).Get().(bool)
100+
case "service":
101+
serviceCmd = myFlag.Value.String()
97102
}
98103
})
99104

100-
if userPrefs.ShowDots {
105+
if userPrefs.ShowDots && !isService {
101106
dotOut = os.Stdout
102107
}
103108

109+
prg := &program{
110+
userPrefs: userPrefs,
111+
exit: make(chan struct{}),
112+
}
113+
114+
if isService {
115+
prg.StartService(serviceCmd)
116+
} else {
117+
runLoop(prg)
118+
}
119+
120+
}
121+
122+
func runLoop(p *program) {
123+
userPrefs := p.userPrefs
104124
srv, err := Connect()
105125
if err != nil {
106126
log.Fatalf("Unable to retrieve Calendar client: %v", err)
@@ -111,67 +131,80 @@ func main() {
111131
go signalHandler(blinkerState)
112132
go blinkerState.patternRunner()
113133

114-
printStartInfo(userPrefs)
134+
if p.service == nil {
135+
printStartInfo(userPrefs)
136+
} else {
137+
fmt.Printf("Calblink starting at %v\n", time.Now())
138+
}
115139

140+
ticker := time.NewTicker(time.Second)
141+
nextEvent := time.Now()
116142
failures := 0
117143

118144
for {
119-
now := time.Now()
120-
weekday := now.Weekday()
121-
if userPrefs.SkipDays[weekday] {
122-
tomorrow := tomorrow()
123-
untilTomorrow := tomorrow.Sub(now)
124-
Black.Execute(blinkerState)
125-
fmt.Fprintf(debugOut, "Sleeping %v until tomorrow because it's a skip day\n", untilTomorrow)
126-
fmt.Fprint(dotOut, "~")
127-
sleep(untilTomorrow)
128-
continue
129-
}
130-
if userPrefs.StartTime != nil {
131-
start := setHourMinuteFromTime(*userPrefs.StartTime)
132-
fmt.Fprintf(debugOut, "Start time: %v\n", start)
133-
if diff := time.Since(start); diff < 0 {
134-
Black.Execute(blinkerState)
135-
fmt.Fprintf(debugOut, "Sleeping %v because start time after now\n", -diff)
136-
fmt.Fprint(dotOut, ">")
137-
sleep(-diff)
145+
select {
146+
case <-p.exit:
147+
blinkerState.turnOff()
148+
fmt.Printf("Calblink exiting at %v\n", time.Now())
149+
ticker.Stop()
150+
return
151+
case now := <-ticker.C:
152+
if nextEvent.After(now) {
138153
continue
139154
}
140-
}
141-
if userPrefs.EndTime != nil {
142-
end := setHourMinuteFromTime(*userPrefs.EndTime)
143-
fmt.Fprintf(debugOut, "End time: %v\n", end)
144-
if diff := time.Since(end); diff > 0 {
145-
Black.Execute(blinkerState)
155+
weekday := now.Weekday()
156+
if userPrefs.SkipDays[weekday] {
146157
tomorrow := tomorrow()
147-
untilTomorrow := tomorrow.Sub(now)
148-
fmt.Fprintf(debugOut, "Sleeping %v until tomorrow because end time %v before now\n", untilTomorrow, diff)
149-
fmt.Fprint(dotOut, "<")
150-
sleep(untilTomorrow)
158+
Black.Execute(blinkerState)
159+
fmt.Fprintf(debugOut, "Sleeping until tomorrow (%v) because it's a skip day\n", tomorrow)
160+
fmt.Fprint(dotOut, "~")
161+
nextEvent = tomorrow
151162
continue
152163
}
153-
}
154-
next, err := fetchEvents(now, srv, userPrefs)
155-
if err != nil {
156-
// Leave the same color, set a flag. If we get more than a critical number of these,
157-
// set the color to blinking magenta to tell the user we are in a failed state.
158-
failures++
159-
if failures > failureRetries {
160-
MagentaFlash.Execute(blinkerState)
164+
if userPrefs.StartTime != nil {
165+
start := setHourMinuteFromTime(*userPrefs.StartTime)
166+
fmt.Fprintf(debugOut, "Start time: %v\n", start)
167+
if diff := time.Since(start); diff < 0 {
168+
Black.Execute(blinkerState)
169+
fmt.Fprintf(debugOut, "Sleeping %v because start time after now\n", -diff)
170+
fmt.Fprint(dotOut, ">")
171+
nextEvent = start
172+
continue
173+
}
161174
}
162-
fmt.Fprintf(debugOut, "Fetch Error:\n%v\n", err)
163-
fmt.Fprint(dotOut, ",")
164-
sleep(time.Duration(userPrefs.PollInterval) * time.Second)
165-
continue
166-
} else {
167-
failures = 0
168-
}
169-
blinkState := blinkStateForEvent(next, userPrefs.PriorityFlashSide)
175+
if userPrefs.EndTime != nil {
176+
end := setHourMinuteFromTime(*userPrefs.EndTime)
177+
fmt.Fprintf(debugOut, "End time: %v\n", end)
178+
if diff := time.Since(end); diff > 0 {
179+
Black.Execute(blinkerState)
180+
tomorrow := tomorrow()
181+
untilTomorrow := tomorrow.Sub(now)
182+
fmt.Fprintf(debugOut, "Sleeping %v until tomorrow because end time %v before now\n", untilTomorrow, diff)
183+
fmt.Fprint(dotOut, "<")
184+
nextEvent = tomorrow
185+
continue
186+
}
187+
}
188+
next, err := fetchEvents(now, srv, userPrefs)
189+
if err != nil {
190+
// Leave the same color, set a flag. If we get more than a critical number of these,
191+
// set the color to blinking magenta to tell the user we are in a failed state.
192+
failures++
193+
if failures > failureRetries {
194+
MagentaFlash.Execute(blinkerState)
195+
}
196+
fmt.Fprintf(debugOut, "Fetch Error:\n%v\n", err)
197+
fmt.Fprint(dotOut, ",")
198+
nextEvent = now.Add(time.Duration(userPrefs.PollInterval) * time.Second)
199+
continue
200+
} else {
201+
failures = 0
202+
}
203+
blinkState := blinkStateForEvent(next, userPrefs.PriorityFlashSide)
170204

171-
blinkState.Execute(blinkerState)
172-
fmt.Fprint(dotOut, ".")
173-
sleep(time.Duration(userPrefs.PollInterval) * time.Second)
205+
blinkState.Execute(blinkerState)
206+
fmt.Fprint(dotOut, ".")
207+
nextEvent = now.Add(time.Duration(userPrefs.PollInterval) * time.Second)
208+
}
174209
}
175-
176-
177210
}

network.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func loadClientCredentials(clientSecretPath string) ([]byte, error) {
4545
return nil, fmt.Errorf("client secret file not found: %s", clientSecretPath)
4646
}
4747
// Check if the file has secure permissions (readable only by owner)
48-
if info.Mode().Perm() & 077 != 0 {
48+
if info.Mode().Perm()&077 != 0 {
4949
return nil, fmt.Errorf("insecure permissions for client secret file: %s", clientSecretPath)
5050
}
5151
// Read the contents of the file

0 commit comments

Comments
 (0)