Files
2026-10-14 18:22:59 -04:00

201 lines
5.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package webscripts
import (
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const steamNotificationDelay = 500 * time.Millisecond
var steamRelativeTime = regexp.MustCompile(`(?i)^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$`)
var steamShaderProgress = regexp.MustCompile(`^Processing Vulkan shaders \((\d{1,3})%\)$`)
// ShaderProgress recognizes Steam's English shader status, which Chromium
// exposes as static text rather than an AT-SPI progress bar. Match the leaf
// only: containing sections repeat this text alongside dialog controls.
func ShaderProgress(processName, eventType, role, name string) (float64, bool) {
if MatchProcess(processName) != Steam || eventType != "object:property-change:accessible-name" || role != "static" {
return 0, false
}
match := steamShaderProgress.FindStringSubmatch(strings.TrimSpace(name))
if match == nil {
return 0, false
}
percentage, err := strconv.Atoi(match[1])
return float64(percentage), err == nil && percentage <= 100
}
// NotificationRoot mirrors Python's Steam root selection: notification/alert
// ancestors take precedence over inherited live-region attributes on a leaf.
// Candidates are ordered from the event source towards its application root.
func NotificationRoot(candidates []NotificationCandidate) int {
for index, candidate := range candidates {
if candidate.Role == "notification" || candidate.Role == "alert" {
return index
}
}
for index, candidate := range candidates {
if candidate.Live == "assertive" || candidate.Live == "polite" ||
candidate.ContainerLive == "assertive" || candidate.ContainerLive == "polite" {
return index
}
}
return -1
}
type notificationKey struct{ busName, rootPath string }
type pendingNotification struct {
text string
windowPath string
timestampOnly bool
deadline time.Time
}
type completedNotification struct {
text string
at time.Time
}
type steamNotifications struct {
pending map[notificationKey]pendingNotification
last map[string]completedNotification
}
func normalize(text string) string {
return strings.Join(strings.Fields(strings.ReplaceAll(text, "\ufffc", "")), " ")
}
func contains(text, other string) bool {
text, other = strings.ToLower(text), strings.ToLower(other)
if len(other) < 4 {
return text == other
}
return strings.Contains(text, other)
}
func appendTimestamp(text, timestamp string) string {
if strings.ContainsAny(text[len(text)-1:], ".!?") {
return text + " " + timestamp
}
return text + ". " + timestamp
}
func (steam *steamNotifications) queue(message Notification, now time.Time) []string {
text := normalize(message.Text)
if text == "" {
return nil
}
if steam.pending == nil {
steam.pending = make(map[notificationKey]pendingNotification)
}
key := notificationKey{message.BusName, message.RootPath}
pending, exists := steam.pending[key]
timestamp := steamRelativeTime.MatchString(text)
var messages []string
if exists {
switch {
case text == pending.text:
return nil
case timestamp:
if pending.timestampOnly {
pending.text = text
} else {
pending.text = appendTimestamp(pending.text, text)
}
case pending.timestampOnly:
pending.text = appendTimestamp(text, pending.text)
pending.timestampOnly = false
case contains(text, pending.text):
pending.text = text
case contains(pending.text, text):
return nil
default:
pending.text = normalize(pending.text + " " + text)
}
} else {
// Bound outstanding roots without merging independent toasts. Finish
// the oldest root if a provider emits an excessive burst.
if len(steam.pending) >= 32 {
oldest := steam.orderedKeys()[0]
messages = steam.complete(oldest, now)
}
pending = pendingNotification{text: text, timestampOnly: timestamp}
}
// Bound accumulated fragments as well as each native capture.
characters := []rune(pending.text)
if len(characters) > 4096 {
pending.text = string(characters[:4096])
}
pending.deadline = now.Add(steamNotificationDelay)
pending.windowPath = message.WindowPath
steam.pending[key] = pending
return messages
}
func (steam *steamNotifications) orderedKeys() []notificationKey {
keys := make([]notificationKey, 0, len(steam.pending))
for key := range steam.pending {
keys = append(keys, key)
}
sort.Slice(keys, func(first, second int) bool {
left, right := keys[first], keys[second]
if steam.pending[left].deadline.Equal(steam.pending[right].deadline) {
if left.busName == right.busName {
return left.rootPath < right.rootPath
}
return left.busName < right.busName
}
return steam.pending[left].deadline.Before(steam.pending[right].deadline)
})
return keys
}
func (steam *steamNotifications) complete(key notificationKey, now time.Time) []string {
pending := steam.pending[key]
delete(steam.pending, key)
if pending.timestampOnly {
return nil
}
last := steam.last[key.busName]
if last.text == pending.text && now.Sub(last.at) < time.Second {
return nil
}
if steam.last == nil {
steam.last = make(map[string]completedNotification)
}
for busName, completed := range steam.last {
if now.Sub(completed.at) >= time.Second {
delete(steam.last, busName)
}
}
if len(steam.last) >= 64 {
clear(steam.last)
}
steam.last[key.busName] = completedNotification{text: pending.text, at: now}
return []string{"Steam: " + pending.text}
}
func (steam *steamNotifications) flush(now time.Time) []string {
var messages []string
for _, key := range steam.orderedKeys() {
if !steam.pending[key].deadline.After(now) {
messages = append(messages, steam.complete(key, now)...)
}
}
return messages
}
func (steam *steamNotifications) deadline() time.Time {
keys := steam.orderedKeys()
if len(keys) == 0 {
return time.Time{}
}
return steam.pending[keys[0]].deadline
}