// 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 }