Repository navigation
Expand file tree
/
Copy pathpause.go
More file actions
351 lines (335 loc) · 10.4 KB
/
Copy pathpause.go
File metadata and controls
351 lines (335 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/ApolloF/syncer/internal/accounts"
"github.com/ApolloF/syncer/internal/features"
"github.com/ApolloF/syncer/internal/logx"
"github.com/ApolloF/syncer/internal/mods"
"github.com/ApolloF/syncer/internal/store"
"github.com/ApolloF/syncer/internal/syncthing"
"github.com/ApolloF/syncer/internal/tasks"
)
// A pause stops syncing (every Syncthing folder is paused) and automatic
// backups on this PC for a while. It always ends on its own: a one-off
// scheduled task runs "Syncer.exe --resume" at the end, and the window and
// the background task resume too as soon as they notice the time has passed.
const maxPause = 7 * 24 * time.Hour
// Pause pauses syncing and automatic backups until the given time (unix s).
// A manual "Back up now" still works.
func (a *App) Pause(until int64) error {
t := time.Unix(until, 0)
switch {
case !t.After(time.Now()):
return errors.New("pick a time in the future")
case t.After(time.Now().Add(maxPause)):
return errors.New("a pause can last a week at most")
}
if _, err := store.UpdateSettings(func(s *store.Settings) { s.PausedUntil = t }); err != nil {
return err
}
scheduleResume(t)
if c, err := a.client(); err == nil {
ctx, cancel := a.callCtx()
defer cancel()
if err := syncPause(ctx, c); err != nil {
// Not in effect (the folders couldn't be recorded or paused): don't
// leave the window saying "Paused until" for a pause that isn't.
logx.Printf("pause: %v", err)
if rerr := a.Resume(); rerr != nil {
logx.Printf("pause: undoing it: %v", rerr)
}
return errors.New("Couldn't pause right now. Try again in a moment.")
}
}
logx.Printf("paused syncing and backups until %s", t.Format("Mon 15:04"))
a.refreshTray()
runtime.EventsEmit(a.ctx, "changed")
return nil
}
// Resume ends a pause now.
func (a *App) Resume() error {
if _, err := store.UpdateSettings(func(s *store.Settings) { s.PausedUntil = time.Time{} }); err != nil {
return err
}
a.afterResume()
return nil
}
// afterResume unpauses the folders and catches up on what the pause held back.
func (a *App) afterResume() {
if c, err := a.client(); err == nil {
ctx, cancel := a.callCtx()
if err := syncPause(ctx, c); err != nil {
logx.Printf("resume: %v", err)
}
cancel()
}
_ = tasks.Delete(tasks.ResumeTask)
logx.Printf("syncing and backups resumed")
a.refreshTray()
runtime.EventsEmit(a.ctx, "changed")
go a.runAutoAdd()
}
// pauseLoop ends an expired pause while the window or tray is open, and
// pauses folders that appeared (or a Syncthing that started) mid-pause.
func (a *App) pauseLoop(ctx context.Context) {
for ctx.Err() == nil {
s := store.LoadSettings()
held := len(store.LoadState().PausedFolders) > 0
switch {
case s.Paused():
if c, err := a.client(); err == nil {
cctx, cancel := a.callCtx()
_ = syncPause(cctx, c)
cancel()
}
case !s.PausedUntil.IsZero(): // the pause just ran out
_, _ = store.UpdateSettings(func(s *store.Settings) { s.PausedUntil = time.Time{} })
a.afterResume()
runtime.EventsEmit(a.ctx, "toast", "Syncing and backups resumed")
case held: // Syncthing wasn't reachable when it ran out: try again
if c, err := a.client(); err == nil {
cctx, cancel := a.callCtx()
_ = syncPause(cctx, c)
cancel()
}
}
sleep(ctx, 30*time.Second)
}
}
// syncPause makes Syncthing match the pause setting: while paused, every
// running folder is paused and remembered; afterwards only those are resumed,
// so folders paused by hand in Syncthing stay paused.
func syncPause(ctx context.Context, c *syncthing.Client) error {
paused := store.LoadSettings().Paused()
held := store.LoadState().PausedFolders
if !paused && len(held) == 0 {
return nil
}
fs, err := c.Folders(ctx)
if err != nil {
return err
}
var errs []error
if paused {
var todo []string
for _, f := range fs {
if !f.Paused {
todo = append(todo, f.ID)
}
}
// Remembered before pausing, so a crash in between can't leave a
// folder paused that nothing will ever resume.
if len(todo) > 0 {
err := store.UpdateState(func(st *store.State) {
for _, id := range todo {
if !slices.Contains(st.PausedFolders, id) {
st.PausedFolders = append(st.PausedFolders, id)
}
}
})
if err != nil {
return fmt.Errorf("not pausing folders that couldn't be remembered: %w", err)
}
}
var failed []string
for _, id := range todo {
if err := c.PatchFolder(ctx, id, map[string]any{"paused": true}); err != nil {
errs = append(errs, err)
failed = append(failed, id)
}
}
if gone := notPaused(ctx, c, failed); len(gone) > 0 {
store.UpdateState(func(st *store.State) {
st.PausedFolders = slices.DeleteFunc(st.PausedFolders, func(id string) bool {
return slices.Contains(gone, id)
})
})
}
return errors.Join(errs...)
}
exists := map[string]bool{}
for _, f := range fs {
exists[f.ID] = true
}
// A game is running and holds these too: they resume when it exits.
game := map[string]bool{}
if st := store.LoadState(); time.Since(st.GameHeld) < staleHold {
for _, id := range st.GamePaused {
game[id] = true
}
}
s := store.LoadSettings()
var left, modHeld []string
busy := map[string]bool{} // saves are moving there: they resume when that's done
for _, id := range accounts.OpFolders() {
busy[id] = true
}
for _, id := range held {
if !exists[id] || game[id] {
continue // removed meanwhile, or the game's hold takes it over
}
// A mod folder waits while its mod manager is open (see modsTick),
// and a PC receiving deployed mods keeps its folder paused.
if isMod(s, id) && (!features.Mods || !holdable(s, id) || mods.ManagerRunning(processPaths())) {
if holdable(s, id) {
modHeld = append(modHeld, id)
}
continue
}
if busy[id] {
left = append(left, id)
continue
}
if err := c.PatchFolder(ctx, id, map[string]any{"paused": false}); err != nil {
errs = append(errs, err)
left = append(left, id)
}
}
store.UpdateState(func(st *store.State) {
st.PausedFolders = left
for _, id := range modHeld {
if !slices.Contains(st.ModHeld, id) {
st.ModHeld = append(st.ModHeld, id)
}
}
})
return errors.Join(errs...)
}
// runResume is what the one-off \Syncer-Resume task runs.
func runResume() {
defer logx.RecoverExit("resume")
s := store.LoadSettings()
if s.Paused() {
return // the pause was extended; its own task will come
}
if !s.PausedUntil.IsZero() {
_, _ = store.UpdateSettings(func(s *store.Settings) { s.PausedUntil = time.Time{} })
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if c, err := syncthing.New(); err == nil {
if err := syncPause(ctx, c); err != nil {
logx.Printf("resume: %v", err)
}
}
_ = tasks.Delete(tasks.ResumeTask)
logx.Printf("pause over: syncing and backups resumed")
// Catch up: apply other PCs' changes and run the backup the pause held back.
if tasks.Exists(tasks.BackupTask) {
if err := tasks.Run(tasks.BackupTask); err != nil {
logx.Printf("resume: %v", err)
}
}
}
// notPaused returns those of ids that Syncthing really doesn't have paused.
// A pause that came back with an error may still have gone through, so the
// folders are looked at again, and the ones that are paused stay remembered.
// If Syncthing can't be asked, none is dropped.
func notPaused(ctx context.Context, c *syncthing.Client, ids []string) []string {
if len(ids) == 0 {
return nil
}
fs, err := c.AllFolders(ctx)
if err != nil {
return nil
}
paused := map[string]bool{}
for _, f := range fs {
paused[f.ID] = f.Paused
}
return slices.DeleteFunc(slices.Clone(ids), func(id string) bool { return paused[id] })
}
// runUninstall is what the uninstaller runs. Nothing of Syncer will be left
// to resume the folders it paused, so it does that now, and removes the
// tasks and the start-with-Windows entry. Syncthing and all data stay.
func runUninstall() {
defer logx.RecoverExit("uninstall")
_ = tasks.Delete(tasks.BackupTask)
_ = tasks.Delete(tasks.ResumeTask)
removeAutostart()
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if c, err := syncthing.New(); err == nil {
if err := releaseFolders(ctx, c); err != nil {
logx.Printf("uninstall: %v", err)
}
}
}
// releaseFolders resumes the folders Syncer paused for a pause or a running
// game and forgets them. Folders paused by hand stay paused. So do the mod
// folders held on purpose (ModHeld), which is why that list isn't released
// here: they must not sync while a mod manager may change them. A mod folder
// that receives deployed mods stays paused too, as in syncPause.
func releaseFolders(ctx context.Context, c *syncthing.Client) error {
s, err := store.UpdateSettings(func(s *store.Settings) { s.PausedUntil = time.Time{} })
if err != nil {
return err
}
st := store.LoadState()
held := slices.Concat(st.PausedFolders, st.GamePaused)
if len(held) == 0 {
return nil
}
fs, err := c.Folders(ctx)
if err != nil {
return err
}
exists := map[string]bool{}
for _, f := range fs {
exists[f.ID] = true
}
var errs []error
failed := map[string]bool{}
for _, id := range held {
if !exists[id] || failed[id] || isMod(s, id) && !holdable(s, id) {
continue
}
if err := c.PatchFolder(ctx, id, map[string]any{"paused": false}); err != nil {
errs = append(errs, err)
failed[id] = true
}
}
keep := func(ids []string) []string {
return slices.DeleteFunc(slices.Clone(ids), func(id string) bool { return !failed[id] })
}
store.UpdateState(func(s *store.State) {
s.PausedFolders, s.GamePaused = keep(s.PausedFolders), keep(s.GamePaused)
})
return errors.Join(errs...)
}
// scheduleResume registers the task that ends the pause at t.
func scheduleResume(t time.Time) {
exe, ok := taskExe()
if !ok {
return
}
err := tasks.Register(tasks.Spec{
Name: tasks.ResumeTask,
Description: "Syncer: ends a pause of game save syncing and backups.",
Exe: exe,
Args: "--resume",
At: t,
TimeLimit: 10 * time.Minute,
})
if err != nil {
logx.Printf("schedule resume: %v", err)
}
}
// taskExe is the exe scheduled tasks should run; false for `wails dev`
// builds, which must never be registered.
func taskExe() (string, bool) {
exe, err := os.Executable()
if err != nil {
return "", false
}
exe, _ = filepath.EvalSymlinks(exe)
return exe, !strings.HasSuffix(strings.ToLower(exe), "-dev.exe")
}