forked from retoor/devplacepy
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,9 @@ devplace-node version
|
||||
|
||||
Create the device secret on your DevPlace profile after consenting to bandwidth sharing. `login` asks the server to accept the secret before storing it (`--no-verify` skips that). The secret can also come from `DEVPLACE_NODE_SECRET`. Every command takes `--config PATH`.
|
||||
|
||||
`run` is quiet: it logs start, disconnects, and one totals line per day. Add `-v` for detail. Exit code 3 means the server ended this device for good (revoked, suspended, kill switch, no consent, not adult, replaced by another instance of the same device); fix the cause and start it again.
|
||||
`run` is quiet: it logs start, disconnects, and one totals line per day. Add `-v` for detail. Exit code 3 means the server ended this device for good (revoked, kill switch, no consent, not adult, replaced by another instance of the same device); fix the cause and start it again. A timed suspension is not final: on a ticket `403` or a `kick` with reason `suspended` the client waits until the `until` instant (at least 60 seconds, at most 24 hours, plus jitter; 5 minutes when `until` is empty or unreadable) and then asks for a new ticket.
|
||||
|
||||
When the server setting `pause_on_battery` is on, jobs are refused with `on_battery` while the machine runs on battery (Linux `/sys/class/power_supply`, macOS `pmset -g batt`, Windows `GetSystemPowerStatus`; the result is cached for 15 seconds). An unknown power state counts as mains power. The device label the server holds is logged at connect with `-v`.
|
||||
|
||||
## Config file
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const batteryCacheSeconds = 15
|
||||
|
||||
type BatteryProbe func() (bool, error)
|
||||
|
||||
type batteryWatch struct {
|
||||
mu sync.Mutex
|
||||
probe BatteryProbe
|
||||
now func() time.Time
|
||||
debugf func(format string, args ...any)
|
||||
checked time.Time
|
||||
value bool
|
||||
warned bool
|
||||
}
|
||||
|
||||
func newBatteryWatch(probe BatteryProbe, now func() time.Time) *batteryWatch {
|
||||
return &batteryWatch{probe: probe, now: now}
|
||||
}
|
||||
|
||||
func (w *batteryWatch) OnBattery() bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
moment := w.now()
|
||||
if !w.checked.IsZero() && moment.Sub(w.checked) < time.Duration(batteryCacheSeconds)*time.Second {
|
||||
return w.value
|
||||
}
|
||||
w.checked = moment
|
||||
value, err := w.probe()
|
||||
if err != nil {
|
||||
value = false
|
||||
if !w.warned && w.debugf != nil {
|
||||
w.debugf("battery state unknown, treating as mains power: %v", err)
|
||||
}
|
||||
w.warned = true
|
||||
}
|
||||
w.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
func parsePmset(text string) bool {
|
||||
return strings.Contains(text, "Now drawing from 'Battery Power'")
|
||||
}
|
||||
|
||||
func windowsOnBattery(acLine, batteryFlag byte) bool {
|
||||
return acLine == 0 && batteryFlag != 128 && batteryFlag != 255
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func systemBattery() (bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "pmset", "-g", "batt").Output()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parsePmset(string(out)), nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const powerSupplyRoot = "/sys/class/power_supply"
|
||||
|
||||
func systemBattery() (bool, error) {
|
||||
return sysfsOnBattery(powerSupplyRoot)
|
||||
}
|
||||
|
||||
func readSupplyField(dir, name string) string {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func isMainsType(kind string) bool {
|
||||
return kind == "Mains" || kind == "ADP" || strings.HasPrefix(kind, "USB")
|
||||
}
|
||||
|
||||
func sysfsOnBattery(root string) (bool, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
discharging := false
|
||||
for _, entry := range entries {
|
||||
dir := filepath.Join(root, entry.Name())
|
||||
if readSupplyField(dir, "scope") == "Device" {
|
||||
continue
|
||||
}
|
||||
kind := readSupplyField(dir, "type")
|
||||
switch {
|
||||
case kind == "Battery":
|
||||
if readSupplyField(dir, "status") == "Discharging" {
|
||||
discharging = true
|
||||
}
|
||||
case isMainsType(kind):
|
||||
if readSupplyField(dir, "online") == "1" {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return discharging, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type supply map[string]string
|
||||
|
||||
func buildTree(t *testing.T, supplies map[string]supply) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for name, fields := range supplies {
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range fields {
|
||||
if err := os.WriteFile(filepath.Join(dir, key), []byte(value+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestSysfsOnBatteryTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
supplies map[string]supply
|
||||
want bool
|
||||
}{
|
||||
{"desktop without battery", map[string]supply{"AC": {"type": "Mains", "online": "1"}}, false},
|
||||
{"empty tree", map[string]supply{}, false},
|
||||
{"laptop discharging", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "AC": {"type": "Mains", "online": "0"}}, true},
|
||||
{"laptop charging", map[string]supply{"BAT0": {"type": "Battery", "status": "Charging"}, "AC": {"type": "Mains", "online": "1"}}, false},
|
||||
{"laptop full on mains", map[string]supply{"BAT0": {"type": "Battery", "status": "Full"}, "AC": {"type": "Mains", "online": "1"}}, false},
|
||||
{"discharging but usb-c online", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "ucsi": {"type": "USB", "online": "1"}}, false},
|
||||
{"discharging usb-c offline", map[string]supply{"BAT0": {"type": "Battery", "status": "Discharging"}, "ucsi": {"type": "USB_C", "online": "0"}}, true},
|
||||
{"adp supply online", map[string]supply{"BAT1": {"type": "Battery", "status": "Discharging"}, "ADP1": {"type": "ADP", "online": "1"}}, false},
|
||||
{"peripheral battery ignored", map[string]supply{"hid": {"type": "Battery", "status": "Discharging", "scope": "Device"}, "AC": {"type": "Mains", "online": "1"}}, false},
|
||||
{"peripheral only", map[string]supply{"hid": {"type": "Battery", "status": "Discharging", "scope": "Device"}}, false},
|
||||
{"unknown status", map[string]supply{"BAT0": {"type": "Battery", "status": "Unknown"}}, false},
|
||||
}
|
||||
for _, item := range cases {
|
||||
got, err := sysfsOnBattery(buildTree(t, item.supplies))
|
||||
if err != nil || got != item.want {
|
||||
t.Errorf("%s: got %v err %v", item.name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysfsMissingRootIsAnError(t *testing.T) {
|
||||
if _, err := sysfsOnBattery(filepath.Join(t.TempDir(), "nope")); err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
import "errors"
|
||||
|
||||
func systemBattery() (bool, error) {
|
||||
return false, errors.New("battery state is not available on this platform")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type systemPowerStatus struct {
|
||||
ACLineStatus byte
|
||||
BatteryFlag byte
|
||||
BatteryLifePercent byte
|
||||
SystemStatusFlag byte
|
||||
BatteryLifeTime uint32
|
||||
BatteryFullLifeTime uint32
|
||||
}
|
||||
|
||||
var getSystemPowerStatus = syscall.NewLazyDLL("kernel32.dll").NewProc("GetSystemPowerStatus")
|
||||
|
||||
func systemBattery() (bool, error) {
|
||||
var status systemPowerStatus
|
||||
result, _, err := getSystemPowerStatus.Call(uintptr(unsafe.Pointer(&status)))
|
||||
if result == 0 {
|
||||
return false, err
|
||||
}
|
||||
return windowsOnBattery(status.ACLineStatus, status.BatteryFlag), nil
|
||||
}
|
||||
+12
-1
@@ -27,6 +27,7 @@ type Limiter struct {
|
||||
serverLimit ServerLimits
|
||||
serverSet ServerSettings
|
||||
hasServer bool
|
||||
battery *batteryWatch
|
||||
day string
|
||||
used int64
|
||||
active int
|
||||
@@ -38,7 +39,7 @@ func NewLimiter(cfg Config, statePath string, now func() time.Time) (*Limiter, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limiter := &Limiter{cfg: cfg, loc: loc, now: now, statePath: statePath}
|
||||
limiter := &Limiter{cfg: cfg, loc: loc, now: now, statePath: statePath, battery: newBatteryWatch(systemBattery, now)}
|
||||
limiter.load()
|
||||
return limiter, nil
|
||||
}
|
||||
@@ -165,6 +166,13 @@ func (l *Limiter) hoursCode() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *Limiter) SetBatteryProbe(probe BatteryProbe, debugf func(format string, args ...any)) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.battery = newBatteryWatch(probe, l.now)
|
||||
l.battery.debugf = debugf
|
||||
}
|
||||
|
||||
func (l *Limiter) Acquire() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
@@ -172,6 +180,9 @@ func (l *Limiter) Acquire() string {
|
||||
if code := l.hoursCode(); code != "" {
|
||||
return code
|
||||
}
|
||||
if l.serverSet.PauseOnBattery && l.battery.OnBattery() {
|
||||
return RefuseBattery
|
||||
}
|
||||
if l.used >= l.capBytes() {
|
||||
return RefuseBandwidth
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -160,3 +161,93 @@ func TestUsagePersistsAcrossRestart(t *testing.T) {
|
||||
t.Fatalf("read usage %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireRefusesOnBatteryOnlyWhenAsked(t *testing.T) {
|
||||
limiter, c := testLimiter(t, DefaultConfig(), "2026-01-15T12:00:00Z")
|
||||
calls := 0
|
||||
onBattery := true
|
||||
limiter.SetBatteryProbe(func() (bool, error) { calls++; return onBattery, nil }, nil)
|
||||
if got := limiter.Acquire(); got != "" {
|
||||
t.Fatalf("setting off must ignore the probe: %q", got)
|
||||
}
|
||||
limiter.Release()
|
||||
if calls != 0 {
|
||||
t.Fatalf("probe ran %d times with the setting off", calls)
|
||||
}
|
||||
limiter.ApplyServer(nil, &ServerSettings{PauseOnBattery: true})
|
||||
if got := limiter.Acquire(); got != RefuseBattery {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
onBattery = false
|
||||
if got := limiter.Acquire(); got != RefuseBattery || calls != 1 {
|
||||
t.Fatalf("cached result expected: %q calls %d", got, calls)
|
||||
}
|
||||
c.now = c.now.Add(16 * time.Second)
|
||||
if got := limiter.Acquire(); got != "" || calls != 2 {
|
||||
t.Fatalf("after cache expiry: %q calls %d", got, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatteryProbeErrorMeansMainsAndLogsOnce(t *testing.T) {
|
||||
limiter, c := testLimiter(t, DefaultConfig(), "2026-01-15T12:00:00Z")
|
||||
logged := 0
|
||||
limiter.SetBatteryProbe(func() (bool, error) { return true, errors.New("no sysfs") }, func(string, ...any) { logged++ })
|
||||
limiter.ApplyServer(nil, &ServerSettings{PauseOnBattery: true})
|
||||
for i := 0; i < 3; i++ {
|
||||
if got := limiter.Acquire(); got != "" {
|
||||
t.Fatalf("probe error must not refuse: %q", got)
|
||||
}
|
||||
limiter.Release()
|
||||
c.now = c.now.Add(20 * time.Second)
|
||||
}
|
||||
if logged != 1 {
|
||||
t.Fatalf("logged %d times", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatteryHelpers(t *testing.T) {
|
||||
if !parsePmset("Now drawing from 'Battery Power'\n -InternalBattery-0") || parsePmset("Now drawing from 'AC Power'") {
|
||||
t.Fatal("pmset parse")
|
||||
}
|
||||
cases := []struct {
|
||||
ac, flag byte
|
||||
want bool
|
||||
}{{0, 1, true}, {0, 8, true}, {1, 1, false}, {0, 128, false}, {255, 255, false}, {0, 255, false}}
|
||||
for _, item := range cases {
|
||||
if got := windowsOnBattery(item.ac, item.flag); got != item.want {
|
||||
t.Errorf("ac %d flag %d: %v", item.ac, item.flag, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
parityBase = 1767225600
|
||||
parityStep = 40009
|
||||
paritySamples = 200
|
||||
)
|
||||
|
||||
var parityCases = []struct{ from, to, zone, bits string }{
|
||||
{"09:00", "17:00", "Europe/Amsterdam", "01010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000001010101010000"},
|
||||
{"22:00", "06:00", "America/New_York", "00010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000000101010101000010101010100000010101010000001010101"},
|
||||
{"08:30", "12:15", "Asia/Kolkata", "00000001010000000000101000000000010101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101010000000000101"},
|
||||
{"00:00", "12:00", "Pacific/Auckland", "01101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101011010101010101101010101010100101010101010010101010"},
|
||||
{"10:00", "10:00", "UTC", "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"},
|
||||
{"09:00", "17:00", "Not/AZone", "01010000101010101000000101010100000010101010000001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010"},
|
||||
{"23:00", "01:00", "Australia/Lord_Howe", "00000000000010000000000001010000000000001000000000000100000000000010000000000001010000000000001000000000000100000000000010000000000000010000000000001000000000000100000000000010000000000000010000000000"},
|
||||
{"", "", "Europe/Amsterdam", "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111"},
|
||||
}
|
||||
|
||||
func TestInWindowParityWithPythonScheduler(t *testing.T) {
|
||||
for _, item := range parityCases {
|
||||
zone, err := time.LoadLocation(item.zone)
|
||||
if err != nil {
|
||||
zone = time.UTC
|
||||
}
|
||||
for index := 0; index < paritySamples; index++ {
|
||||
moment := time.Unix(int64(parityBase+index*parityStep), 0).In(zone)
|
||||
if got := InWindow(item.from, item.to, moment); got != (item.bits[index] == '1') {
|
||||
t.Fatalf("%s-%s %s #%d: got %v", item.from, item.to, item.zone, index, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ func runRun(args []string, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
limiter.SetBatteryProbe(systemBattery, logger.Debugf)
|
||||
policy := NewPolicy()
|
||||
stats := NewStats(time.Now())
|
||||
session := NewSession(config, policy, limiter, NewFetcher(policy), stats, logger)
|
||||
|
||||
@@ -37,6 +37,12 @@ const (
|
||||
MaxDonorMBPerDay = 20 * 1024
|
||||
StableSessionSeconds = 30
|
||||
FastRetryMillis = 1500
|
||||
|
||||
SuspendedReason = "suspended"
|
||||
SuspendedMinSeconds = 60
|
||||
SuspendedMaxSeconds = 24 * 60 * 60
|
||||
SuspendedDefaultSeconds = 300
|
||||
SuspendedJitterSeconds = 30
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -48,6 +54,7 @@ const (
|
||||
RefuseBandwidth = "bandwidth_exhausted"
|
||||
RefuseConcurrency = "concurrency_exceeded"
|
||||
RefuseHours = "outside_hours"
|
||||
RefuseBattery = "on_battery"
|
||||
RefuseTimeout = "timeout"
|
||||
RefuseBadURL = "bad_url"
|
||||
FailFetch = "fetch_failed"
|
||||
@@ -71,7 +78,6 @@ const (
|
||||
|
||||
var FatalReasons = map[string]bool{
|
||||
"revoked": true,
|
||||
"suspended": true,
|
||||
"kill_switch": true,
|
||||
"no_consent": true,
|
||||
"no_account": true,
|
||||
@@ -117,6 +123,7 @@ type ServerSettings struct {
|
||||
ActiveFrom string `json:"active_from"`
|
||||
ActiveTo string `json:"active_to"`
|
||||
Timezone string `json:"timezone"`
|
||||
PauseOnBattery bool `json:"pause_on_battery"`
|
||||
}
|
||||
|
||||
type Frame struct {
|
||||
@@ -131,6 +138,8 @@ type Frame struct {
|
||||
Port int `json:"port"`
|
||||
B64 string `json:"b64"`
|
||||
Reason string `json:"reason"`
|
||||
Until string `json:"until"`
|
||||
DeviceLabel string `json:"device_label"`
|
||||
DeviceUID string `json:"device_uid"`
|
||||
Limits *ServerLimits `json:"limits"`
|
||||
Destinations []Destination `json:"destinations"`
|
||||
|
||||
+43
-9
@@ -25,10 +25,11 @@ func (e *FatalError) Error() string {
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
fatal string
|
||||
fast bool
|
||||
stable bool
|
||||
reason string
|
||||
fatal string
|
||||
fast bool
|
||||
stable bool
|
||||
reason string
|
||||
holdUntil time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
@@ -40,6 +41,8 @@ type Session struct {
|
||||
log *Logger
|
||||
httpc *http.Client
|
||||
jitter func() float64
|
||||
now func() time.Time
|
||||
sleep func(ctx context.Context, d time.Duration)
|
||||
dialWS func(ctx context.Context, rawURL string) (*Conn, error)
|
||||
limits ServerLimits
|
||||
maxBody int
|
||||
@@ -55,6 +58,8 @@ func NewSession(cfg Config, policy *Policy, limiter *Limiter, fetcher *Fetcher,
|
||||
log: logger,
|
||||
httpc: &http.Client{Timeout: time.Duration(TicketTimeoutSeconds) * time.Second, Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}},
|
||||
jitter: rand.Float64,
|
||||
now: time.Now,
|
||||
sleep: sleepContext,
|
||||
dialWS: Dial,
|
||||
limits: defaultLimits(),
|
||||
maxBody: DefaultMaxBodyBytes,
|
||||
@@ -101,6 +106,21 @@ func sleepContext(ctx context.Context, d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) holdDelay(until time.Time) time.Duration {
|
||||
wait := until.Sub(s.now())
|
||||
wait = max(wait, time.Duration(SuspendedMinSeconds)*time.Second)
|
||||
wait = min(wait, time.Duration(SuspendedMaxSeconds)*time.Second)
|
||||
return wait + time.Duration(s.jitter()*float64(SuspendedJitterSeconds)*float64(time.Second))
|
||||
}
|
||||
|
||||
func (s *Session) suspension(until string) outcome {
|
||||
end := s.now().Add(time.Duration(SuspendedDefaultSeconds) * time.Second)
|
||||
if moment, err := time.Parse(time.RFC3339, until); err == nil {
|
||||
end = moment
|
||||
}
|
||||
return outcome{reason: "device is suspended", holdUntil: end}
|
||||
}
|
||||
|
||||
func (s *Session) Run(ctx context.Context) error {
|
||||
attempt := 0
|
||||
for ctx.Err() == nil {
|
||||
@@ -115,6 +135,13 @@ func (s *Session) Run(ctx context.Context) error {
|
||||
attempt = 0
|
||||
}
|
||||
var delay time.Duration
|
||||
if !result.holdUntil.IsZero() {
|
||||
delay = s.holdDelay(result.holdUntil)
|
||||
attempt = 0
|
||||
s.log.Printf("device is suspended until %s, waiting %s", result.holdUntil.UTC().Format(time.RFC3339), delay.Round(time.Second))
|
||||
s.sleep(ctx, delay)
|
||||
continue
|
||||
}
|
||||
if result.fast {
|
||||
delay = time.Duration((0.5 + s.jitter()) * float64(FastRetryMillis) * float64(time.Millisecond))
|
||||
} else {
|
||||
@@ -122,7 +149,7 @@ func (s *Session) Run(ctx context.Context) error {
|
||||
attempt++
|
||||
}
|
||||
s.log.Printf("disconnected (%s), reconnecting in %s", result.reason, delay.Round(100*time.Millisecond))
|
||||
sleepContext(ctx, delay)
|
||||
s.sleep(ctx, delay)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -136,6 +163,7 @@ type errorAnswer struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Reason string `json:"reason"`
|
||||
Until string `json:"until"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
@@ -166,6 +194,9 @@ func (s *Session) fetchTicket(ctx context.Context) (ticketAnswer, outcome) {
|
||||
var failure errorAnswer
|
||||
_ = json.Unmarshal(body, &failure)
|
||||
reason := failure.Error.Reason
|
||||
if reason == SuspendedReason {
|
||||
return answer, s.suspension(failure.Error.Until)
|
||||
}
|
||||
if FatalReasons[reason] {
|
||||
return answer, outcome{fatal: reason}
|
||||
}
|
||||
@@ -292,13 +323,13 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
|
||||
return s.protocolFailure(conn, "welcome is not valid json")
|
||||
}
|
||||
if welcome.Type == "kick" {
|
||||
return s.kickOutcome(welcome.Reason)
|
||||
return s.kickOutcome(welcome.Reason, welcome.Until)
|
||||
}
|
||||
if welcome.Type != "welcome" {
|
||||
return s.protocolFailure(conn, "expected welcome, got "+welcome.Type)
|
||||
}
|
||||
s.applyWelcome(welcome)
|
||||
s.log.Debugf("connected as device %s with %d allowed destinations", welcome.DeviceUID, len(s.policy.Destinations()))
|
||||
s.log.Debugf("connected as device %s (%s) with %d allowed destinations", welcome.DeviceUID, welcome.DeviceLabel, len(s.policy.Destinations()))
|
||||
|
||||
var jobs sync.WaitGroup
|
||||
tunnels := NewTunnels(s.policy, s.limiter, s.stats, send, s.cfg.MaxTunnels)
|
||||
@@ -342,7 +373,7 @@ func (s *Session) converse(ctx context.Context, conn *Conn) outcome {
|
||||
case "settings_update":
|
||||
s.limiter.ApplyServer(nil, frame.Settings)
|
||||
case "kick":
|
||||
return s.kickOutcome(frame.Reason)
|
||||
return s.kickOutcome(frame.Reason, frame.Until)
|
||||
default:
|
||||
return s.protocolFailure(conn, "unexpected frame "+frame.Type)
|
||||
}
|
||||
@@ -355,7 +386,10 @@ func (s *Session) protocolFailure(conn *Conn, reason string) outcome {
|
||||
return outcome{reason: "protocol error: " + reason}
|
||||
}
|
||||
|
||||
func (s *Session) kickOutcome(reason string) outcome {
|
||||
func (s *Session) kickOutcome(reason, until string) outcome {
|
||||
if reason == SuspendedReason {
|
||||
return s.suspension(until)
|
||||
}
|
||||
if FatalReasons[reason] {
|
||||
return outcome{fatal: reason}
|
||||
}
|
||||
|
||||
@@ -268,12 +268,12 @@ func TestSessionLimitsAnswerWithDonorCodes(t *testing.T) {
|
||||
_ = c.WriteClose(CloseGoingAway, "bye")
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended"})
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
|
||||
})
|
||||
h.session.limiter.AddBytes(1 << 40)
|
||||
err := h.run(t)
|
||||
var fatal *FatalError
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "suspended" {
|
||||
if !errors.As(err, &fatal) || fatal.Reason != "revoked" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +286,8 @@ func TestFatalTicketStatuses(t *testing.T) {
|
||||
}{
|
||||
{401, "", "unauthorized"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"no_consent"}}`, "no_consent"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"suspended"}}`, "suspended"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"revoked"}}`, "revoked"},
|
||||
{403, `{"error":{"status":403,"message":"x","reason":"kill_switch"}}`, "kill_switch"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -349,14 +350,120 @@ func TestBackoffStaysInsideBounds(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFatalReasonsMatchPlan(t *testing.T) {
|
||||
for _, reason := range []string{"revoked", "suspended", "kill_switch", "no_consent"} {
|
||||
for _, reason := range []string{"revoked", "kill_switch", "no_consent"} {
|
||||
if !FatalReasons[reason] {
|
||||
t.Errorf("%s must stop reconnecting", reason)
|
||||
}
|
||||
}
|
||||
for _, reason := range []string{"heartbeat", "ticket", "capacity"} {
|
||||
for _, reason := range []string{"heartbeat", "ticket", "capacity", "suspended"} {
|
||||
if FatalReasons[reason] {
|
||||
t.Errorf("%s must reconnect", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sleepLog struct{ waits []time.Duration }
|
||||
|
||||
func (l *sleepLog) sleep(_ context.Context, d time.Duration) { l.waits = append(l.waits, d) }
|
||||
|
||||
func fixedNow(text string) func() time.Time {
|
||||
moment, _ := time.Parse(time.RFC3339, text)
|
||||
return func() time.Time { return moment }
|
||||
}
|
||||
|
||||
func TestSuspendedTicketWaitsUntilTheEndThenRetries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
until string
|
||||
want time.Duration
|
||||
}{
|
||||
{"future", "2026-01-01T01:00:00Z", time.Hour},
|
||||
{"empty", "", 300 * time.Second},
|
||||
{"garbage", "tomorrow", 300 * time.Second},
|
||||
{"expired", "2025-12-31T00:00:00Z", 60 * time.Second},
|
||||
{"far", "2030-01-01T00:00:00Z", 24 * time.Hour},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
body, _ := json.Marshal(map[string]any{"error": map[string]any{"status": 403, "message": "x", "reason": "suspended", "until": item.until}})
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
session.jitter = func() float64 { return 0 }
|
||||
session.now = fixedNow("2026-01-01T00:00:00Z")
|
||||
slept := &sleepLog{}
|
||||
session.sleep = slept.sleep
|
||||
var fatal *FatalError
|
||||
if err := session.Run(context.Background()); !errors.As(err, &fatal) || calls.Load() != 2 {
|
||||
t.Fatalf("err %v calls %d", err, calls.Load())
|
||||
}
|
||||
if len(slept.waits) != 1 || slept.waits[0] != item.want {
|
||||
t.Fatalf("waits %v want %v", slept.waits, item.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendedKickHoldsThenReconnects(t *testing.T) {
|
||||
h := newHarness(t, func(n int, c *Conn) {
|
||||
readFrame(t, c)
|
||||
writeFrame(t, c, welcome())
|
||||
if n == 1 {
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "suspended", "until": "2026-01-01T00:10:00Z"})
|
||||
return
|
||||
}
|
||||
writeFrame(t, c, map[string]any{"type": "kick", "reason": "revoked"})
|
||||
})
|
||||
h.session.now = fixedNow("2026-01-01T00:00:00Z")
|
||||
slept := &sleepLog{}
|
||||
h.session.sleep = slept.sleep
|
||||
var fatal *FatalError
|
||||
if err := h.run(t); !errors.As(err, &fatal) || fatal.Reason != "revoked" {
|
||||
t.Fatalf("run returned %v", err)
|
||||
}
|
||||
if h.conns.Load() != 2 || len(slept.waits) != 1 || slept.waits[0] != 10*time.Minute {
|
||||
t.Fatalf("conns %d waits %v", h.conns.Load(), slept.waits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuspendedWaitHonoursCancel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"status":403,"message":"x","reason":"suspended","until":""}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Server, cfg.Secret = server.URL, testSecret
|
||||
limiter, _ := NewLimiter(cfg, "", time.Now)
|
||||
policy := testPolicy(publicAnswer)
|
||||
session := NewSession(cfg, policy, limiter, NewFetcher(policy), NewStats(time.Now()), NewLogger(testWriter{t}, true))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if err := session.Run(ctx); err != nil || time.Since(started) > 5*time.Second {
|
||||
t.Fatalf("err %v after %v", err, time.Since(started))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeDecodesDeviceLabel(t *testing.T) {
|
||||
frame, err := decodeFrame([]byte(`{"type":"welcome","device_uid":"d","device_label":"Laptop","settings":{"pause_on_battery":true}}`))
|
||||
if err != nil || frame.DeviceLabel != "Laptop" || !frame.Settings.PauseOnBattery {
|
||||
t.Fatalf("%+v %v", frame, err)
|
||||
}
|
||||
kick, _ := decodeFrame([]byte(`{"type":"kick","reason":"suspended","until":"2026-01-01T00:00:00Z"}`))
|
||||
if kick.Until == "" {
|
||||
t.Fatal("until not decoded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ A Manifest V3 extension for Chrome and Firefox from one source tree. It lets a D
|
||||
3. On your DevPlace profile give consent to bandwidth sharing, register a device and copy the `dpn_...` secret (shown once).
|
||||
4. Open the popup, enter the server URL and the secret, grant the host permission for that server when asked, then switch sharing on.
|
||||
|
||||
Packaged build: `./build.sh <outdir>` writes `devplace-node-extension.zip` and `devplace-node-extension.zip.sha256`. The zip holds `manifest.json`, `src/`, `ui/` and this file; tests are not shipped.
|
||||
Packaged build: `./build.sh <outdir>` writes `devplace-node-extension.zip` and `devplace-node-extension.zip.sha256`. The zip holds `manifest.json`, `src/`, `ui/`, `icons/` and this file; tests are not shipped.
|
||||
|
||||
## Chrome and Firefox from one tree
|
||||
|
||||
@@ -27,7 +27,7 @@ Both use the `chrome.*` namespace with promises. Firefox store submission additi
|
||||
|
||||
- The background worker holds the WebSocket. It exchanges the device secret for a 30 second ticket (`POST /nodes/ticket`, `Authorization: Bearer dpn_...`) and opens `WS /nodes/ws?ticket=...`, then sends `hello` with `kind: "extension"` and `modes: ["fetch"]`.
|
||||
- A `pong` is sent every `ping_seconds` as a keepalive and for every server `ping`. A `chrome.alarms` alarm every minute is the reconnect backstop when the worker has been suspended.
|
||||
- Reconnects use full-jitter backoff between `reconnect_min_seconds` and `reconnect_max_seconds`. Close `4013` retries at the minimum delay. A kick (`4403`), a consent or device refusal (`1008` with a reason) or a rejected secret (HTTP 401/403) **halts** and shows the reason; nothing hammers the server.
|
||||
- Reconnects use full-jitter backoff between `reconnect_min_seconds` and `reconnect_max_seconds`. Close `4013` retries at the minimum delay. A kick (`4403`), a consent or device refusal (`1008` with a reason) or a rejected secret (HTTP 401/403) **halts** and shows the reason; nothing hammers the server. A timed suspension is the exception: a ticket `403` with reason `suspended`, or a `kick` with reason `suspended`, puts the client in the `SUSPENDED` state, shows the end time in the popup and retries at the `until` instant (clamped to 60 seconds to 24 hours, plus up to 30 seconds of jitter; 5 minutes when `until` is empty or unreadable).
|
||||
- State (connection, last error) is in `chrome.storage.session`. Config (server URL, secret, limits, disclosure flag) and the usage counters are in `chrome.storage.local`.
|
||||
- The secret is never logged and never shown after saving; the popup shows its first 8 characters only.
|
||||
|
||||
@@ -40,7 +40,7 @@ Independent of anything the server says:
|
||||
- `fetch` with `credentials: "omit"`, `redirect: "manual"`, `cache: "no-store"`, no referrer; only `accept` and `accept-language` are forwarded from the job, so no cookie or authorization header can be injected
|
||||
- each redirect hop is re-checked against the allowlist, at most 5 hops; a redirect that leaves the allowlist is refused with `redirect_not_allowed`
|
||||
- response body capped at `max_body_bytes` and by the remaining daily budget, with `truncated: true`; `set-cookie` style response headers are dropped
|
||||
- timeout by `AbortController`, daily bandwidth cap, concurrency cap and active hours from the local settings, each answered with its refusal code (`timeout`, `bandwidth_exhausted`, `concurrency_exceeded`, `outside_hours`). A server value can only lower these, never raise them
|
||||
- timeout by `AbortController`, daily bandwidth cap, concurrency cap and active hours from the local settings (the active hours must be open in the donor's own browser clock AND in the server window evaluated in the server's timezone, an unknown zone falling back to UTC), each answered with its refusal code (`timeout`, `bandwidth_exhausted`, `concurrency_exceeded`, `outside_hours`). A server value can only lower these, never raise them
|
||||
|
||||
Known limit: browsers hide the target of a `redirect: "manual"` response (an opaque redirect), so the extension cannot read where it points. It fails closed with `redirect_not_allowed`. A followable redirect is only handled when the response exposes its `Location`.
|
||||
|
||||
@@ -61,10 +61,14 @@ Shared: the URL of the requested public page, the page content that comes back,
|
||||
|
||||
## Tests
|
||||
|
||||
`node --test nodes/extension/test/*.mjs` (no dependencies). They cover Policy, Limits, Fetcher with a stubbed `fetch`, WsClient with a fake WebSocket, and a drift check of `src/spec.js` against `spec.py`. Not covered by automated tests: `background.js` and the popup, which need a real browser.
|
||||
`node --test nodes/extension/test/*.mjs` (no dependencies). They cover Policy, Limits (including a server-timezone parity table generated with Python `zoneinfo`), the popup helpers, the packaged icons, WsClient suspension and device label, Fetcher with a stubbed `fetch`, WsClient with a fake WebSocket, and a drift check of `src/spec.js` against `spec.py`. Not covered by automated tests: `background.js` and the popup, which need a real browser.
|
||||
|
||||
## Gaps
|
||||
|
||||
- No icons yet; the browser shows a default.
|
||||
- The `welcome` frame carries no device label, so the label in the popup is local only.
|
||||
- Not tested in a real Chrome or Firefox session in this change.
|
||||
- Battery-aware pause is not implemented in the extension (browsers expose no reliable power state to a background worker); the Go client honours `pause_on_battery`.
|
||||
- Verified in real browsers on 2026-10-16: Chromium through `tests/e2e/nodes/extension.py` (disclosure, connect, online, a fetch-mode job answered 403 via `fetch`, suspend, disable) and Firefox 158 driven by geckodriver and selenium (same flow). The Firefox script is not in the repository. Method: start a scratch DevPlace server with a donor account, install the extension as a temporary add-on, open the popup from a chrome-context tab (`--remote-allow-system-access`), and watch the device go online in `/admin/nodes`.
|
||||
- `manifest.json` sets `content_security_policy.extension_pages` to `script-src 'self'; object-src 'self'`. Firefox's default MV3 policy includes `upgrade-insecure-requests`, which rewrites `ws://127.0.0.1` to `wss://` and breaks a loopback server; a public `wss://` server is unaffected.
|
||||
|
||||
## Icons
|
||||
|
||||
`icons/icon-16|32|48|128.png` are generated by `python3 tools/make_icons.py` (Pillow), a node glyph on a rounded dark-blue square. The popup shows the server's device label from `welcome` and falls back to the label typed locally before the first connect.
|
||||
|
||||
@@ -15,7 +15,7 @@ rm -f "$archive"
|
||||
|
||||
cd "$here"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
zip -q -X -r "$archive" manifest.json src ui README.md
|
||||
zip -q -X -r "$archive" manifest.json src ui icons README.md
|
||||
else
|
||||
python3 - "$archive" <<'PY'
|
||||
import sys
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
|
||||
archive = sys.argv[1]
|
||||
paths = [Path("manifest.json"), Path("README.md")]
|
||||
for folder in ("src", "ui"):
|
||||
for folder in ("src", "ui", "icons"):
|
||||
paths.extend(sorted(item for item in Path(folder).rglob("*") if item.is_file()))
|
||||
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||
for path in paths:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 720 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -8,9 +8,22 @@
|
||||
"scripts": ["src/background.js"],
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "DevPlace Node",
|
||||
"default_popup": "ui/popup.html"
|
||||
"default_popup": "ui/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||
},
|
||||
"permissions": ["storage", "alarms"],
|
||||
"host_permissions": [
|
||||
|
||||
@@ -27,6 +27,26 @@ function minutes(text) {
|
||||
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
||||
}
|
||||
|
||||
function windowOpen(from, to, current) {
|
||||
const start = minutes(from);
|
||||
const end = minutes(to);
|
||||
if (start === null || end === null || start === end) {
|
||||
return true;
|
||||
}
|
||||
return start < end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
|
||||
function zoneMinutes(moment, timeZone) {
|
||||
let format;
|
||||
try {
|
||||
format = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
} catch (error) {
|
||||
format = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
}
|
||||
const parts = Object.fromEntries(format.formatToParts(moment).map((part) => [part.type, part.value]));
|
||||
return Number(parts.hour) * 60 + Number(parts.minute);
|
||||
}
|
||||
|
||||
export class Limits {
|
||||
constructor(options = {}) {
|
||||
this.baked = options.baked || LIMITS;
|
||||
@@ -39,6 +59,7 @@ export class Limits {
|
||||
this.reconnectMax = this.baked.reconnect_max_seconds;
|
||||
this.serverBandwidthMb = this.baked.max_donor_mb_per_day;
|
||||
this.serverConcurrent = this.baked.max_concurrent_per_node;
|
||||
this.serverWindow = { from: "", to: "", timeZone: "UTC" };
|
||||
this.local = Limits.normaliseLocal({}, this.baked);
|
||||
this.inflight = 0;
|
||||
if (options.local) {
|
||||
@@ -97,6 +118,11 @@ export class Limits {
|
||||
this.baked.max_concurrent_per_node,
|
||||
this.baked.max_concurrent_per_node,
|
||||
);
|
||||
this.serverWindow = {
|
||||
from: typeof values.active_from === "string" ? values.active_from : "",
|
||||
to: typeof values.active_to === "string" ? values.active_to : "",
|
||||
timeZone: typeof values.timezone === "string" && values.timezone ? values.timezone : "UTC",
|
||||
};
|
||||
}
|
||||
|
||||
get maxBodyBytes() {
|
||||
@@ -137,14 +163,12 @@ export class Limits {
|
||||
}
|
||||
|
||||
inActiveHours() {
|
||||
const from = minutes(this.local.activeFrom);
|
||||
const to = minutes(this.local.activeTo);
|
||||
if (from === null || to === null || from === to) {
|
||||
return true;
|
||||
}
|
||||
const now = this.clock();
|
||||
const current = now.getHours() * 60 + now.getMinutes();
|
||||
return from < to ? current >= from && current < to : current >= from || current < to;
|
||||
if (!windowOpen(this.local.activeFrom, this.local.activeTo, now.getHours() * 60 + now.getMinutes())) {
|
||||
return false;
|
||||
}
|
||||
const { from, to, timeZone } = this.serverWindow;
|
||||
return windowOpen(from, to, zoneMinutes(now, timeZone));
|
||||
}
|
||||
|
||||
admit(usedBytes) {
|
||||
|
||||
@@ -6,13 +6,14 @@ export const STATES = Object.freeze({
|
||||
ONLINE: "online",
|
||||
BACKOFF: "backoff",
|
||||
HALTED: "halted",
|
||||
SUSPENDED: "suspended",
|
||||
});
|
||||
|
||||
export class State {
|
||||
constructor(area, clock = () => Date.now()) {
|
||||
this.area = area;
|
||||
this.clock = clock;
|
||||
this.data = { state: STATES.OFF, detail: "", since: clock(), deviceUid: "", lastError: "" };
|
||||
this.data = { state: STATES.OFF, detail: "", since: clock(), deviceUid: "", deviceLabel: "", suspendedUntil: "", lastError: "" };
|
||||
this.chain = Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
REFUSALS,
|
||||
SERVER_FRAMES,
|
||||
SUSPENSION,
|
||||
} from "./spec.js";
|
||||
|
||||
const OPEN = 1;
|
||||
@@ -35,6 +36,7 @@ export class WsClient {
|
||||
this.WebSocketImpl = options.WebSocketImpl || globalThis.WebSocket;
|
||||
this.timers = options.timers || globalThis;
|
||||
this.random = options.random || Math.random;
|
||||
this.clock = options.clock || (() => Date.now());
|
||||
this.clientVersion = options.clientVersion || "0.0.0";
|
||||
this.platform = options.platform || "chrome";
|
||||
this.baked = options.baked || DESTINATIONS;
|
||||
@@ -120,6 +122,37 @@ export class WsClient {
|
||||
await this.state.set({ state: STATES.HALTED, detail, lastError: detail });
|
||||
}
|
||||
|
||||
suspensionDelaySeconds(until) {
|
||||
const end = Date.parse(until);
|
||||
const seconds = Number.isNaN(end) ? SUSPENSION.default_seconds : (end - this.clock()) / 1000;
|
||||
const bounded = Math.min(SUSPENSION.max_seconds, Math.max(SUSPENSION.min_seconds, seconds));
|
||||
return bounded + this.random() * SUSPENSION.jitter_seconds;
|
||||
}
|
||||
|
||||
async suspend(until) {
|
||||
if (!this.wanted) {
|
||||
return;
|
||||
}
|
||||
const seconds = this.suspensionDelaySeconds(until);
|
||||
this.welcomed = false;
|
||||
this.clearTimers();
|
||||
this.generation += 1;
|
||||
this.closeSocket(CLOSE_NORMAL, "suspended");
|
||||
this.retryTimer = this.timers.setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.ensure();
|
||||
}, seconds * 1000);
|
||||
const ends = Number.isNaN(Date.parse(until))
|
||||
? new Date(this.clock() + seconds * 1000).toISOString()
|
||||
: new Date(until).toISOString();
|
||||
await this.state.set({
|
||||
state: STATES.SUSPENDED,
|
||||
detail: "This device is suspended by the server",
|
||||
suspendedUntil: ends,
|
||||
retryInSeconds: Math.round(seconds),
|
||||
});
|
||||
}
|
||||
|
||||
async fetchTicket() {
|
||||
const { serverUrl, secret } = this.config.credentials();
|
||||
const response = await this.fetchImpl(serverUrl + TICKET_PATH, {
|
||||
@@ -129,14 +162,18 @@ export class WsClient {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
let reason = "";
|
||||
let detail = {};
|
||||
try {
|
||||
reason = (await response.json()).reason || "";
|
||||
const body = await response.json();
|
||||
detail = body && typeof body.error === "object" && body.error ? body.error : body || {};
|
||||
} catch (error) {
|
||||
reason = "";
|
||||
detail = {};
|
||||
}
|
||||
const reason = typeof detail.reason === "string" ? detail.reason : "";
|
||||
const error = new Error(`ticket refused with ${response.status} ${reason}`.trim());
|
||||
error.status = response.status;
|
||||
error.reason = reason;
|
||||
error.until = typeof detail.until === "string" ? detail.until : "";
|
||||
throw error;
|
||||
}
|
||||
const body = await response.json();
|
||||
@@ -164,6 +201,10 @@ export class WsClient {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
if (error.status === 403 && error.reason === SUSPENSION.reason) {
|
||||
await this.suspend(error.until);
|
||||
return;
|
||||
}
|
||||
if (HALT_STATUSES.includes(error.status)) {
|
||||
await this.halt(error.message);
|
||||
return;
|
||||
@@ -276,6 +317,8 @@ export class WsClient {
|
||||
this.runJob(frame);
|
||||
} else if (frame.type === "settings_update") {
|
||||
this.limits.applyServerSettings(frame.settings);
|
||||
} else if (frame.type === "kick" && frame.reason === SUSPENSION.reason) {
|
||||
await this.suspend(typeof frame.until === "string" ? frame.until : "");
|
||||
} else if (frame.type === "kick") {
|
||||
await this.halt(`kicked by the server: ${frame.reason}`);
|
||||
} else {
|
||||
@@ -303,6 +346,8 @@ export class WsClient {
|
||||
state: STATES.ONLINE,
|
||||
detail: notes,
|
||||
deviceUid: frame.device_uid,
|
||||
deviceLabel: typeof frame.device_label === "string" ? frame.device_label : "",
|
||||
suspendedUntil: "",
|
||||
lastError: notes,
|
||||
});
|
||||
}
|
||||
@@ -344,6 +389,9 @@ export class WsClient {
|
||||
}
|
||||
|
||||
classifyClose(code, reason) {
|
||||
if (code === CLOSE_KICKED && reason === SUSPENSION.reason) {
|
||||
return { action: "suspend", detail: "suspended by the server" };
|
||||
}
|
||||
if (code === CLOSE_KICKED) {
|
||||
return { action: "halt", detail: `closed by the server: ${reason || "kicked"}` };
|
||||
}
|
||||
@@ -366,6 +414,10 @@ export class WsClient {
|
||||
return;
|
||||
}
|
||||
const verdict = this.classifyClose(code, reason);
|
||||
if (verdict.action === "suspend") {
|
||||
await this.suspend("");
|
||||
return;
|
||||
}
|
||||
if (verdict.action === "halt") {
|
||||
await this.halt(verdict.detail);
|
||||
return;
|
||||
|
||||
@@ -65,10 +65,19 @@ export const REFUSALS = Object.freeze({
|
||||
BANDWIDTH_EXHAUSTED: "bandwidth_exhausted",
|
||||
CONCURRENCY_EXCEEDED: "concurrency_exceeded",
|
||||
OUTSIDE_HOURS: "outside_hours",
|
||||
ON_BATTERY: "on_battery",
|
||||
BODY_TOO_LARGE: "body_too_large",
|
||||
TIMEOUT: "timeout",
|
||||
});
|
||||
|
||||
export const SUSPENSION = Object.freeze({
|
||||
reason: "suspended",
|
||||
min_seconds: 60,
|
||||
max_seconds: 24 * 60 * 60,
|
||||
default_seconds: 300,
|
||||
jitter_seconds: 30,
|
||||
});
|
||||
|
||||
export const FETCH_FAILED = "fetch_failed";
|
||||
|
||||
export const SERVER_FRAMES = Object.freeze({
|
||||
|
||||
@@ -89,3 +89,70 @@ test("hello limits advertise the effective concurrency", () => {
|
||||
const limits = new Limits({ local: { maxConcurrent: 1 } });
|
||||
assert.deepEqual(limits.helloLimits(), { max_concurrent: 1 });
|
||||
});
|
||||
|
||||
function serverLimits(window, clock, local = {}) {
|
||||
const limits = new Limits({ clock, local });
|
||||
limits.applyServerSettings({ active_from: window.from, active_to: window.to, timezone: window.zone });
|
||||
return limits;
|
||||
}
|
||||
|
||||
test("the server window is evaluated in the server timezone", () => {
|
||||
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "UTC" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "20:00", to: "23:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "22:00", to: "06:00", zone: "America/New_York" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "21:00", to: "06:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
});
|
||||
|
||||
test("an invalid server timezone falls back to UTC and empty or equal bounds mean always", () => {
|
||||
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "13:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "", to: "", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "10:00", to: "10:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
const bare = new Limits({ clock: moment });
|
||||
bare.applyServerSettings({});
|
||||
assert.equal(bare.inActiveHours(), true);
|
||||
});
|
||||
|
||||
test("both the donor window and the server window must be open", () => {
|
||||
const noon = at(12);
|
||||
const open = { from: "00:00", to: "23:59", zone: "UTC" };
|
||||
const shut = { from: "00:00", to: "00:01", zone: "UTC" };
|
||||
assert.equal(serverLimits(open, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), null);
|
||||
assert.equal(serverLimits(shut, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
assert.equal(serverLimits(open, noon, { activeFrom: "13:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
});
|
||||
|
||||
test("a settings_update replaces the server window", () => {
|
||||
const limits = serverLimits({ from: "13:00", to: "17:00", zone: "UTC" }, () => new Date(Date.UTC(2026, 0, 15, 12, 0)));
|
||||
assert.equal(limits.inActiveHours(), false);
|
||||
limits.applyServerSettings({ active_from: "", active_to: "", timezone: "UTC" });
|
||||
assert.equal(limits.inActiveHours(), true);
|
||||
});
|
||||
|
||||
const BASE = 1767225600;
|
||||
const STEP = 40009;
|
||||
const SAMPLES = 200;
|
||||
const PARITY = [
|
||||
{ from: "09:00", to: "17:00", zone: "Europe/Amsterdam", bits: "01010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000001010101010000" },
|
||||
{ from: "22:00", to: "06:00", zone: "America/New_York", bits: "00010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000000101010101000010101010100000010101010000001010101" },
|
||||
{ from: "08:30", to: "12:15", zone: "Asia/Kolkata", bits: "00000001010000000000101000000000010101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101010000000000101" },
|
||||
{ from: "00:00", to: "12:00", zone: "Pacific/Auckland", bits: "01101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101011010101010101101010101010100101010101010010101010" },
|
||||
{ from: "10:00", to: "10:00", zone: "UTC", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
|
||||
{ from: "09:00", to: "17:00", zone: "Not/AZone", bits: "01010000101010101000000101010100000010101010000001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010" },
|
||||
{ from: "23:00", to: "01:00", zone: "Australia/Lord_Howe", bits: "00000000000010000000000001010000000000001000000000000100000000000010000000000001010000000000001000000000000100000000000010000000000000010000000000001000000000000100000000000010000000000000010000000000" },
|
||||
{ from: "", to: "", zone: "Europe/Amsterdam", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
|
||||
];
|
||||
|
||||
test("server window parity with the Python scheduler over 200 sampled instants", () => {
|
||||
for (const item of PARITY) {
|
||||
assert.equal(item.bits.length, SAMPLES);
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const moment = new Date((BASE + index * STEP) * 1000);
|
||||
const limits = serverLimits({ from: item.from, to: item.to, zone: item.zone }, () => moment);
|
||||
assert.equal(limits.inActiveHours(), item.bits[index] === "1", `${item.from}-${item.to} ${item.zone} #${index}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = (path) => fileURLToPath(new URL(`../${path}`, import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(root("manifest.json"), "utf8"));
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
test("the manifest declares icons and the action icon, and every file is a PNG of its declared size", () => {
|
||||
assert.deepEqual(Object.keys(manifest.icons), ["16", "32", "48", "128"]);
|
||||
for (const [size, path] of Object.entries(manifest.icons)) {
|
||||
assert.ok(existsSync(root(path)), path);
|
||||
const data = readFileSync(root(path));
|
||||
assert.equal(data.subarray(0, 8).equals(PNG_SIGNATURE), true, path);
|
||||
assert.equal(data.subarray(12, 16).toString(), "IHDR", path);
|
||||
assert.equal(data.readUInt32BE(16), Number(size), `${path} width`);
|
||||
assert.equal(data.readUInt32BE(20), Number(size), `${path} height`);
|
||||
}
|
||||
for (const [size, path] of Object.entries(manifest.action.default_icon)) {
|
||||
assert.equal(manifest.icons[size], path);
|
||||
}
|
||||
});
|
||||
|
||||
test("build.sh ships the icons", () => {
|
||||
const script = readFileSync(root("build.sh"), "utf8");
|
||||
assert.match(script, /manifest\.json src ui icons README\.md/);
|
||||
assert.match(script, /\("src", "ui", "icons"\)/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { describeState, displayLabel } from "../ui/popup.js";
|
||||
|
||||
test("a suspended state shows the end time in DD/MM/YYYY and says it retries", () => {
|
||||
const text = describeState({ state: "suspended", suspendedUntil: "2026-03-05T10:07:00Z", detail: "x" });
|
||||
assert.match(text, /^Suspended until \d{2}\/\d{2}\/2026 \d{2}:\d{2}, retrying automatically$/);
|
||||
assert.equal(describeState({ state: "suspended", suspendedUntil: "" }), "Suspended, retrying automatically");
|
||||
});
|
||||
|
||||
test("other states show detail then last error", () => {
|
||||
assert.equal(describeState({ state: "online", detail: "ok", lastError: "e" }), "ok");
|
||||
assert.equal(describeState({ state: "backoff", detail: "", lastError: "e" }), "e");
|
||||
});
|
||||
|
||||
test("the server label is the authority and the local label is the fallback", () => {
|
||||
assert.equal(displayLabel({ deviceLabel: "Server name" }, { label: "typed" }), "Server name");
|
||||
assert.equal(displayLabel({ deviceLabel: "" }, { label: "typed" }), "typed");
|
||||
assert.equal(displayLabel({}, { label: "typed" }), "typed");
|
||||
});
|
||||
@@ -78,6 +78,8 @@ class FakeTimers {
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = Date.parse("2026-01-01T00:00:00Z");
|
||||
|
||||
function build(overrides = {}) {
|
||||
FakeSocket.instances = [];
|
||||
const timers = new FakeTimers();
|
||||
@@ -104,6 +106,7 @@ function build(overrides = {}) {
|
||||
WebSocketImpl: FakeSocket,
|
||||
timers,
|
||||
random: overrides.random || (() => 0.5),
|
||||
clock: () => NOW,
|
||||
clientVersion: "9.9.9",
|
||||
platform: "firefox",
|
||||
});
|
||||
@@ -321,3 +324,109 @@ test("settings_update can only lower the effective limits", async () => {
|
||||
assert.equal(context.limits.concurrency, 1);
|
||||
assert.equal(context.limits.bandwidthBytes, 10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
function suspendedTicket(until) {
|
||||
return async () =>
|
||||
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "suspended", until } }), {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
test("a suspended ticket waits until the end instead of halting, then retries", async () => {
|
||||
const context = build({ random: () => 0, ticketFetch: suspendedTicket("2026-01-01T01:00:00Z") });
|
||||
await context.client.start();
|
||||
const snapshot = context.state.snapshot();
|
||||
assert.equal(snapshot.state, STATES.SUSPENDED);
|
||||
assert.equal(snapshot.suspendedUntil, "2026-01-01T01:00:00.000Z");
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 3600 * 1000);
|
||||
assert.equal(context.client.wanted, true);
|
||||
await context.client.ensure();
|
||||
assert.equal(FakeSocket.instances.length, 0);
|
||||
});
|
||||
|
||||
test("suspension waits are clamped and an unusable until waits five minutes", async () => {
|
||||
const cases = [
|
||||
["2025-12-31T00:00:00Z", 60],
|
||||
["2030-01-01T00:00:00Z", 24 * 3600],
|
||||
["", 300],
|
||||
["tomorrow", 300],
|
||||
];
|
||||
for (const [until, seconds] of cases) {
|
||||
const context = build({ random: () => 0, ticketFetch: suspendedTicket(until) });
|
||||
await context.client.start();
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, seconds * 1000, until);
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
}
|
||||
const jittered = build({ random: () => 1, ticketFetch: suspendedTicket("") });
|
||||
await jittered.client.start();
|
||||
assert.equal(jittered.timers.timeouts.at(-1).ms, 330 * 1000);
|
||||
});
|
||||
|
||||
test("the retry after a suspension asks for a new ticket", async () => {
|
||||
let calls = 0;
|
||||
const context = build({
|
||||
random: () => 0,
|
||||
ticketFetch: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return suspendedTicket("2026-01-01T00:02:00Z")();
|
||||
}
|
||||
return new Response(JSON.stringify({ ticket: "tk2", ws_path: "/nodes/ws" }), { status: 200 });
|
||||
},
|
||||
});
|
||||
await context.client.start();
|
||||
context.timers.timeouts.at(-1).fn();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(FakeSocket.instances.length, 1);
|
||||
});
|
||||
|
||||
test("a revoked ticket or other 403 reason still halts", async () => {
|
||||
const context = build({
|
||||
ticketFetch: async () =>
|
||||
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "revoked" } }), { status: 403 }),
|
||||
});
|
||||
await context.client.start();
|
||||
assert.equal(context.state.snapshot().state, STATES.HALTED);
|
||||
});
|
||||
|
||||
test("a suspended kick with until closes the socket and holds until then", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "kick", reason: "suspended", until: "2026-01-01T00:10:00Z" });
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 600 * 1000);
|
||||
assert.equal(socket.closed.code, 1000);
|
||||
assert.equal(context.timers.intervals[0].cleared, true);
|
||||
});
|
||||
|
||||
test("a suspended kick without until waits five minutes", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "kick", reason: "suspended" });
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 300 * 1000);
|
||||
});
|
||||
|
||||
test("a close 4403 with reason suspended holds, any other 4403 halts", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.serverClose(4403, "suspended");
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
});
|
||||
|
||||
test("welcome stores the server device label and clears an old suspension", async () => {
|
||||
const context = build();
|
||||
await context.state.set({ suspendedUntil: "2026-01-01T00:00:00.000Z" });
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket.open();
|
||||
await socket.receive(welcome({ device_label: "Work laptop" }));
|
||||
assert.equal(context.state.snapshot().deviceLabel, "Work laptop");
|
||||
assert.equal(context.state.snapshot().suspendedUntil, "");
|
||||
});
|
||||
|
||||
test("a welcome without a label leaves the label empty so the popup falls back", async () => {
|
||||
const context = build();
|
||||
await online(context);
|
||||
assert.equal(context.state.snapshot().deviceLabel, "");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SIZES = (16, 32, 48, 128)
|
||||
SCALE = 8
|
||||
BACKGROUND = (17, 43, 94, 255)
|
||||
GLYPH = (234, 242, 255, 255)
|
||||
CENTER = (0.5, 0.5)
|
||||
OUTER = ((0.5, 0.2), (0.24, 0.68), (0.76, 0.68))
|
||||
CORNER_RADIUS = 0.22
|
||||
CENTER_RADIUS = 0.1
|
||||
OUTER_RADIUS = 0.075
|
||||
LINE_WIDTH = 0.04
|
||||
|
||||
|
||||
def render(size: int) -> Image.Image:
|
||||
big = size * SCALE
|
||||
image = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rounded_rectangle((0, 0, big - 1, big - 1), radius=big * CORNER_RADIUS, fill=BACKGROUND)
|
||||
|
||||
def point(pair):
|
||||
return (pair[0] * big, pair[1] * big)
|
||||
|
||||
for outer in OUTER:
|
||||
draw.line((point(CENTER), point(outer)), fill=GLYPH, width=max(1, round(LINE_WIDTH * big)))
|
||||
for pair, radius in [(CENTER, CENTER_RADIUS)] + [(outer, OUTER_RADIUS) for outer in OUTER]:
|
||||
x, y = point(pair)
|
||||
r = radius * big
|
||||
draw.ellipse((x - r, y - r, x + r, y + r), fill=GLYPH)
|
||||
return image.resize((size, size), Image.LANCZOS)
|
||||
|
||||
|
||||
def main(out_dir: Path) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for size in SIZES:
|
||||
render(size).save(out_dir / f"icon-{size}.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent / "icons")
|
||||
@@ -108,7 +108,8 @@ p {
|
||||
}
|
||||
|
||||
.dot.connecting,
|
||||
.dot.backoff {
|
||||
.dot.backoff,
|
||||
.dot.suspended {
|
||||
background: var(--warn);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const STATE_LABELS = {
|
||||
online: "Online",
|
||||
backoff: "Reconnecting",
|
||||
halted: "Stopped",
|
||||
suspended: "Suspended",
|
||||
};
|
||||
|
||||
function formatBytes(value) {
|
||||
@@ -18,6 +19,30 @@ function formatBytes(value) {
|
||||
return `${unit === 0 ? size : size.toFixed(1)} ${UNITS[unit]}`;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatInstant(iso) {
|
||||
const moment = new Date(iso);
|
||||
if (Number.isNaN(moment.getTime())) {
|
||||
return "";
|
||||
}
|
||||
return `${pad(moment.getDate())}/${pad(moment.getMonth() + 1)}/${moment.getFullYear()} ${pad(moment.getHours())}:${pad(moment.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function describeState(state) {
|
||||
if (state.state === "suspended") {
|
||||
const ends = formatInstant(state.suspendedUntil);
|
||||
return ends ? `Suspended until ${ends}, retrying automatically` : "Suspended, retrying automatically";
|
||||
}
|
||||
return state.detail || state.lastError || "";
|
||||
}
|
||||
|
||||
export function displayLabel(state, config) {
|
||||
return state.deviceLabel || config.label;
|
||||
}
|
||||
|
||||
class Popup {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
@@ -55,8 +80,8 @@ class Popup {
|
||||
const { config, state, usage } = view;
|
||||
this.$("dot").className = `dot ${state.state}`;
|
||||
this.$("state").textContent = STATE_LABELS[state.state] || state.state;
|
||||
this.$("detail").textContent = state.detail || state.lastError || "";
|
||||
this.$("label").textContent = config.label;
|
||||
this.$("detail").textContent = describeState(state);
|
||||
this.$("label").textContent = displayLabel(state, config);
|
||||
this.$("disclosure-card").hidden = config.disclosed;
|
||||
const toggle = this.$("toggle");
|
||||
toggle.checked = config.enabled;
|
||||
@@ -152,4 +177,6 @@ class Popup {
|
||||
}
|
||||
}
|
||||
|
||||
new Popup(document).start();
|
||||
if (typeof document !== "undefined") {
|
||||
new Popup(document).start();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user