Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/opensave-app/frontend/src/lib/restoreprogress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// How far a restore has come, in words: the phase, percent and, once it can
// be told, roughly how long is left (daemon/restoreprogress.go).
//
// The words are the catalog's (restoreProgress.*): the caller passes its
// translate function, `$t` in a component, so the line follows the chosen
// language as it changes.

const phases = {
rebuilding: 'restoreProgress.rebuilding',
checking: 'restoreProgress.checking',
writing: 'restoreProgress.writing'
};

export function restoreProgressText(p, t) {
if (!p || !p.phase) return '';
const params = {
phase: t(phases[p.phase] ?? 'restoreProgress.restoring'),
percent: p.total > 0 ? Math.floor((p.done / p.total) * 100) : 0,
done: p.done.toLocaleString(),
total: p.total.toLocaleString()
};
if (p.secondsLeft > 0) return t('restoreProgress.lineWithTimeLeft', { ...params, left: timeLeft(p.secondsLeft, t) });
return t('restoreProgress.line', params);
}

function timeLeft(s, t) {
if (s < 60) return t('restoreProgress.underAMinute');
return t('restoreProgress.aboutMinutes', { n: Math.round(s / 60) });
}
39 changes: 39 additions & 0 deletions cmd/opensave-app/frontend/src/lib/restoreprogress.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { get } from 'svelte/store';
import { locale, t } from './i18n.js';
import { restoreProgressText } from './restoreprogress.js';

// The line in a given language, as a component would say it with `$t`.
const say = (l, p) => {
locale.set(l);
return restoreProgressText(p, get(t));
};

