201 lines
5.8 KiB
Go
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
|
|
}
|