describe('restoreProgressText', () => {
it('says nothing when no restore is under way', () => {
expect(say('en', null)).toBe('');
});
it('says the phase, percent and time left', () => {
const line = say('en', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 });
expect(line).toContain('Writing the files');
expect(line).toContain('25%');
expect(line).toContain('about 3 minutes left');
});
it('says one minute, and less than one, in words', () => {
expect(say('en', { phase: 'writing', done: 1, total: 2, secondsLeft: 70 })).toContain('about a minute left');
expect(say('en', { phase: 'writing', done: 1, total: 2, secondsLeft: 20 })).toContain('under a minute left');
});
it('leaves out the time while it cannot be told', () => {
expect(say('en', { phase: 'checking', done: 10, total: 100 })).not.toContain('left');
});
it('calls a phase it does not know restoring', () => {
expect(say('en', { phase: 'later', done: 0, total: 0 })).toContain('Restoring');
});
it('says the line in the chosen language', () => {
const line = say('zh-CN', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 });
expect(line).toContain('正在写入文件');
expect(line).toContain('25%');
expect(line).toContain('约 3 分钟');
expect(line).not.toContain('Writing');
});
});
13 changes: 12 additions & 1 deletion cmd/opensave-app/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,5 +211,16 @@
"home.findGame.pickAnother": "← Pick another game",
"home.findGame.already": "Already tracked here as \"{tracked}\". Tracking it again would sync the same files twice; link that entry to {name} instead, so it gets the game database’s name, App ID and cover art.",
"home.findGame.link": "Link \"{tracked}\" to {name}",
"home.findGame.linked": "\"{tracked}\" is now {name}"
"home.findGame.linked": "\"{tracked}\" is now {name}",
"restoreProgress.rebuilding": "Rebuilding the snapshot",
"restoreProgress.checking": "Checking the snapshot",
"restoreProgress.writing": "Writing the files",
"restoreProgress.restoring": "Restoring",
"restoreProgress.line": "{phase} — {percent}% ({done} of {total})",
"restoreProgress.lineWithTimeLeft": "{phase} — {percent}% ({done} of {total}), {left} left",
"restoreProgress.underAMinute": "under a minute",
"restoreProgress.aboutMinutes": {
"one": "about a minute",
"other": "about {n} minutes"
}
}
10 changes: 9 additions & 1 deletion cmd/opensave-app/frontend/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,5 +190,13 @@
"home.findGame.pickAnother": "← 选择其他游戏",
"home.findGame.already": "此处已作为“{tracked}”跟踪。再次跟踪会将相同的文件同步两次;请改为将该条目关联到 {name},使其获得游戏数据库中的名称、App ID 和封面。",
"home.findGame.link": "将“{tracked}”关联到 {name}",
"home.findGame.linked": "“{tracked}”现在是 {name}"
"home.findGame.linked": "“{tracked}”现在是 {name}",
"restoreProgress.rebuilding": "正在重建快照",
"restoreProgress.checking": "正在检查快照",
"restoreProgress.writing": "正在写入文件",
"restoreProgress.restoring": "正在恢复",
"restoreProgress.line": "{phase} — {percent}%({done}/{total})",
"restoreProgress.lineWithTimeLeft": "{phase} — {percent}%({done}/{total}),还剩{left}",
"restoreProgress.underAMinute": "不到一分钟",
"restoreProgress.aboutMinutes": "约 {n} 分钟"
}
6 changes: 6 additions & 0 deletions cmd/opensave-app/frontend/src/views/game/GameHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import { timeAgo } from '../../lib/timeago.js';
import { playLength } from '../../lib/format.js';
import { canLaunch } from '../../lib/gameactions.js';
import Spinner from '../../components/ui/Spinner.svelte';
import { restoreProgressText } from '../../lib/restoreprogress.js';
import { t } from '../../lib/i18n.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import Play from 'lucide-svelte/icons/play';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
Expand Down Expand Up @@ -133,6 +136,9 @@
{#if activity?.state === 'running'}
· <span class="syncing">syncing {activity.percentage ?? 0}%</span>
{/if}
{#if game.restoring}
· <span class="syncing"><Spinner size={11} /> {restoreProgressText(game.restoring, $t)}</span>
{/if}
{#if game.playingSince}
· <span class="playing">in session since {new Date(game.playingSince).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
{:else if game.lastPlayedAt}
Expand Down
2 changes: 2 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,8 @@ func (s *Server) gamePayload(g store.Game) map[string]any {
"playingSince": playingSince,
"playtimeMs": play.PlaytimeMs,
"playSessions": play.Sessions,
// A restore of it under way, and how far it has come.
"restoring": s.Daemon.RestoringNow(g.ID),
// Whether the game is installed on this device: "found", "not-found",
// or "" when there is no telling (daemon.InstallState). A game that is
// not here is one nobody plays here, which is why it shows no play.
Expand Down
5 changes: 5 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ type Daemon struct {
relocateFor map[string]string
relocateDismissed map[string]string

// Restores under way; see restoreprogress.go.
restoring restoreState

// Play sessions; see sessions.go.
sessions sessionState

Expand Down Expand Up @@ -171,6 +174,8 @@ func New(opts Options) (*Daemon, error) {
// A restore or a branch switch rewrites a save folder; nothing syncing it
// may read it half-way (syncengine/settle.go).
snaps.WriteGate = d.P2P.Sync.Writing
// How far a restore has come, for the window (restoreprogress.go).
snaps.OnRestoreProgress = d.noteRestoreProgress

// A paired peer untracking/re-tracking a game mirrors here.
d.P2P.OnUntrackRequest = d.untrackFromPeer
Expand Down
79 changes: 79 additions & 0 deletions internal/daemon/restoreprogress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package daemon

import (
"sync"
"time"
)

// How far a restore has come, for the window: a large save is put back in
// minutes, and a spinner alone left it unclear whether anything was
// happening at all.

// RestoreProgress is a restore under way.
type RestoreProgress struct {
Phase string `json:"phase"` // snapshot.PhaseRebuilding, PhaseChecking, PhaseWriting
Done int `json:"done"`
Total int `json:"total"`
// SecondsLeft estimates how long the phase still takes; 0 until there
// is enough to go on.
SecondsLeft int `json:"secondsLeft,omitempty"`
}

type restoreState struct {
mu sync.Mutex
games map[string]*restoreEntry
}

type restoreEntry struct {
RestoreProgress
phaseAt time.Time // when the phase began
sentAt time.Time // when the window was last told
}

// noteRestoreProgress records a restore's progress (snapshot.Manager's
// OnRestoreProgress), telling the window about once a second.
func (d *Daemon) noteRestoreProgress(gameID, phase string, done, total int) {
now := time.Now()
d.restoring.mu.Lock()
if d.restoring.games == nil {
d.restoring.games = map[string]*restoreEntry{}
}
tell := false
if phase == "" {
delete(d.restoring.games, gameID)
tell = true
} else {
e := d.restoring.games[gameID]
if e == nil || e.Phase != phase {
e = &restoreEntry{phaseAt: now}
d.restoring.games[gameID] = e
tell = true
}
e.Phase, e.Done, e.Total = phase, done, total
e.SecondsLeft = 0
if elapsed := now.Sub(e.phaseAt); done > 0 && total > done && elapsed > 2*time.Second {
e.SecondsLeft = int(elapsed.Seconds() / float64(done) * float64(total-done))
}
if now.Sub(e.sentAt) >= time.Second {
tell = true
}
if tell {
e.sentAt = now
}
}
d.restoring.mu.Unlock()
if tell && d.OnGameChanged != nil {
d.OnGameChanged(gameID)
}
}

// RestoringNow is the progress of a restore of gameID under way, or nil.
func (d *Daemon) RestoringNow(gameID string) *RestoreProgress {
d.restoring.mu.Lock()
defer d.restoring.mu.Unlock()
if e := d.restoring.games[gameID]; e != nil {
p := e.RestoreProgress
return &p
}
return nil
}
52 changes: 52 additions & 0 deletions internal/iopar/drive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Package iopar runs many small file operations — a save of a quarter of a
// million files restored, checked, written — as many at a time as the drive
// they are on does best: one at a time on a spinning disk, whose head would
// otherwise be sent back and forth across it; many at once on an SSD, which
// works through several requests in parallel and is otherwise left waiting
// on each file's own small delays (its creation, a virus scanner's look at
// it). How many is a starting point by kind of drive, adjusted as the work
// goes by what it actually achieves (Run).
package iopar

// Kind is the kind of drive a path is on.
type Kind int

const (
// Unknown: it could not be told. Treated as a SATA SSD — parallel, but
// modestly so.
Unknown Kind = iota
HDD
SATA
NVMe
)

func (k Kind) String() string {
switch k {
case HDD:
return "hard disk"
case SATA:
return "SATA SSD"
case NVMe:
return "NVMe SSD"
}
return "unknown drive"
}

// start is how many operations run at once to begin with.
func (k Kind) start() int {
switch k {
case HDD:
return 1
case NVMe:
return 32
}
return 8
}

// adapts reports whether the number is adjusted as the work goes. A spinning
// disk is not: it does best one at a time, and finding that out would cost
// the seeking it exists to avoid.
func (k Kind) adapts() bool { return k != HDD }

// DriveKind says what kind of drive path is on.
func DriveKind(path string) Kind { return driveKind(path) }
56 changes: 56 additions & 0 deletions internal/iopar/drive_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//go:build !windows

package iopar

import (
"os"
"path/filepath"
"strings"
"syscall"
)

// On Linux the kernel says whether a block device rotates; an NVMe drive is
// named so. Elsewhere it is not told.
func driveKind(path string) Kind {
var st syscall.Stat_t
if err := syscall.Stat(path, &st); err != nil {
return Unknown
}
major, minor := (st.Dev>>8)&0xfff, (st.Dev&0xff)|((st.Dev>>12)&0xfff00)
dev, err := os.Readlink("/sys/dev/block/" + itoa(uint64(major)) + ":" + itoa(uint64(minor)))
if err != nil {
return Unknown
}
// A partition: its disk is the folder above it.
disk := filepath.Base(dev)
sys := filepath.Join("/sys/class/block", disk)
if _, err := os.Stat(filepath.Join(sys, "partition")); err == nil {
disk = filepath.Base(filepath.Dir(dev))
sys = filepath.Join("/sys/class/block", disk)
}
if strings.HasPrefix(disk, "nvme") {
return NVMe
}
b, err := os.ReadFile(filepath.Join(sys, "queue", "rotational"))
if err != nil {
return Unknown
}
if strings.TrimSpace(string(b)) == "1" {
return HDD
}
return SATA
}

func itoa(n uint64) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
Loading
Loading