diff --git a/.stylelintrc.json b/.stylelintrc.json
index dcfad55..2667832 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -13,7 +13,16 @@
"spacing",
"icon-size",
"z-position",
- "icon-shadow"
+ "icon-shadow",
+ "border-radius-topleft",
+ "border-radius-topright",
+ "border-radius-bottomleft",
+ "border-radius-bottomright",
+ "background-gradient-direction",
+ "background-gradient-start",
+ "background-gradient-end",
+ "-arrow-rise",
+ "-boxpointer-gap"
]
}
],
diff --git a/data/po/pt_BR.po b/data/po/pt_BR.po
index bc28fee..d7a8f4d 100644
--- a/data/po/pt_BR.po
+++ b/data/po/pt_BR.po
@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: aurora-shell\n"
"Report-Msgid-Bugs-To: https://github.com/luminusOS/aurora-shell/issues\n"
-"POT-Creation-Date: 2026-09-06 17:06-0300\n"
+"POT-Creation-Date: 2026-09-13 18:39-0300\n"
"PO-Revision-Date: 2026-07-16 09:20-0300\n"
"Last-Translator: Aurora Shell Contributors\n"
"Language-Team: Portuguese (Brazil)\n"
@@ -226,27 +226,19 @@ msgstr ""
"Insere o texto selecionado no campo que estava em foco anteriormente. "
"Atenção: requer um campo Wayland ativo compatível com text-input-v3"
-#: dist/clipboard/clipboardHistory.manifest.js:27
-msgid "Poll Interval (ms)"
-msgstr "Intervalo de verificação (ms)"
-
-#: dist/clipboard/clipboardHistory.manifest.js:28
-msgid "How often to check the clipboard for changes"
-msgstr "Frequência de verificação de alterações na área de transferência"
-
-#: dist/clipboard/clipboardItem.js:148
+#: dist/clipboard/clipboardItem.js:151
msgid "Copy"
msgstr "Copiar"
-#: dist/clipboard/clipboardItem.js:151
+#: dist/clipboard/clipboardItem.js:154
msgid "Unpin"
msgstr "Desafixar"
-#: dist/clipboard/clipboardItem.js:151
+#: dist/clipboard/clipboardItem.js:154
msgid "Pin"
msgstr "Fixar"
-#: dist/clipboard/clipboardItem.js:154
+#: dist/clipboard/clipboardItem.js:157 dist/dev/devToolModuleSettings.js:144
msgid "Delete"
msgstr "Excluir"
@@ -334,6 +326,386 @@ msgstr ""
"Recolore automaticamente ícones SNI monocromáticos para corresponder ao tema "
"do painel"
+#: dist/dev/calendarRemindersDevTool.js:50
+#, fuzzy
+msgid "Fake events"
+msgstr "Eventos e alertas fictícios"
+
+#: dist/dev/calendarRemindersDevTool.js:59
+#, fuzzy
+msgid "reminder"
+msgstr "Lembrete do calendário"
+
+#: dist/dev/calendarRemindersDevTool.js:59
+#, fuzzy
+msgid "no reminder"
+msgstr "Lembrete do calendário"
+
+#: dist/dev/clipboardHistoryDevTool.js:116
+msgid "Generated samples"
+msgstr ""
+
+#: dist/dev/clipboardHistoryDevTool.js:118
+#, fuzzy
+msgid "Added by Aurora DevTool"
+msgstr "Aurora DevTool"
+
+#: dist/dev/clipboardHistoryDevTool.js:160
+msgid "Generated code sample"
+msgstr ""
+
+#: dist/dev/clipboardHistoryDevTool.js:174
+msgid "Generated image sample"
+msgstr ""
+
+#: dist/dev/devTool.js:28
+msgid "Extension tools and session info"
+msgstr "Ferramentas da extensão e informações da sessão"
+
+#: dist/dev/devTool.js:29
+msgid "Visibility, layout and monitor bindings"
+msgstr "Visibilidade, layout e vínculos de monitores"
+
+#: dist/dev/devTool.js:30
+msgid "Annotation overlay and OCR states"
+msgstr "Sobreposição de anotação e estados de OCR"
+
+#: dist/dev/devTool.js:31
+msgid "Populate the history panel"
+msgstr "Preenche o painel de histórico"
+
+#: dist/dev/devTool.js:32
+msgid "Fake SNI items and attention badges"
+msgstr "Itens SNI e selos de atenção fictícios"
+
+#: dist/dev/devTool.js:33
+msgid "Fake weather snapshots"
+msgstr "Instantâneos de clima fictícios"
+
+#: dist/dev/devTool.js:34
+msgid "Fake events and alerts"
+msgstr "Eventos e alertas fictícios"
+
+#: dist/dev/devTool.js:82
+msgid "Runtime updated"
+msgstr "Ambiente atualizado"
+
+#: dist/dev/devTool.js:346
+msgid "Back"
+msgstr "Voltar"
+
+#: dist/dev/devTool.js:375 dist/dev/devToolModuleSettings.js:58
+msgid "Module"
+msgstr "Módulo"
+
+#: dist/dev/devTool.js:377
+msgid "Custom Commands"
+msgstr "Comandos personalizados"
+
+#: dist/dev/devTool.js:378
+msgid "Aurora DevTool"
+msgstr "Aurora DevTool"
+
+#: dist/dev/devTool.js:382 dist/dev/devTool.js:394
+#: dist/dev/devToolModuleBrowser.js:107
+msgid "Modules"
+msgstr "Módulos"
+
+#: dist/dev/devTool.js:383 dist/dev/devTool.js:447
+msgid "Developer Tools"
+msgstr "Ferramentas de desenvolvedor"
+
+#: dist/dev/devTool.js:384
+msgid "Module Settings"
+msgstr "Configurações do módulo"
+
+#: dist/dev/devTool.js:385 dist/panel/auroraMenu.js:342
+#: dist/panel/auroraMenu.js:355 dist/panel/auroraMenu.js:377
+#: dist/panel/auroraMenu.manifest.js:8
+msgid "Aurora Menu"
+msgstr "Aurora Menu"
+
+#: dist/dev/devTool.js:387
+msgid "Developer Tool"
+msgstr "Ferramenta de desenvolvedor"
+
+#: dist/dev/devTool.js:395
+msgid "Dev Tools"
+msgstr "Ferramentas de desenvolvimento"
+
+#: dist/dev/devTool.js:448 dist/dev/devToolModuleSettings.js:95
+msgid "Simulate runtime states and trigger module actions"
+msgstr "Simula estados de execução e dispara ações de módulos"
+
+#: dist/dev/devTool.js:455
+msgid "Inspect and simulate runtime state"
+msgstr "Inspeciona e simula o estado em tempo de execução"
+
+#: dist/dev/devTool.js:480
+msgid "Live development controls"
+msgstr "Controles de desenvolvimento em tempo real"
+
+#: dist/dev/devToolModuleBrowser.js:54
+msgid "Search modules"
+msgstr "Buscar módulos"
+
+#: dist/dev/devToolModuleBrowser.js:84 dist/prefs.js:24
+msgid "Other"
+msgstr "Outros"
+
+#: dist/dev/devToolModuleBrowser.js:111
+#, javascript-format
+msgid "%d of %d modules · toggle a module or open its settings"
+msgstr "%d de %d módulos · ative um módulo ou abra suas configurações"
+
+#: dist/dev/devToolModuleBrowser.js:143
+msgid "No Results Found"
+msgstr "Nenhum resultado encontrado"
+
+#: dist/dev/devToolModuleBrowser.js:148
+#, javascript-format
+msgid "No modules match “%s”. Try a different search."
+msgstr "Nenhum módulo corresponde a “%s”. Tente uma busca diferente."
+
+#: dist/dev/devToolModuleBrowser.js:202 dist/dev/devToolModuleSettings.js:61
+#: dist/dev/devToolModuleSettings.js:73 dist/dev/devToolModuleSettings.js:234
+#, javascript-format
+msgid "Enable %s"
+msgstr "Ativar %s"
+
+#: dist/dev/devToolModuleSettings.js:63
+msgid "Module is running in this session"
+msgstr "O módulo está em execução nesta sessão"
+
+#: dist/dev/devToolModuleSettings.js:65
+#, fuzzy
+msgid "Module is enabled but not running in this session"
+msgstr "O módulo está em execução nesta sessão"
+
+#: dist/dev/devToolModuleSettings.js:66
+msgid "Module is off"
+msgstr "O módulo está desativado"
+
+#: dist/dev/devToolModuleSettings.js:80
+msgid "Settings"
+msgstr "Configurações"
+
+#: dist/dev/devToolModuleSettings.js:89
+#, javascript-format
+msgid "%s Dev Tools"
+msgstr "Ferramentas de desenvolvedor de %s"
+
+#: dist/dev/devToolModuleSettings.js:91
+msgid "Developer"
+msgstr "Desenvolvedor"
+
+#: dist/dev/devToolModuleSettings.js:112 dist/panel/auroraMenu.manifest.js:83
+msgid "Custom Menu Commands"
+msgstr "Comandos de menu personalizados"
+
+#: dist/dev/devToolModuleSettings.js:116
+msgid "Shortcuts that run commands from Aurora Menu"
+msgstr "Atalhos que executam comandos do Aurora Menu"
+
+#: dist/dev/devToolModuleSettings.js:120
+msgid "Commands"
+msgstr "Comandos"
+
+#: dist/dev/devToolModuleSettings.js:130
+msgid "Edit"
+msgstr "Editar"
+
+#: dist/dev/devToolModuleSettings.js:136
+#, javascript-format
+msgid "Edit %s"
+msgstr "Editar %s"
+
+#: dist/dev/devToolModuleSettings.js:156
+#, javascript-format
+msgid "Delete %s"
+msgstr "Excluir %s"
+
+#: dist/dev/devToolModuleSettings.js:165
+#: dist/preferences/commandListEditor.js:39
+msgid "No custom commands"
+msgstr "Nenhum comando personalizado"
+
+#: dist/dev/devToolModuleSettings.js:165
+msgid "Add one below."
+msgstr "Adicione um abaixo."
+
+#: dist/dev/devToolModuleSettings.js:175 dist/dev/devToolModuleSettings.js:462
+#: dist/preferences/commandListEditor.js:63
+#: dist/preferences/commandListEditor.js:232
+msgid "Add Command"
+msgstr "Adicionar comando"
+
+#: dist/dev/devToolModuleSettings.js:191
+msgid "Stored in"
+msgstr "Armazenados em"
+
+#: dist/dev/devToolModuleSettings.js:201
+#, javascript-format
+msgid "· module %s"
+msgstr "· módulo %s"
+
+#: dist/dev/devToolModuleSettings.js:219
+msgid "This module is disabled. Its settings are stored but have no effect."
+msgstr ""
+"Este módulo está desativado. Suas configurações ficam salvas, mas não têm "
+"efeito."
+
+#: dist/dev/devToolModuleSettings.js:228
+msgid "Enable"
+msgstr "Ativar"
+
+#: dist/dev/devToolModuleSettings.js:282
+msgid "Hours"
+msgstr "Horas"
+
+#: dist/dev/devToolModuleSettings.js:296
+msgid "Minutes"
+msgstr "Minutos"
+
+#: dist/dev/devToolModuleSettings.js:349
+msgid "Unsupported option"
+msgstr "Opção não suportada"
+
+#: dist/dev/devToolModuleSettings.js:374
+#, fuzzy, javascript-format
+msgid "Decrease %s"
+msgstr "Excluir %s"
+
+#: dist/dev/devToolModuleSettings.js:389
+#, fuzzy, javascript-format
+msgid "Increase %s"
+msgstr "Ativar %s"
+
+#: dist/dev/devToolModuleSettings.js:402
+msgid "Reset"
+msgstr "Redefinir"
+
+#: dist/dev/devToolModuleSettings.js:417 dist/dev/devToolModuleSettings.js:429
+#: dist/prefs.js:324 dist/prefs.js:359 dist/prefs.js:366 dist/prefs.js:376
+msgid "Disabled"
+msgstr "Desativado"
+
+#: dist/dev/devToolModuleSettings.js:424
+msgid "Keyboard shortcut"
+msgstr "Atalho de teclado"
+
+#: dist/dev/devToolModuleSettings.js:429
+msgid "Press shortcut…"
+msgstr "Pressione o atalho…"
+
+#: dist/dev/devToolModuleSettings.js:462
+#: dist/preferences/commandListEditor.js:149
+#: dist/preferences/commandListEditor.js:232
+msgid "Edit Command"
+msgstr "Editar comando"
+
+#: dist/dev/devToolModuleSettings.js:465 dist/dev/devToolModuleSettings.js:466
+#: dist/dev/devToolModuleSettings.js:483
+#: dist/preferences/commandListEditor.js:171
+msgid "Name"
+msgstr "Nome"
+
+#: dist/dev/devToolModuleSettings.js:473 dist/dev/devToolModuleSettings.js:474
+#: dist/dev/devToolModuleSettings.js:484
+#: dist/preferences/commandListEditor.js:176
+msgid "Command"
+msgstr "Comando"
+
+#: dist/dev/devToolModuleSettings.js:483
+msgid "Shown in the Aurora menu"
+msgstr "Exibido no menu Aurora"
+
+#: dist/dev/devToolModuleSettings.js:484
+msgid "Executed when selected"
+msgstr "Executado ao ser selecionado"
+
+#: dist/dev/devToolModuleSettings.js:487
+#: dist/preferences/commandListEditor.js:274
+msgid "Cancel"
+msgstr "Cancelar"
+
+#: dist/dev/devToolModuleSettings.js:494 dist/dev/devToolModuleSettings.js:520
+#: dist/preferences/commandListEditor.js:219
+msgid "Save"
+msgstr "Salvar"
+
+#: dist/dev/devToolModuleSettings.js:501
+msgid "Name and command are required."
+msgstr "Nome e comando são obrigatórios."
+
+#: dist/dev/devToolModuleSettings.js:502
+msgid "Name cannot contain “|”."
+msgstr "O nome não pode conter “|”."
+
+#: dist/dev/devToolModuleSettings.js:510
+msgid "Command list changed. Reopen this command before saving."
+msgstr ""
+
+#: dist/dev/devToolUi.js:122 dist/dev/devToolUi.js:127
+msgid "On"
+msgstr "Ligado"
+
+#: dist/dev/devToolUi.js:122 dist/dev/devToolUi.js:127
+msgid "Off"
+msgstr "Desligado"
+
+#: dist/dev/generalDevTool.js:30
+msgid "Environment"
+msgstr ""
+
+#: dist/dev/generalDevTool.js:31
+#, fuzzy
+msgid "GNOME Shell"
+msgstr "Usar alternativa do GNOME Shell"
+
+#: dist/dev/generalDevTool.js:33
+msgid "Session"
+msgstr ""
+
+#: dist/dev/generalDevTool.js:33 dist/panel/volumeMixer/mixerItem.js:76
+msgid "Unknown"
+msgstr "Desconhecido"
+
+#: dist/dev/generalDevTool.js:36
+#, fuzzy
+msgid "Enabled modules"
+msgstr "Ativar %s"
+
+#: dist/dev/generalDevTool.js:43
+#, fuzzy
+msgid "Open Settings"
+msgstr "Configurações"
+
+#: dist/dev/generalDevTool.js:51
+msgid "Looking Glass"
+msgstr ""
+
+#: dist/dev/trayIconsDevTool.js:77
+msgid "Fake tray preview"
+msgstr ""
+
+#: dist/dev/trayIconsDevTool.js:82
+msgid "Active"
+msgstr ""
+
+#: dist/dev/weatherClockDevTool.js:21 dist/dev/weatherClockDevTool.js:27
+#: dist/dev/weatherClockDevTool.js:126
+msgid "No fake weather"
+msgstr ""
+
+#: dist/dev/weatherClockDevTool.js:116
+msgid "Offline"
+msgstr ""
+
+#: dist/dev/weatherClockDevTool.js:120
+msgid "Weather service unavailable"
+msgstr ""
+
#: dist/dock/dock.manifest.js:8
msgid "Dock"
msgstr "Dock"
@@ -547,11 +919,6 @@ msgstr "Itens recentes"
msgid "No recent items"
msgstr "Nenhum item recente"
-#: dist/panel/auroraMenu.js:342 dist/panel/auroraMenu.js:355
-#: dist/panel/auroraMenu.js:377 dist/panel/auroraMenu.manifest.js:8
-msgid "Aurora Menu"
-msgstr "Menu Aurora"
-
#: dist/panel/auroraMenu.js:342 dist/panel/auroraMenu.js:355
msgid "Could not launch the selected command."
msgstr "Não foi possível iniciar o comando selecionado."
@@ -656,10 +1023,6 @@ msgstr "Comando de aplicativos"
msgid "Command used by the Software menu item"
msgstr "Comando usado pelo item Aplicativos do menu"
-#: dist/panel/auroraMenu.manifest.js:83
-msgid "Custom Menu Commands"
-msgstr "Comandos de menu personalizados"
-
#: dist/panel/auroraMenu.manifest.js:84
msgid "Add shortcuts that run commands from Aurora Menu"
msgstr "Adicione atalhos que executam comandos pelo Menu Aurora"
@@ -698,7 +1061,9 @@ msgstr "Lembretes do calendário"
#: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:9
msgid "Shows upcoming events next to the clock and notifies calendar reminders"
-msgstr "Mostra os próximos eventos ao lado do relógio e notifica os lembretes do calendário"
+msgstr ""
+"Mostra os próximos eventos ao lado do relógio e notifica os lembretes do "
+"calendário"
#: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:13
msgid "Notifications"
@@ -714,7 +1079,9 @@ msgstr "Forçar lembrete no início do evento"
#: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:20
msgid "Always notify when a timed event starts, even after an earlier reminder"
-msgstr "Sempre notificar quando um evento com horário começar, mesmo após um lembrete anterior"
+msgstr ""
+"Sempre notificar quando um evento com horário começar, mesmo após um "
+"lembrete anterior"
#: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:25
msgid "Snooze Duration (minutes)"
@@ -798,10 +1165,6 @@ msgstr "Mostra o avatar e o nome do usuário atual no menu de energia"
msgid "Audio"
msgstr "Áudio"
-#: dist/panel/volumeMixer/mixerItem.js:76
-msgid "Unknown"
-msgstr "Desconhecido"
-
#: dist/panel/volumeMixer/mixerPanel.js:29
msgid "No audio playing"
msgstr "Nenhum áudio em reprodução"
@@ -920,10 +1283,6 @@ msgstr "Mostra um emblema X11 em aplicativos XWayland no alternador Alt+Tab"
msgid "Manage Commands"
msgstr "Gerenciar comandos"
-#: dist/preferences/commandListEditor.js:39
-msgid "No custom commands"
-msgstr "Nenhum comando personalizado"
-
#: dist/preferences/commandListEditor.js:42
#, javascript-format
msgid "%d custom command"
@@ -935,11 +1294,6 @@ msgstr[1] "%d comandos personalizados"
msgid "Menu Commands"
msgstr "Comandos do menu"
-#: dist/preferences/commandListEditor.js:63
-#: dist/preferences/commandListEditor.js:232
-msgid "Add Command"
-msgstr "Adicionar comando"
-
#: dist/preferences/commandListEditor.js:86
msgid "No commands yet"
msgstr "Nenhum comando ainda"
@@ -956,23 +1310,10 @@ msgstr "Mover para cima"
msgid "Move Down"
msgstr "Mover para baixo"
-#: dist/preferences/commandListEditor.js:149
-#: dist/preferences/commandListEditor.js:232
-msgid "Edit Command"
-msgstr "Editar comando"
-
#: dist/preferences/commandListEditor.js:150
msgid "Remove Command"
msgstr "Remover comando"
-#: dist/preferences/commandListEditor.js:171
-msgid "Name"
-msgstr "Nome"
-
-#: dist/preferences/commandListEditor.js:176
-msgid "Command"
-msgstr "Comando"
-
#: dist/preferences/commandListEditor.js:193
msgid "The name cannot contain “|”"
msgstr "O nome não pode conter “|”"
@@ -981,10 +1322,6 @@ msgstr "O nome não pode conter “|”"
msgid "Choose the name shown in Aurora Menu and the command to run."
msgstr "Escolha o nome exibido no Menu Aurora e o comando a ser executado."
-#: dist/preferences/commandListEditor.js:219
-msgid "Save"
-msgstr "Salvar"
-
#: dist/preferences/commandListEditor.js:219
msgid "Add"
msgstr "Adicionar"
@@ -998,10 +1335,6 @@ msgstr "Remover comando?"
msgid "“%s” will be removed from Aurora Menu."
msgstr "“%s” será removido do Menu Aurora."
-#: dist/preferences/commandListEditor.js:274
-msgid "Cancel"
-msgstr "Cancelar"
-
#: dist/preferences/commandListEditor.js:275
msgid "Remove"
msgstr "Remover"
@@ -1010,10 +1343,6 @@ msgstr "Remover"
msgid "General"
msgstr "Geral"
-#: dist/prefs.js:24
-msgid "Other"
-msgstr "Outros"
-
#: dist/prefs.js:47
msgid "Aurora Shell logo"
msgstr "Logo do Aurora Shell"
@@ -1026,10 +1355,6 @@ msgstr "Acessar website"
msgid "Reset to Default"
msgstr "Restaurar padrão"
-#: dist/prefs.js:324 dist/prefs.js:359 dist/prefs.js:366 dist/prefs.js:376
-msgid "Disabled"
-msgstr "Desativado"
-
#: dist/prefs.js:354
msgid "Press a shortcut…"
msgstr "Pressione um atalho…"
@@ -1098,29 +1423,11 @@ msgstr "Alternador de Tema"
msgid "Monitors and synchronizes GNOME color scheme"
msgstr "Monitora e sincroniza o esquema de cores do GNOME"
-#~ msgid "Dismiss"
-#~ msgstr "Dispensar"
-
-#~ msgid "Ignore"
-#~ msgstr "Ignorar"
-
-#~ msgid "Restore"
-#~ msgstr "Restaurar"
-
-#~ msgid "Minimize"
-#~ msgstr "Minimizar"
-
-#~ msgid "Save Changes"
-#~ msgstr "Salvar alterações"
-
-#~ msgid "Manage"
-#~ msgstr "Gerenciar"
-
-#~ msgid "One command per line, using “Label | command”"
-#~ msgstr "Um comando por linha, usando “Rótulo | comando”"
+#~ msgid "No configurable options"
+#~ msgstr "Nenhuma opção configurável"
-#~ msgid "Clear OCR result"
-#~ msgstr "Limpar resultado do OCR"
+#~ msgid "This module works out of the box once enabled"
+#~ msgstr "Este módulo funciona de imediato após ser ativado"
-#~ msgid "Recognizing text…"
-#~ msgstr "Reconhecendo texto…"
+#~ msgid "Choose"
+#~ msgstr "Escolher"
diff --git a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml
index 5baa6b7..cacb7ba 100644
--- a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml
+++ b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml
@@ -403,11 +403,5 @@
Insert clipboard text automatically
Insert the selected clipboard text into the input that was focused before opening the panel. Warning: requires an active Wayland field that supports text-input-v3
-
-
- 1000
- Clipboard poll interval (ms)
- How often to check the clipboard for new content in milliseconds
-
diff --git a/package.json b/package.json
index 39359ee..2773bed 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"stylelint": "stylelint \"src/styles/**/*.scss\"",
"watch:css": "tsx sass.config.ts --watch",
"test:unit": "node --import tsx/esm --test \"tests/unit/**/*.test.ts\"",
- "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test \"tests/unit/**/*.test.ts\""
+ "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test \"tests/unit/**/*.test.ts\"",
+ "benchmark:runtime": "tsx tests/performance/runtimePerformance.ts"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/src/clipboard/clipboardHistory.manifest.ts b/src/clipboard/clipboardHistory.manifest.ts
index 25d1a99..2c9b049 100644
--- a/src/clipboard/clipboardHistory.manifest.ts
+++ b/src/clipboard/clipboardHistory.manifest.ts
@@ -22,13 +22,5 @@ export const manifest: ModuleManifest = {
),
type: 'switch',
},
- {
- key: 'clipboard-history-poll-interval',
- title: _('Poll Interval (ms)'),
- subtitle: _('How often to check the clipboard for changes'),
- type: 'spin',
- min: 250,
- max: 5000,
- },
],
};
diff --git a/src/clipboard/clipboardHistory.ts b/src/clipboard/clipboardHistory.ts
index f6e2bc1..ce20b68 100644
--- a/src/clipboard/clipboardHistory.ts
+++ b/src/clipboard/clipboardHistory.ts
@@ -26,7 +26,7 @@ const AUTO_PASTE_DELAY_MS = 100;
const LOG_PREFIX = 'ClipboardHistory';
// @ts-ignore - _promisify is a GJS extension not reflected in .d.ts
-Gio._promisify(Gio.File.prototype, 'load_contents_async');
+Gio._promisify(Gio.File.prototype, 'load_bytes_async');
export class ClipboardHistory extends Module {
private _store: ClipboardStore | null = null;
@@ -34,6 +34,7 @@ export class ClipboardHistory extends Module {
private _panel: ClipboardPanel | null = null;
private _lifecycle: LifecycleScope | null = null;
private _autoPasteTimeout: ManagedSource | null = null;
+ private _restoreCancellable: Gio.Cancellable | null = null;
private _pasteTargetWindow: Meta.Window | null = null;
private _pasteTargetInputFocus: Clutter.InputFocus | null = null;
@@ -51,7 +52,6 @@ export class ClipboardHistory extends Module {
const filePath = sessionDir + '/clipboard-history.log';
const mediaDir = sessionDir + '/clipboard-media';
const rawSettings = this.context.settings.getRawSettings();
- const pollMs = rawSettings.get_int('clipboard-history-poll-interval');
const store = new ClipboardStore(filePath, mediaDir);
this._store = store;
@@ -63,23 +63,30 @@ export class ClipboardHistory extends Module {
});
this._panel = panel;
- const monitor = new ClipboardMonitor(pollMs, {
+ const monitor = new ClipboardMonitor({
onText: (text) => this.addText(text),
- onImage: (payload) => void this.addImage(payload),
+ onImage: (payload) => this.addImage(payload),
});
this._monitor = monitor;
void store.load().then(() => {
- if (this._panel === panel) panel.refresh();
- });
+ if (
+ this._lifecycle !== lifecycle ||
+ this._store !== store ||
+ this._panel !== panel ||
+ this._monitor !== monitor
+ )
+ return;
- startupIdle.replace(() =>
- GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
- startupIdle.complete();
- monitor.start();
- return GLib.SOURCE_REMOVE;
- }),
- );
+ panel.refresh();
+ startupIdle.replace(() =>
+ GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
+ startupIdle.complete();
+ monitor.start();
+ return GLib.SOURCE_REMOVE;
+ }),
+ );
+ });
Main.wm.addKeybinding(
KEYBINDING_KEY,
@@ -89,16 +96,15 @@ export class ClipboardHistory extends Module {
() => this.togglePanel(),
);
lifecycle.onDispose(() => Main.wm.removeKeybinding(KEYBINDING_KEY));
-
- lifecycle.connect(rawSettings, 'changed::clipboard-history-poll-interval', () => {
- monitor.setInterval(rawSettings.get_int('clipboard-history-poll-interval'));
- });
}
override disable(): void {
this._pasteTargetWindow = null;
this._pasteTargetInputFocus = null;
+ if (this._restoreCancellable) this._restoreCancellable.cancel();
+ this._restoreCancellable = null;
+
this._lifecycle?.dispose();
this._lifecycle = null;
this._autoPasteTimeout = null;
@@ -111,6 +117,7 @@ export class ClipboardHistory extends Module {
this._monitor = null;
this._store?.save();
+ this._store?.destroy();
this._store = null;
}
@@ -196,6 +203,7 @@ export class ClipboardHistory extends Module {
? this._pasteTargetWindow
: null;
const pasteTargetInputFocus = pasteTarget ? this._pasteTargetInputFocus : null;
+ this._monitor?.suppressNextOwnerChange();
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, entry.text);
this.closePanel();
if (pasteTarget && pasteTargetInputFocus)
@@ -204,14 +212,29 @@ export class ClipboardHistory extends Module {
}
private async _restoreImage(entry: ClipboardEntry): Promise {
- if (!entry.filePath || !entry.mimeType) return;
+ if (!this._lifecycle || !entry.filePath || !entry.mimeType) return;
+
+ if (this._restoreCancellable) this._restoreCancellable.cancel();
+ const restoreCancellable = new Gio.Cancellable();
+ this._restoreCancellable = restoreCancellable;
try {
- const [contents] = await Gio.File.new_for_path(entry.filePath).load_contents_async(null);
- St.Clipboard.get_default().set_content(St.ClipboardType.CLIPBOARD, entry.mimeType, contents);
+ const [bytes] = await Gio.File.new_for_path(entry.filePath).load_bytes_async(
+ restoreCancellable,
+ );
+ if (this._restoreCancellable !== restoreCancellable || restoreCancellable.is_cancelled())
+ return;
+
+ if (!this._monitor) return;
+ this._monitor.suppressNextOwnerChange();
+ St.Clipboard.get_default().set_content(St.ClipboardType.CLIPBOARD, entry.mimeType, bytes);
logger.debug(`Restored clipboard image: ${entry.mimeType}`, { prefix: LOG_PREFIX });
} catch (e) {
+ if (restoreCancellable.is_cancelled()) return;
+
logger.warn('Failed to restore clipboard image:', { prefix: LOG_PREFIX }, e as Error);
+ } finally {
+ if (this._restoreCancellable === restoreCancellable) this._restoreCancellable = null;
}
}
diff --git a/src/clipboard/clipboardItem.ts b/src/clipboard/clipboardItem.ts
index 7e776ee..e18b5b7 100644
--- a/src/clipboard/clipboardItem.ts
+++ b/src/clipboard/clipboardItem.ts
@@ -1,13 +1,14 @@
import '@girs/gjs';
import { gettext as _ } from '~/shared/i18n.ts';
+import GLib from '@girs/glib-2.0';
import St from '@girs/st-18';
import GObject from '@girs/gobject-2.0';
import Clutter from '@girs/clutter-18';
import * as Main from '@girs/gnome-shell/ui/main';
import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu';
-import type { ClipboardEntry } from '~/clipboard/clipboardStore.ts';
+import { thumbnailPathFor, type ClipboardEntry } from '~/clipboard/clipboardStore.ts';
import { classifyClipboardCard, parseClipboardUrl } from '~/clipboard/clipboardCardState.ts';
import {
buildCodeCard,
@@ -137,8 +138,9 @@ export const ClipboardItem = GObject.registerClass(
private _buildImageCard(): void {
this.add_style_class_name('aurora-clipboard-item--image');
- if (this._entry.filePath) {
- this.style = `background-image: url("file://${this._entry.filePath}"); background-size: cover;`;
+ const thumbnail = this._entry.filePath ? thumbnailPathFor(this._entry.filePath) : null;
+ if (thumbnail && GLib.file_test(thumbnail, GLib.FileTest.EXISTS)) {
+ this.style = `background-image: url("file://${thumbnail}"); background-size: cover;`;
}
this.set_child(buildImageCard(this._entry, this._actions));
diff --git a/src/clipboard/clipboardLog.ts b/src/clipboard/clipboardLog.ts
index 076a148..7d5e069 100644
--- a/src/clipboard/clipboardLog.ts
+++ b/src/clipboard/clipboardLog.ts
@@ -34,10 +34,22 @@ type IdOp = {
type ClipboardLogOp = AddOp | IdOp;
+type ClipboardEntryNode = {
+ entry: ClipboardEntrySnapshot;
+ previous: ClipboardEntryNode | null;
+ next: ClipboardEntryNode | null;
+ list: ClipboardEntryList | null;
+};
+
+type ClipboardEntryList = {
+ first: ClipboardEntryNode | null;
+ last: ClipboardEntryNode | null;
+};
+
export function parseClipboardLog(source: string): ClipboardLogState {
- const pinned: ClipboardEntrySnapshot[] = [];
- const history: ClipboardEntrySnapshot[] = [];
- const byId = new Map();
+ const pinned: ClipboardEntryList = { first: null, last: null };
+ const history: ClipboardEntryList = { first: null, last: null };
+ const byId = new Map();
let nextId = 1;
let wastedOps = 0;
@@ -62,37 +74,42 @@ export function parseClipboardLog(source: string): ClipboardLogState {
};
if (op.mimeType) entry.mimeType = op.mimeType;
if (op.filePath) entry.filePath = op.filePath;
- byId.set(entry.id, entry);
- history.unshift(entry);
+ const node: ClipboardEntryNode = { entry, previous: null, next: null, list: null };
+ byId.set(entry.id, node);
+ moveNodeToFront(history, node);
nextId = Math.max(nextId, Number.parseInt(entry.id, 10) + 1 || nextId);
continue;
}
- const entry = byId.get(op.id);
- if (!entry) continue;
+ const node = byId.get(op.id);
+ if (!node) continue;
+
+ const entry = node.entry;
if (op.op === 'delete') {
- removeClipboardEntry(pinned, entry);
- removeClipboardEntry(history, entry);
+ removeNode(node);
byId.delete(op.id);
wastedOps += 2;
} else if (op.op === 'move') {
const list = entry.pinned ? pinned : history;
- moveToFront(list, entry);
+ moveNodeToFront(list, node);
wastedOps += 1;
} else if (op.op === 'pin') {
- removeClipboardEntry(history, entry);
entry.pinned = true;
- moveToFront(pinned, entry);
+ moveNodeToFront(pinned, node);
} else if (op.op === 'unpin') {
- removeClipboardEntry(pinned, entry);
entry.pinned = false;
- moveToFront(history, entry);
+ moveNodeToFront(history, node);
wastedOps += 2;
}
}
- return { pinned, history, nextId, wastedOps };
+ return {
+ pinned: materializeClipboardEntries(pinned),
+ history: materializeClipboardEntries(history),
+ nextId,
+ wastedOps,
+ };
}
export function encodeAddOp(entry: ClipboardEntrySnapshot): string {
@@ -145,7 +162,37 @@ export function removeClipboardEntry(
if (index !== -1) list.splice(index, 1);
}
-function moveToFront(list: ClipboardEntrySnapshot[], entry: ClipboardEntrySnapshot): void {
- removeClipboardEntry(list, entry);
- list.unshift(entry);
+function removeNode(node: ClipboardEntryNode): void {
+ if (!node.list) return;
+
+ const list = node.list;
+ if (node.previous) node.previous.next = node.next;
+ else list.first = node.next;
+
+ if (node.next) node.next.previous = node.previous;
+ else list.last = node.previous;
+
+ node.previous = null;
+ node.next = null;
+ node.list = null;
+}
+
+function moveNodeToFront(list: ClipboardEntryList, node: ClipboardEntryNode): void {
+ removeNode(node);
+
+ node.next = list.first;
+ node.list = list;
+ if (list.first) list.first.previous = node;
+ else list.last = node;
+ list.first = node;
+}
+
+function materializeClipboardEntries(list: ClipboardEntryList): ClipboardEntrySnapshot[] {
+ const entries: ClipboardEntrySnapshot[] = [];
+ let node = list.first;
+ while (node) {
+ entries.push(node.entry);
+ node = node.next;
+ }
+ return entries;
}
diff --git a/src/clipboard/clipboardMonitor.ts b/src/clipboard/clipboardMonitor.ts
index f563eea..724775c 100644
--- a/src/clipboard/clipboardMonitor.ts
+++ b/src/clipboard/clipboardMonitor.ts
@@ -1,10 +1,14 @@
+import Gio from '@girs/gio-2.0';
import GLib from '@girs/glib-2.0';
+import Meta from '@girs/meta-18';
import St from '@girs/st-18';
import type { ClipboardImagePayload } from '~/clipboard/clipboardStore.ts';
-import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts';
-import { createManagedSource } from '~/core/mainLoop.ts';
+import { LifecycleScope } from '~/core/lifecycleScope.ts';
+import { logger } from '~/core/logger.ts';
+import { createManagedTimeout } from '~/core/mainLoop.ts';
+const LOG_PREFIX = 'ClipboardMonitor';
const IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
@@ -14,107 +18,177 @@ const IMAGE_MIME_TYPES = [
'image/bmp',
'image/tiff',
];
+const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
+const READ_DELAY_MS = 50;
+
+type ClipboardMonitorCallbacks = {
+ onText: (text: string) => void;
+ onImage: (payload: ClipboardImagePayload) => Promise;
+};
export class ClipboardMonitor {
- private _intervalMs: number;
- private _onText: (text: string) => void;
- private _onImage: (payload: ClipboardImagePayload) => void;
+ private _callbacks: ClipboardMonitorCallbacks | null;
private _lifecycle = new LifecycleScope();
- private _pollSource: ManagedSource = createManagedSource(this._lifecycle);
+ private _readTimeout = createManagedTimeout(this._lifecycle);
+ private _cancellable = new Gio.Cancellable();
private _lastContentKey: string | null = null;
+ private _started = false;
+ private _suppressNextOwnerChange = false;
+ private _readInProgress = false;
+ private _readPending = false;
- constructor(
- intervalMs: number,
- callbacks: {
- onText: (text: string) => void;
- onImage: (payload: ClipboardImagePayload) => void;
- },
- ) {
- this._intervalMs = intervalMs;
- this._onText = callbacks.onText;
- this._onImage = callbacks.onImage;
+ constructor(callbacks: ClipboardMonitorCallbacks) {
+ this._callbacks = callbacks;
}
start(): void {
- if (this._pollSource.active) return;
- this._pollSource.replace(() =>
- GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._intervalMs, () => {
- this._tick();
- return GLib.SOURCE_CONTINUE;
- }),
+ if (this._started || !this._callbacks) return;
+ this._started = true;
+
+ this._lifecycle.connect(
+ global.display.get_selection(),
+ 'owner-changed',
+ (_selection, selectionType) => {
+ if (selectionType !== Meta.SelectionType.SELECTION_CLIPBOARD) return;
+ if (this._suppressNextOwnerChange) {
+ this._suppressNextOwnerChange = false;
+ return;
+ }
+ // owner-changed is emitted synchronously from inside set_content (e.g. while the
+ // screenshot UI still holds its grab), and reading a shell-owned source copies the
+ // whole payload in mutter. Defer, and coalesce bursts of owner changes.
+ this._readTimeout.schedule(READ_DELAY_MS, () => this._requestRead());
+ },
);
+ this._requestRead();
}
- stop(): void {
- this._pollSource.clear();
- }
-
- setInterval(ms: number): void {
- this._intervalMs = ms;
- if (this._pollSource.active) {
- this.stop();
- this.start();
- }
+ // Call right before setting the clipboard ourselves: the owner-changed it emits is
+ // synchronous, so the flag is consumed by exactly that change.
+ suppressNextOwnerChange(): void {
+ this._readTimeout.clear();
+ this._readPending = false;
+ this._suppressNextOwnerChange = true;
}
destroy(): void {
+ this._callbacks = null;
+ this._cancellable.cancel();
this._lifecycle.dispose();
}
- private _tick(): void {
- const clipboard = St.Clipboard.get_default();
- const imageMimeType = this._findImageMimeType(
- clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD),
- );
-
- if (imageMimeType) {
- clipboard.get_content(St.ClipboardType.CLIPBOARD, imageMimeType, (_clipboard, bytes) => {
- if (!bytes || bytes.get_size() === 0) return;
+ private _requestRead(): void {
+ if (!this._callbacks) return;
+ if (this._readInProgress) {
+ this._readPending = true;
+ return;
+ }
- const fingerprint = fingerprintBytes(bytes);
- const contentKey = 'image:' + imageMimeType + ':' + fingerprint;
- if (contentKey === this._lastContentKey) return;
+ this._readInProgress = true;
+ this._readClipboard();
+ }
- this._lastContentKey = contentKey;
- this._onImage({ mimeType: imageMimeType, bytes, fingerprint });
- });
+ private _readClipboard(): void {
+ const clipboard = St.Clipboard.get_default();
+ const imageMimeType = findImageMimeType(clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD));
+ if (imageMimeType) {
+ this._readImage(imageMimeType);
return;
}
clipboard.get_text(
St.ClipboardType.CLIPBOARD,
(_clipboard: St.Clipboard, text: string | null) => {
- if (!text || text.trim().length === 0) return;
+ try {
+ if (!this._callbacks || !text || text.trim().length === 0) return;
+
+ const contentKey = 'text:' + text;
+ if (contentKey === this._lastContentKey) return;
- const contentKey = 'text:' + text;
- if (contentKey === this._lastContentKey) return;
+ this._lastContentKey = contentKey;
+ this._callbacks.onText(text);
+ } finally {
+ this._finishRead();
+ }
+ },
+ );
+ }
- this._lastContentKey = contentKey;
- this._onText(text);
+ // Not St.Clipboard.get_content(): its callback receives a transfer-none GBytes that GJS
+ // wraps without taking a reference (Boxed::NoCopy) and St unrefs right after the callback.
+ // When the wrapper is later finalized, GJS removes g_bytes_get_size() of the freed GBytes
+ // from SpiderMonkey's malloc accounting; reading reused memory underflows the counter to
+ // ~2^64 and every later JS allocation then triggers a full GC, crippling the shell for the
+ // rest of the session. Transferring into our own stream yields an owned GBytes instead.
+ private _readImage(mimeType: string): void {
+ const selection = global.display.get_selection();
+ const stream = Gio.MemoryOutputStream.new_resizable();
+ selection.transfer_async(
+ Meta.SelectionType.SELECTION_CLIPBOARD,
+ mimeType,
+ MAX_IMAGE_BYTES + 1,
+ stream,
+ this._cancellable,
+ (_selection, result) => {
+ try {
+ selection.transfer_finish(result);
+ } catch {
+ // Cancelled on destroy, or the owner vanished mid-transfer; the next owner-changed
+ // reads again.
+ this._finishRead();
+ return;
+ }
+
+ let persistenceStarted = false;
+ try {
+ if (!stream.is_closed()) stream.close(null);
+ const bytes = stream.steal_as_bytes();
+ const size = bytes.get_size();
+ if (!this._callbacks || size === 0 || size > MAX_IMAGE_BYTES) return;
+
+ const fingerprint = fingerprintBytes(bytes);
+ const contentKey = 'image:' + mimeType + ':' + fingerprint;
+ if (contentKey === this._lastContentKey) return;
+
+ this._lastContentKey = contentKey;
+ persistenceStarted = true;
+ void this._persistImage(this._callbacks, { mimeType, bytes, fingerprint });
+ } finally {
+ if (!persistenceStarted) this._finishRead();
+ }
},
);
}
- private _findImageMimeType(mimeTypes: string[]): string | null {
- for (const preferred of IMAGE_MIME_TYPES) {
- if (mimeTypes.includes(preferred)) return preferred;
+ private async _persistImage(
+ callbacks: ClipboardMonitorCallbacks,
+ payload: ClipboardImagePayload,
+ ): Promise {
+ try {
+ await callbacks.onImage(payload);
+ } catch (e) {
+ logger.error('Unexpected clipboard image persistence failure:', { prefix: LOG_PREFIX }, e);
+ } finally {
+ this._finishRead();
}
+ }
- const imageMimeType = mimeTypes.find((mimeType) => mimeType.startsWith('image/'));
- if (!imageMimeType) return null;
+ private _finishRead(): void {
+ this._readInProgress = false;
+ if (!this._readPending || !this._callbacks) return;
- return imageMimeType;
+ this._readPending = false;
+ this._requestRead();
}
}
-export function fingerprintBytes(bytes: GLib.Bytes): string {
- const data = bytes.toArray();
- let hash = 2166136261;
+function findImageMimeType(mimeTypes: string[]): string | null {
+ const preferred = IMAGE_MIME_TYPES.find((mimeType) => mimeTypes.includes(mimeType));
+ if (preferred) return preferred;
- for (let i = 0; i < data.length; i++) {
- hash ^= data[i]!;
- hash = Math.imul(hash, 16777619);
- }
+ return mimeTypes.find((mimeType) => mimeType.startsWith('image/')) || null;
+}
- return data.length + '-' + (hash >>> 0).toString(16);
+export function fingerprintBytes(bytes: GLib.Bytes): string {
+ return `${bytes.get_size()}-${GLib.compute_checksum_for_bytes(GLib.ChecksumType.SHA256, bytes)}`;
}
diff --git a/src/clipboard/clipboardStore.ts b/src/clipboard/clipboardStore.ts
index d1d85f1..dda162f 100644
--- a/src/clipboard/clipboardStore.ts
+++ b/src/clipboard/clipboardStore.ts
@@ -24,7 +24,7 @@ Gio._promisify(Gio.File.prototype, 'append_to_async', 'append_to_finish');
// @ts-ignore - _promisify is a GJS extension not reflected in .d.ts
Gio._promisify(Gio.File.prototype, 'replace_contents_async', 'replace_contents_finish');
// @ts-ignore - _promisify is a GJS extension not reflected in .d.ts
-Gio._promisify(Gio.OutputStream.prototype, 'write_bytes_async', 'write_bytes_finish');
+Gio._promisify(Gio.OutputStream.prototype, 'write_all_async', 'write_all_finish');
// @ts-ignore - _promisify is a GJS extension not reflected in .d.ts
Gio._promisify(Gio.OutputStream.prototype, 'flush_async', 'flush_finish');
// @ts-ignore - _promisify is a GJS extension not reflected in .d.ts
@@ -33,9 +33,29 @@ Gio._promisify(Gio.OutputStream.prototype, 'close_async', 'close_finish');
const LOG_PREFIX = 'ClipboardHistory';
const WRITE_PRIORITY = GLib.PRIORITY_DEFAULT_IDLE;
const MAX_WASTED_OPS = 500;
+const THUMBNAIL_SIZE = 640;
export type ClipboardEntry = ClipboardEntrySnapshot;
+export function thumbnailPathFor(filePath: string): string {
+ return filePath.replace(/\.[^./]+$/, '') + '.thumb.png';
+}
+
+function callAsync(
+ start: (callback: (_source: unknown, result: Gio.AsyncResult) => void) => void,
+ finish: (result: Gio.AsyncResult) => T,
+): Promise {
+ return new Promise((resolve, reject) => {
+ start((_source, result) => {
+ try {
+ resolve(finish(result));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ });
+}
+
export type ClipboardImagePayload = {
mimeType: string;
bytes: GLib.Bytes;
@@ -55,6 +75,8 @@ export class ClipboardStore {
private _writing: boolean = false;
private _compactionRequested: boolean = false;
private _writeRequestVersion = 0;
+ private _clearVersion = 0;
+ private _imageCancellable = new Gio.Cancellable();
constructor(filePath: string, mediaDir: string) {
this._filePath = filePath;
@@ -62,15 +84,17 @@ export class ClipboardStore {
}
async load(): Promise {
+ const clearVersion = this._clearVersion;
try {
const file = Gio.File.new_for_path(this._filePath);
const [contents] = await file.load_contents_async(null);
const decoded = new TextDecoder().decode(contents);
const state = parseClipboardLog(decoded);
- const { pinned, history, removed } = this._dropInvalidImageEntries(
+ const { pinned, history, removed } = await this._dropInvalidImageEntries(
state.pinned,
state.history,
);
+ if (this._clearVersion !== clearVersion) return;
this._pinned = pinned;
this._history = history;
@@ -83,6 +107,8 @@ export class ClipboardStore {
}
this._requestCompactionIfNeeded();
} catch (_e) {
+ if (this._clearVersion !== clearVersion) return;
+
this._pinned = [];
this._history = [];
this._nextId = 1;
@@ -95,6 +121,11 @@ export class ClipboardStore {
this._requestCompactionIfNeeded();
}
+ destroy(): void {
+ this._clearVersion++;
+ this._imageCancellable.cancel();
+ }
+
addText(text: string): boolean {
const cleanText = text.trim();
if (!cleanText) return false;
@@ -127,17 +158,6 @@ export class ClipboardStore {
async addImage(payload: ClipboardImagePayload): Promise {
if (payload.bytes.get_size() === 0) return false;
- try {
- this._validateImageBytes(payload.mimeType, payload.bytes);
- } catch (e) {
- logger.warn(
- `Rejected invalid clipboard image: ${payload.mimeType}, ${payload.bytes.get_size()} bytes`,
- { prefix: LOG_PREFIX },
- e as Error,
- );
- return false;
- }
-
const contentKey = 'image:' + payload.mimeType + ':' + payload.fingerprint;
const existing = this._byContentKey.get(contentKey);
if (existing) {
@@ -149,8 +169,62 @@ export class ClipboardStore {
}
const id = String(this._nextId++);
+ const clearVersion = this._clearVersion;
const filePath = this._mediaDir + '/' + id + this._extensionForMimeType(payload.mimeType);
- await this._writeImage(filePath, payload.bytes);
+ const temporaryPath = `${filePath}.${GLib.uuid_string_random()}.tmp`;
+ let hasThumbnail = false;
+ try {
+ await this._writeImage(temporaryPath, payload.bytes);
+ await this._validateImageFileAsync(temporaryPath, payload.mimeType);
+ const pixbuf = await this._decodeThumbnail(temporaryPath);
+
+ try {
+ await this._writeThumbnail(temporaryPath, pixbuf);
+ hasThumbnail = true;
+ } catch (e) {
+ if (this._imageCancellable.is_cancelled()) {
+ this._deleteMediaPath(temporaryPath);
+ return false;
+ }
+
+ logger.warn(
+ 'Failed to create clipboard image thumbnail:',
+ { prefix: LOG_PREFIX },
+ e as Error,
+ );
+ }
+
+ if (this._imageCancellable.is_cancelled() || this._clearVersion !== clearVersion) {
+ this._deleteMediaPath(temporaryPath);
+ return false;
+ }
+
+ if (hasThumbnail) {
+ Gio.File.new_for_path(thumbnailPathFor(temporaryPath)).move(
+ Gio.File.new_for_path(thumbnailPathFor(filePath)),
+ Gio.FileCopyFlags.OVERWRITE,
+ this._imageCancellable,
+ null,
+ );
+ }
+ Gio.File.new_for_path(temporaryPath).move(
+ Gio.File.new_for_path(filePath),
+ Gio.FileCopyFlags.OVERWRITE,
+ this._imageCancellable,
+ null,
+ );
+ } catch (e) {
+ this._deleteMediaPath(temporaryPath);
+ if (hasThumbnail) this._deleteMediaPath(filePath);
+ if (this._imageCancellable.is_cancelled()) return false;
+
+ logger.warn(
+ `Rejected clipboard image: ${payload.mimeType}, ${payload.bytes.get_size()} bytes`,
+ { prefix: LOG_PREFIX },
+ e as Error,
+ );
+ return false;
+ }
const entry: ClipboardEntry = {
id,
@@ -206,8 +280,14 @@ export class ClipboardStore {
}
clear(): boolean {
+ this._clearVersion++;
const entries = [...this._pinned, ...this._history];
- if (entries.length === 0) return false;
+ if (entries.length === 0) {
+ this._compactionRequested = true;
+ this._writeRequestVersion++;
+ void this._drainWrites();
+ return false;
+ }
for (const entry of entries) this._deleteMediaFile(entry);
@@ -260,34 +340,50 @@ export class ClipboardStore {
}
}
- private _dropInvalidImageEntries(
+ private async _dropInvalidImageEntries(
pinned: ClipboardEntry[],
history: ClipboardEntry[],
- ): { pinned: ClipboardEntry[]; history: ClipboardEntry[]; removed: number } {
- let removed = 0;
-
- const keepValid = (entry: ClipboardEntry): boolean => {
- if (entry.kind !== 'image') return true;
+ ): Promise<{ pinned: ClipboardEntry[]; history: ClipboardEntry[]; removed: number }> {
+ const invalid = new Set();
+ const images = [...pinned, ...history].filter((entry) => entry.kind === 'image');
+ for (const entry of images) {
+ let pixbuf: GdkPixbuf.Pixbuf;
try {
- this._validateImageFile(entry);
- return true;
+ if (!entry.filePath || !entry.mimeType) throw new Error('Image entry has no file');
+ await this._validateImageFileAsync(entry.filePath, entry.mimeType);
+ pixbuf = await this._decodeThumbnail(entry.filePath);
} catch (e) {
- removed++;
+ if (this._imageCancellable.is_cancelled()) break;
+
+ invalid.add(entry);
logger.warn(
`Dropped invalid clipboard image from history: id=${entry.id}, path=${entry.filePath || '(none)'}`,
{ prefix: LOG_PREFIX },
e as Error,
);
this._deleteMediaFile(entry);
- return false;
+ continue;
}
- };
+ try {
+ await this._writeThumbnail(entry.filePath, pixbuf);
+ } catch (e) {
+ if (this._imageCancellable.is_cancelled()) break;
+
+ logger.warn(
+ `Failed to refresh clipboard image thumbnail: id=${entry.id}, path=${entry.filePath}`,
+ { prefix: LOG_PREFIX },
+ e as Error,
+ );
+ }
+ }
+
+ const keepValid = (entry: ClipboardEntry): boolean => !invalid.has(entry);
return {
pinned: pinned.filter(keepValid),
history: history.filter(keepValid),
- removed,
+ removed: invalid.size,
};
}
@@ -347,7 +443,7 @@ export class ClipboardStore {
try {
const bytes = new TextEncoder().encode(data);
- await stream.write_bytes_async(bytes, WRITE_PRIORITY, null);
+ await stream.write_all_async(bytes, WRITE_PRIORITY, null);
await stream.flush_async(WRITE_PRIORITY, null);
} finally {
await stream.close_async(WRITE_PRIORITY, null);
@@ -383,23 +479,82 @@ export class ClipboardStore {
private async _writeImage(filePath: string, bytes: GLib.Bytes): Promise {
this._ensureMediaDirectory();
- await Gio.File.new_for_path(filePath).replace_contents_async(
- bytes.toArray(),
- null,
- false,
- Gio.FileCreateFlags.PRIVATE,
- null,
+ const file = Gio.File.new_for_path(filePath);
+ await callAsync(
+ (callback) =>
+ file.replace_contents_bytes_async(
+ bytes,
+ null,
+ false,
+ Gio.FileCreateFlags.PRIVATE,
+ this._imageCancellable,
+ callback,
+ ),
+ (result) => file.replace_contents_finish(result),
+ );
+ }
+
+ private async _decodeThumbnail(filePath: string): Promise {
+ const source = Gio.File.new_for_path(filePath);
+ const input = await callAsync(
+ (callback) => source.read_async(GLib.PRIORITY_DEFAULT, this._imageCancellable, callback),
+ (result) => source.read_finish(result),
+ );
+ return callAsync(
+ (callback) =>
+ GdkPixbuf.Pixbuf.new_from_stream_at_scale_async(
+ input,
+ THUMBNAIL_SIZE,
+ THUMBNAIL_SIZE,
+ true,
+ this._imageCancellable,
+ callback,
+ ),
+ (result) => GdkPixbuf.Pixbuf.new_from_stream_finish(result),
+ ).finally(() => input.close(null));
+ }
+
+ // PNG encoding runs on a GdkPixbuf worker thread.
+ private async _writeThumbnail(filePath: string, pixbuf: GdkPixbuf.Pixbuf): Promise {
+ const target = Gio.File.new_for_path(thumbnailPathFor(filePath));
+ const output = await callAsync(
+ (callback) =>
+ target.replace_async(
+ null,
+ false,
+ Gio.FileCreateFlags.PRIVATE,
+ GLib.PRIORITY_DEFAULT,
+ this._imageCancellable,
+ callback,
+ ),
+ (result) => target.replace_finish(result),
);
+ try {
+ await callAsync(
+ (callback) =>
+ pixbuf.save_to_streamv_async(output, 'png', null, null, this._imageCancellable, callback),
+ (result) => GdkPixbuf.Pixbuf.save_to_stream_finish(result),
+ );
+ } finally {
+ await output.close_async(GLib.PRIORITY_DEFAULT, this._imageCancellable);
+ }
}
private _deleteMediaFile(entry: ClipboardEntry): void {
if (entry.kind !== 'image' || !entry.filePath) return;
+ this._deleteMediaPath(entry.filePath);
+ }
- try {
- const file = Gio.File.new_for_path(entry.filePath);
- if (file.query_exists(null)) file.delete(null);
- } catch (_e) {
- // Runtime files are session-scoped; deletion here is best effort.
+ private _deleteMediaPath(filePath: string): void {
+ for (const path of [filePath, thumbnailPathFor(filePath)]) {
+ try {
+ Gio.File.new_for_path(path).delete(null);
+ } catch (e) {
+ if (e instanceof GLib.Error && e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
+ continue;
+
+ logger.warn(`Failed to delete clipboard media: ${path}`, { prefix: LOG_PREFIX }, e);
+ }
}
}
@@ -412,29 +567,19 @@ export class ClipboardStore {
return '.png';
}
- private _validateImageBytes(mimeType: string, bytes: GLib.Bytes): void {
- const loader = GdkPixbuf.PixbufLoader.new_with_mime_type(mimeType);
-
- try {
- loader.write_bytes(bytes);
- } finally {
- loader.close();
- }
-
- if (!loader.get_pixbuf() && !loader.get_animation()) {
- throw new Error('Image decoder did not produce a pixbuf or animation');
+ // Reads only the image header, on a GdkPixbuf worker thread.
+ private async _validateImageFileAsync(filePath: string, mimeType: string): Promise {
+ const [format] = await callAsync(
+ (callback) =>
+ GdkPixbuf.Pixbuf.get_file_info_async(filePath, this._imageCancellable, callback),
+ (result) => GdkPixbuf.Pixbuf.get_file_info_finish(result),
+ );
+ const expectedMimeType = mimeType === 'image/jpg' ? 'image/jpeg' : mimeType;
+ if (!format?.get_mime_types()?.includes(expectedMimeType)) {
+ throw new Error(`Image data does not match ${mimeType}`);
}
}
- private _validateImageFile(entry: ClipboardEntry): void {
- if (!entry.filePath) throw new Error('Image entry has no file path');
-
- const file = Gio.File.new_for_path(entry.filePath);
- if (!file.query_exists(null)) throw new Error('Image file is missing');
-
- GdkPixbuf.Pixbuf.new_from_file(entry.filePath);
- }
-
private _searchText(entry: ClipboardEntry): string {
if (entry.kind === 'image') return 'image imagem picture photo foto';
return entry.text.toLowerCase();
diff --git a/src/core/logger.ts b/src/core/logger.ts
index 4a5cc77..971408f 100644
--- a/src/core/logger.ts
+++ b/src/core/logger.ts
@@ -5,6 +5,15 @@ type LogOptions = {
prefix?: string;
};
+let _cachedExtension: { metadata: { name: string }; uuid: string } | null = null;
+
+function getExtension(): { metadata: { name: string }; uuid: string } {
+ if (!_cachedExtension) {
+ _cachedExtension = Extension.lookupByURL(import.meta.url)!;
+ }
+ return _cachedExtension;
+}
+
function write(
level: GLib.LogLevelFlags,
msg: string,
@@ -13,7 +22,7 @@ function write(
): void {
const prefix = options.prefix ? `[${options.prefix}] ` : '';
const suffix = args.length ? ` ${args.map(String).join(' ')}` : '';
- const extension = Extension.lookupByURL(import.meta.url)!;
+ const extension = getExtension();
GLib.log_structured(extension.metadata.name, level, {
SYSLOG_IDENTIFIER: extension.uuid,
MESSAGE: `${prefix}${msg}${suffix}`,
@@ -23,10 +32,14 @@ function write(
export const logger = {
log: (msg: string, options: LogOptions = {}, ...args: unknown[]) =>
write(GLib.LogLevelFlags.LEVEL_MESSAGE, msg, options, args),
- debug: (msg: string, options: LogOptions = {}, ...args: unknown[]) =>
- write(GLib.LogLevelFlags.LEVEL_DEBUG, msg, options, args),
+ debug: (_msg: string, _options?: LogOptions, ..._args: unknown[]) => {},
warn: (msg: string, options: LogOptions = {}, ...args: unknown[]) =>
write(GLib.LogLevelFlags.LEVEL_WARNING, msg, options, args),
error: (msg: string, options: LogOptions = {}, ...args: unknown[]) =>
write(GLib.LogLevelFlags.LEVEL_CRITICAL, msg, options, args),
};
+
+export function enableDebugLogging(): void {
+ logger.debug = (msg: string, options: LogOptions = {}, ...args: unknown[]) =>
+ write(GLib.LogLevelFlags.LEVEL_DEBUG, msg, options, args);
+}
diff --git a/src/desktop/trayIcons/dbusMenu.ts b/src/desktop/trayIcons/dbusMenu.ts
index 75366bd..8f656bc 100644
--- a/src/desktop/trayIcons/dbusMenu.ts
+++ b/src/desktop/trayIcons/dbusMenu.ts
@@ -92,11 +92,19 @@ export class DBusMenuClient {
'AboutToShow',
new GLib.Variant('(i)', [0]),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
null,
);
- } catch {
- // Not all apps implement AboutToShow; ignore errors silently
+ } catch (e) {
+ const remoteError = e instanceof GLib.Error ? Gio.DBusError.get_remote_error(e) : null;
+ if (
+ !this._cancellable.is_cancelled() &&
+ remoteError !== 'org.freedesktop.DBus.Error.UnknownMethod'
+ ) {
+ logger.warn(`DBusMenu AboutToShow failed for ${this._busName}: ${e}`, {
+ prefix: LOG_PREFIX,
+ });
+ }
}
try {
@@ -104,7 +112,7 @@ export class DBusMenuClient {
'GetLayout',
new GLib.Variant('(iias)', [0, -1, []]),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
null,
);
@@ -211,7 +219,7 @@ export class DBusMenuClient {
'Event',
new GLib.Variant('(isvu)', [id, 'clicked', new GLib.Variant('i', 0), timestamp]),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
null,
(p, res) => {
try {
diff --git a/src/desktop/trayIcons/sniHost.ts b/src/desktop/trayIcons/sniHost.ts
index 9c9532f..869ac0c 100644
--- a/src/desktop/trayIcons/sniHost.ts
+++ b/src/desktop/trayIcons/sniHost.ts
@@ -178,12 +178,20 @@ export class SniHost {
'org.freedesktop.DBus.Properties.Get',
new GLib.Variant('(ss)', ['org.kde.StatusNotifierItem', prop]),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
cancellable,
);
proxy.set_cached_property(prop, result.get_child_value(0).get_variant());
- } catch {
- // property may not be supported by this item
+ } catch (e) {
+ const remoteError = e instanceof GLib.Error ? Gio.DBusError.get_remote_error(e) : null;
+ if (
+ !cancellable.is_cancelled() &&
+ remoteError !== 'org.freedesktop.DBus.Error.UnknownProperty'
+ ) {
+ logger.warn(`Failed to fetch SNI property ${prop} from ${proxy.g_name}: ${e}`, {
+ prefix: LOG_PREFIX,
+ });
+ }
}
}),
);
diff --git a/src/desktop/trayIcons/trayClipArea.ts b/src/desktop/trayIcons/trayClipArea.ts
index f64dba6..55252f4 100644
--- a/src/desktop/trayIcons/trayClipArea.ts
+++ b/src/desktop/trayIcons/trayClipArea.ts
@@ -1,26 +1,41 @@
import '@girs/gjs';
import Clutter from '@girs/clutter-18';
-import GLib from '@girs/glib-2.0';
import GObject from '@girs/gobject-2.0';
-import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts';
-import { createManagedSource } from '~/core/mainLoop.ts';
+const VIEWPORT_TRANSITION = 'viewport-progress';
export const TrayClipArea = GObject.registerClass(
class TrayClipArea extends Clutter.Actor {
+ static [GObject.properties]: Record = {
+ [VIEWPORT_TRANSITION]: GObject.ParamSpec.double(
+ VIEWPORT_TRANSITION,
+ VIEWPORT_TRANSITION,
+ 'Viewport animation progress',
+ GObject.ParamFlags.READWRITE,
+ 0,
+ 1,
+ 0,
+ ),
+ };
+
public fullWidth = 0;
public reservedWidth = 0;
private _childOffsetX = 0;
private _viewportWidth = 0;
private _clipStart = 0;
- declare private _lifecycle: LifecycleScope;
- declare private _viewportTimeout: ManagedSource;
+ private _viewportProgress = 0;
+
+ get viewport_progress(): number {
+ return this._viewportProgress;
+ }
+
+ set viewport_progress(progress: number) {
+ this._viewportProgress = progress;
+ }
override _init(params = {}) {
super._init({ clip_to_allocation: false, x_expand: false, y_expand: true, ...params });
- this._lifecycle = new LifecycleScope();
- this._viewportTimeout = createManagedSource(this._lifecycle);
}
override vfunc_allocate(box: Clutter.ActorBox): void {
@@ -58,32 +73,43 @@ export const TrayClipArea = GObject.registerClass(
onFrame: (viewportWidth: number, clipStart: number) => void,
onComplete: () => void,
): void {
- const startUs = GLib.get_monotonic_time();
- const durationUs = durationMs * 1000;
-
- this._viewportTimeout.replace(() =>
- GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => {
- const progress = Math.min(1, (GLib.get_monotonic_time() - startUs) / durationUs);
- const eased = 1 - Math.pow(1 - progress, 3);
- this._viewportWidth = fromViewportWidth + (toViewportWidth - fromViewportWidth) * eased;
- this._clipStart = fromClipStart + (toClipStart - fromClipStart) * eased;
- this._syncClip();
- onFrame(this._viewportWidth, this._clipStart);
- if (progress < 1) return GLib.SOURCE_CONTINUE;
+ this.cancelViewportAnimation();
+ this._viewportProgress = 0;
+
+ if (durationMs <= 0 || !this.mapped) {
+ this._viewportWidth = toViewportWidth;
+ this._clipStart = toClipStart;
+ this._syncClip();
+ onFrame(toViewportWidth, toClipStart);
+ onComplete();
+ return;
+ }
- this._viewportTimeout.complete();
+ this.ease_property(VIEWPORT_TRANSITION, 1, {
+ duration: durationMs,
+ mode: Clutter.AnimationMode.EASE_OUT_CUBIC,
+ onComplete: () => {
this._viewportWidth = toViewportWidth;
this._clipStart = toClipStart;
this._syncClip();
onFrame(toViewportWidth, toClipStart);
onComplete();
- return GLib.SOURCE_REMOVE;
- }),
- );
+ },
+ });
+ const transition = this.get_transition(VIEWPORT_TRANSITION);
+ if (!transition) return;
+
+ transition.connect('new-frame', () => {
+ this._viewportWidth =
+ fromViewportWidth + (toViewportWidth - fromViewportWidth) * this._viewportProgress;
+ this._clipStart = fromClipStart + (toClipStart - fromClipStart) * this._viewportProgress;
+ this._syncClip();
+ onFrame(this._viewportWidth, this._clipStart);
+ });
}
cancelViewportAnimation(): void {
- this._viewportTimeout.clear();
+ this.remove_transition(VIEWPORT_TRANSITION);
}
get viewportWidth(): number {
@@ -110,7 +136,7 @@ export const TrayClipArea = GObject.registerClass(
}
override destroy(): void {
- this._lifecycle.dispose();
+ this.cancelViewportAnimation();
super.destroy();
}
diff --git a/src/desktop/trayIcons/trayContainer.ts b/src/desktop/trayIcons/trayContainer.ts
index 1f5894c..a4808d1 100644
--- a/src/desktop/trayIcons/trayContainer.ts
+++ b/src/desktop/trayIcons/trayContainer.ts
@@ -556,7 +556,6 @@ export const TrayContainer = GObject.registerClass(
}
override destroy(): void {
- this._clipArea.cancelViewportAnimation();
this._lifecycle.dispose();
destroyTooltip();
for (const widget of this._items.values()) widget.destroy();
diff --git a/src/desktop/trayIcons/trayIcons.ts b/src/desktop/trayIcons/trayIcons.ts
index f02d455..648ff2a 100644
--- a/src/desktop/trayIcons/trayIcons.ts
+++ b/src/desktop/trayIcons/trayIcons.ts
@@ -211,7 +211,7 @@ export class TrayIcons extends Module {
GLib.Variant.new('(s)', [busName]),
new GLib.VariantType('(s)'),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
null,
);
return res.get_child_value(0).unpack() as string;
@@ -230,7 +230,7 @@ export class TrayIcons extends Module {
GLib.Variant.new('(s)', [busName]),
new GLib.VariantType('(u)'),
Gio.DBusCallFlags.NONE,
- -1,
+ 5000,
null,
);
return res.get_child_value(0).unpack() as number;
diff --git a/src/dev/calendarRemindersDevTool.ts b/src/dev/calendarRemindersDevTool.ts
index a133dac..2f70bc0 100644
--- a/src/dev/calendarRemindersDevTool.ts
+++ b/src/dev/calendarRemindersDevTool.ts
@@ -9,12 +9,15 @@ import type { SettingsManager } from '~/core/settings.ts';
import {
createDevToolActionButton,
createDevToolActionRow,
+ createDevToolGroup,
createDevToolModulePanel,
+ createDevToolRow,
createDevToolSummary,
} from '~/dev/devToolUi.ts';
import type { Module } from '~/module.ts';
import { CalendarReminders } from '~/panel/clock/calendarReminders/calendarReminders.ts';
import type { CalendarEvent } from '~/panel/clock/calendarReminders/calendarRemindersLogic.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
const DEVTOOL_SOURCE_KEY = 'aurora-devtool';
@@ -53,6 +56,23 @@ export class CalendarRemindersDevTool {
),
);
+ if (this._events.length) {
+ const preview = createDevToolGroup(_('Fake events'));
+ for (const event of this._events) {
+ const time = new Date(event.startEpochSeconds * 1000).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ preview.list.add_child(
+ createDevToolRow(
+ event.title,
+ `${time} · ${this._withReminder ? _('reminder') : _('no reminder')}`,
+ ),
+ );
+ }
+ panel.add_child(preview.container);
+ }
+
const firstRow = createDevToolActionRow();
firstRow.add_child(
createDevToolActionButton(
@@ -80,6 +100,9 @@ export class CalendarRemindersDevTool {
() => this.setWithReminder(!this._withReminder),
!calendarReminders,
this._withReminder,
+ undefined,
+ 'calendar-with-reminder',
+ true,
),
);
secondRow.add_child(
diff --git a/src/dev/captureToolsDevTool.ts b/src/dev/captureToolsDevTool.ts
index 4d93926..9b0dbc4 100644
--- a/src/dev/captureToolsDevTool.ts
+++ b/src/dev/captureToolsDevTool.ts
@@ -57,6 +57,8 @@ export class CaptureToolsDevTool {
'Open Preview',
() => void this.openPreview(),
!capture,
+ false,
+ 'suggested',
),
);
sessionRow.add_child(
@@ -71,6 +73,9 @@ export class CaptureToolsDevTool {
`Tool: ${toolLabel}`,
() => this.cycleTool(),
!capture,
+ false,
+ undefined,
+ 'capture-tool',
),
);
appearanceRow.add_child(
@@ -79,6 +84,9 @@ export class CaptureToolsDevTool {
`Color: ${colorLabel}`,
() => this.cycleColor(),
!capture,
+ false,
+ undefined,
+ 'capture-color',
),
);
appearanceRow.add_child(
@@ -87,6 +95,9 @@ export class CaptureToolsDevTool {
`Width: ${widthLabel}`,
() => this.cycleWidth(),
!capture,
+ false,
+ undefined,
+ 'capture-width',
),
);
panel.add_child(appearanceRow);
@@ -99,6 +110,9 @@ export class CaptureToolsDevTool {
() => this.toggleInteraction('selection'),
!capture,
Boolean(state && state.interaction === 'selection'),
+ undefined,
+ 'capture-interaction-selection',
+ true,
),
);
opacityRow.add_child(
@@ -108,6 +122,9 @@ export class CaptureToolsDevTool {
() => this.toggleInteraction('drawing'),
!capture,
Boolean(state && state.interaction === 'drawing'),
+ undefined,
+ 'capture-interaction-drawing',
+ true,
),
);
panel.add_child(opacityRow);
@@ -120,6 +137,9 @@ export class CaptureToolsDevTool {
() => this.setTesseractAvailable(true),
!capture,
Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === true),
+ undefined,
+ 'capture-tesseract-on',
+ true,
),
);
tesseractRow.add_child(
@@ -129,6 +149,9 @@ export class CaptureToolsDevTool {
() => this.setTesseractAvailable(false),
!capture,
Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === false),
+ undefined,
+ 'capture-tesseract-off',
+ true,
),
);
panel.add_child(tesseractRow);
diff --git a/src/dev/clipboardHistoryDevTool.ts b/src/dev/clipboardHistoryDevTool.ts
index 1f9891c..f9bd10b 100644
--- a/src/dev/clipboardHistoryDevTool.ts
+++ b/src/dev/clipboardHistoryDevTool.ts
@@ -8,10 +8,13 @@ import { fingerprintBytes } from '~/clipboard/clipboardMonitor.ts';
import {
createDevToolActionButton,
createDevToolActionRow,
+ createDevToolGroup,
createDevToolModulePanel,
+ createDevToolRow,
createDevToolSummary,
} from '~/dev/devToolUi.ts';
import type { Module } from '~/module.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
const RANDOM_MESSAGES = [
'Aurora dev note: clipboard entry',
@@ -37,6 +40,8 @@ export class ClipboardHistoryDevTool {
readonly title = 'Clipboard';
readonly iconName = 'edit-paste-symbolic';
+ private _generatedSamples: string[] = [];
+
constructor(
private readonly _getModule: (key: string) => Module | null,
private readonly _requestMenuRebuild: () => void,
@@ -86,6 +91,8 @@ export class ClipboardHistoryDevTool {
'Clear History',
() => this.clearHistory(),
!clipboard || clipboard.entryCount === 0,
+ false,
+ 'destructive',
),
);
panel.add_child(secondaryRow);
@@ -117,6 +124,13 @@ export class ClipboardHistoryDevTool {
);
panel.add_child(sampleRow);
+ if (this._generatedSamples.length) {
+ const preview = createDevToolGroup(_('Generated samples'));
+ for (const sample of this._generatedSamples)
+ preview.list.add_child(createDevToolRow(sample, _('Added by Aurora DevTool')));
+ panel.add_child(preview.container);
+ }
+
return panel;
}
@@ -133,7 +147,7 @@ export class ClipboardHistoryDevTool {
const text = this._makeRandomMessage();
if (!clipboard.addText(text)) return null;
- this._requestMenuRebuild();
+ this._remember(text);
return text;
}
@@ -151,7 +165,7 @@ export class ClipboardHistoryDevTool {
if (!clipboard) return false;
const added = clipboard.addText(SAMPLE_LINK);
- if (added) this._requestMenuRebuild();
+ if (added) this._remember(SAMPLE_LINK);
return added;
}
@@ -160,7 +174,7 @@ export class ClipboardHistoryDevTool {
if (!clipboard) return false;
const added = clipboard.addText(SAMPLE_CODE_SNIPPET);
- if (added) this._requestMenuRebuild();
+ if (added) this._remember(_('Generated code sample'));
return added;
}
@@ -175,7 +189,7 @@ export class ClipboardHistoryDevTool {
bytes,
fingerprint: fingerprintBytes(bytes),
});
- if (added) this._requestMenuRebuild();
+ if (added) this._remember(_('Generated image sample'));
return added;
}
@@ -184,6 +198,7 @@ export class ClipboardHistoryDevTool {
if (!clipboard) return false;
if (!clipboard.clearHistory()) return false;
+ this._generatedSamples = [];
this._requestMenuRebuild();
return true;
}
@@ -211,4 +226,9 @@ export class ClipboardHistoryDevTool {
const base = RANDOM_MESSAGES[Math.floor(Math.random() * RANDOM_MESSAGES.length)]!;
return `${base} #${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
+
+ private _remember(sample: string): void {
+ this._generatedSamples = [sample, ...this._generatedSamples].slice(0, 5);
+ this._requestMenuRebuild();
+ }
}
diff --git a/src/dev/devTool.ts b/src/dev/devTool.ts
index 9fa3a7b..4899da5 100644
--- a/src/dev/devTool.ts
+++ b/src/dev/devTool.ts
@@ -2,19 +2,28 @@ import '@girs/gjs';
import St from '@girs/st-18';
import Clutter from '@girs/clutter-18';
+import GLib from '@girs/glib-2.0';
+import Atk from '@girs/atk-1.0';
import * as Main from '@girs/gnome-shell/ui/main';
import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu';
import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu';
import type { ExtensionContext } from '~/core/context.ts';
+import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts';
+import { createManagedSource, createManagedTimeout, type ManagedTimeout } from '~/core/mainLoop.ts';
import { Module } from '~/module.ts';
+import { MODULE_CATALOG } from '~/moduleCatalog.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
import { createIcon } from '~/shared/icons.ts';
-import { ClipboardHistoryDevTool } from './clipboardHistoryDevTool.ts';
+import { CalendarRemindersDevTool } from './calendarRemindersDevTool.ts';
import { CaptureToolsDevTool } from './captureToolsDevTool.ts';
+import { ClipboardHistoryDevTool } from './clipboardHistoryDevTool.ts';
+import { DevToolModuleBrowser, plainText } from './devToolModuleBrowser.ts';
+import { DevToolModuleSettings } from './devToolModuleSettings.ts';
+import { createDevToolGroup, createDevToolRow } from './devToolUi.ts';
import { DockDevTool } from './dockDevTool.ts';
import { GeneralDevTool } from './generalDevTool.ts';
-import { CalendarRemindersDevTool } from './calendarRemindersDevTool.ts';
import { TrayIconsDevTool } from './trayIconsDevTool.ts';
import { WeatherClockDevTool } from './weatherClockDevTool.ts';
@@ -30,6 +39,7 @@ type DevToolSection = {
type DevToolCallbacks = {
getModule(key: string): Module | null;
openPreferences(): void;
+ versionName: string;
};
type DevTools = {
@@ -42,18 +52,57 @@ type DevTools = {
calendarReminders: CalendarRemindersDevTool;
};
+type Route =
+ | { type: 'modules' }
+ | { type: 'devtools' }
+ | { type: 'module'; key: string }
+ | { type: 'commands'; key: string; optionKey: string }
+ | { type: 'devtool'; key: string };
+
+const DEVTOOL_SECTION_SUBTITLES: Record = {
+ general: _('Extension tools and session info'),
+ dock: _('Visibility, layout and monitor bindings'),
+ 'capture-tools': _('Annotation overlay and OCR states'),
+ 'clipboard-history': _('Populate the history panel'),
+ 'tray-icons': _('Fake SNI items and attention badges'),
+ 'weather-clock': _('Fake weather snapshots'),
+ 'calendar-reminders': _('Fake events and alerts'),
+};
+
export class DevTool extends Module {
private _button: PanelMenu.Button | null = null;
private _menuOpenStateId = 0;
private _tools: DevTools | null = null;
- private _activeSectionKey = 'general';
- private _sectionDropdownOpen = false;
+ private _routes: Route[] = [{ type: 'modules' }];
+ private _toast = '';
+ private _lastAnimatedRoute = '';
+ private _returnFocusNames: string[] = [];
+ private _scrollPositions = new Map();
+ private _lifecycle: LifecycleScope | null = null;
+ private _toastTimer: ManagedTimeout | null = null;
+ private _uiRestoreIdle: ManagedSource | null = null;
+ private readonly _moduleBrowser: DevToolModuleBrowser;
+ private readonly _moduleSettings: DevToolModuleSettings;
constructor(
context: ExtensionContext,
private readonly _callbacks: DevToolCallbacks,
) {
super(context);
+ this._moduleBrowser = new DevToolModuleBrowser(context.settings, {
+ openModule: (key, returnFocusName) =>
+ this._navigate({ type: 'module', key }, returnFocusName),
+ });
+ this._moduleSettings = new DevToolModuleSettings(context.settings, {
+ getModule: this._callbacks.getModule,
+ rebuild: (requestedFocusName) => this._rebuildMenu(requestedFocusName),
+ openCommands: (key, optionKey, returnFocusName) =>
+ this._navigate({ type: 'commands', key, optionKey }, returnFocusName),
+ openDeveloperTool: (key, returnFocusName) =>
+ this._navigate({ type: 'devtool', key }, returnFocusName),
+ hasDeveloperTool: (key) => this._section(key) !== undefined,
+ buildHero: (title, subtitle) => this._buildHero(title, subtitle),
+ });
}
override enable(): void {
@@ -67,27 +116,50 @@ export class DevTool extends Module {
}),
);
- const rebuildMenu = () => this._rebuildMenu();
+ this._lifecycle = new LifecycleScope();
+ this._toastTimer = createManagedTimeout(this._lifecycle);
+ this._uiRestoreIdle = createManagedSource(this._lifecycle);
+ const rebuild = () => this._showToast(_('Runtime updated'));
this._tools = {
- general: new GeneralDevTool(this._callbacks.openPreferences),
- captureTools: new CaptureToolsDevTool(this._callbacks.getModule, rebuildMenu),
- dock: new DockDevTool(this.context.settings, this._callbacks.getModule, rebuildMenu),
- clipboardHistory: new ClipboardHistoryDevTool(this._callbacks.getModule, rebuildMenu),
- trayIcons: new TrayIconsDevTool(rebuildMenu),
- weatherClock: new WeatherClockDevTool(this._callbacks.getModule, rebuildMenu),
+ general: new GeneralDevTool(
+ this._callbacks.openPreferences,
+ () => {
+ const menu = this._getMenu();
+ if (menu) menu.close();
+ },
+ this._callbacks.versionName,
+ () =>
+ MODULE_CATALOG.filter((manifest) =>
+ this.context.settings.getBoolean(manifest.settingsKey),
+ ).length,
+ ),
+ captureTools: new CaptureToolsDevTool(this._callbacks.getModule, rebuild),
+ dock: new DockDevTool(this.context.settings, this._callbacks.getModule, rebuild),
+ clipboardHistory: new ClipboardHistoryDevTool(this._callbacks.getModule, rebuild),
+ trayIcons: new TrayIconsDevTool(rebuild),
+ weatherClock: new WeatherClockDevTool(this._callbacks.getModule, rebuild),
calendarReminders: new CalendarRemindersDevTool(
this.context.settings,
this._callbacks.getModule,
- rebuildMenu,
+ rebuild,
),
};
const menu = this._getMenu();
if (!menu) return;
- menu.setSourceAlignment(1.0);
+ menu.setSourceAlignment(1.0);
+ menu.actor.add_style_class_name('aurora-devtool-menu');
this._menuOpenStateId = menu.connect('open-state-changed', (_menu, open) => {
- if (open) this._rebuildMenu();
+ if (open) {
+ this._routes = [{ type: 'modules' }];
+ this._moduleBrowser.resetQuery();
+ this._moduleSettings.resetEditor();
+ this._lastAnimatedRoute = '';
+ this._returnFocusNames = [];
+ this._scrollPositions.clear();
+ this._rebuildMenu();
+ }
return undefined;
});
@@ -98,20 +170,27 @@ export class DevTool extends Module {
override disable(): void {
const tools = this._tools;
this._tools = null;
-
+ if (this._lifecycle) this._lifecycle.dispose();
+ this._lifecycle = null;
+ this._toastTimer = null;
+ this._uiRestoreIdle = null;
if (tools) {
tools.captureTools.destroy();
tools.trayIcons.destroy();
tools.weatherClock.destroy();
tools.calendarReminders.destroy();
}
-
+ this._toast = '';
+ this._lastAnimatedRoute = '';
+ this._returnFocusNames = [];
+ this._scrollPositions.clear();
+ this._moduleBrowser.resetQuery();
+ this._moduleSettings.resetEditor();
if (this._menuOpenStateId && this._button) {
(this._button.menu as PopupMenu.PopupMenu).disconnect(this._menuOpenStateId);
this._menuOpenStateId = 0;
}
-
- this._button?.destroy();
+ if (this._button) this._button.destroy();
this._button = null;
}
@@ -143,187 +222,348 @@ export class DevTool extends Module {
return this._tools ? this._tools.weatherClock : null;
}
- private _rebuildMenu(): void {
- const menu = this._getMenu();
- if (!menu) return;
+ private _route(): Route {
+ if (this._routes.length === 0) return { type: 'modules' };
+ return this._routes.at(-1)!;
+ }
+
+ private _navigate(route: Route, returnFocusName: string): void {
+ this._routes.push(route);
+ this._returnFocusNames.push(returnFocusName);
+ this._moduleSettings.resetEditor();
+ this._rebuildMenu('aurora-devtool-focus-back');
+ }
+
+ private _back(): void {
+ if (this._routes.length <= 1) return;
+
+ this._routes.pop();
+ const returnFocusName = this._returnFocusNames.pop();
+ this._moduleSettings.resetEditor();
+ this._rebuildMenu(returnFocusName);
+ }
+
+ private _rebuildMenu(requestedFocusName?: string): void {
+ if (!this._button) return;
+ const menu = this._button.menu as PopupMenu.PopupMenu;
+ const currentScroll = this._findScrollView(menu.actor);
+ if (currentScroll && this._lastAnimatedRoute)
+ this._scrollPositions.set(this._lastAnimatedRoute, currentScroll.vadjustment.value);
+
+ const focused = global.stage.get_key_focus();
+ const focusName = requestedFocusName || (focused ? focused.name : '') || '';
menu.removeAll();
+
+ const route = this._route();
+ const routeSignature = this._routeSignature(route);
+ const animatePage = routeSignature !== this._lastAnimatedRoute;
+ this._lastAnimatedRoute = routeSignature;
+
const section = new PopupMenu.PopupMenuSection();
- section.box.add_child(this._buildPanel());
+ section.box.add_child(this._buildCard(animatePage, this._button));
menu.addMenuItem(section);
+
+ const scrollPosition = this._scrollPositions.get(routeSignature) || 0;
+ if (!focusName && scrollPosition === 0) return;
+
+ if (!this._uiRestoreIdle) return;
+ const uiRestoreIdle = this._uiRestoreIdle;
+ uiRestoreIdle.replace(() =>
+ GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
+ uiRestoreIdle.complete();
+ if (!this._button) return GLib.SOURCE_REMOVE;
+
+ const rebuiltMenu = this._button.menu as PopupMenu.PopupMenu;
+ const scroll = this._findScrollView(rebuiltMenu.actor);
+ if (scroll) scroll.vadjustment.value = scrollPosition;
+ if (focusName) {
+ const focusTarget = this._findNamedActor(rebuiltMenu.actor, focusName);
+ if (focusTarget instanceof St.Widget && focusTarget.can_focus && focusTarget.visible)
+ focusTarget.grab_key_focus();
+ }
+ return GLib.SOURCE_REMOVE;
+ }),
+ );
}
- private _buildPanel(): St.Widget {
+ private _routeSignature(route: Route): string {
+ if (route.type === 'module' || route.type === 'devtool') return `${route.type}:${route.key}`;
+ if (route.type === 'commands') return `${route.type}:${route.key}:${route.optionKey}`;
+ return route.type;
+ }
+
+ private _findNamedActor(actor: Clutter.Actor, name: string): Clutter.Actor | null {
+ if (actor.name === name) return actor;
+ for (const child of actor.get_children()) {
+ const match = this._findNamedActor(child, name);
+ if (match) return match;
+ }
+ return null;
+ }
+
+ private _findScrollView(actor: Clutter.Actor): St.ScrollView | null {
+ if (actor instanceof St.ScrollView) return actor;
+ for (const child of actor.get_children()) {
+ const match = this._findScrollView(child);
+ if (match) return match;
+ }
+ return null;
+ }
+
+ private _buildCard(animatePage: boolean, sourceActor: Clutter.Actor): St.Widget {
+ const monitor = Main.layoutManager.findMonitorForActor(sourceActor);
+ const monitorIndex = monitor ? monitor.index : Main.layoutManager.primaryIndex;
+ const workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+ const width = Math.min(448, Math.max(1, workArea.width - 24));
+ const maxHeight = Math.max(1, workArea.height - 48);
const panel = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
style_class: 'aurora-devtool-panel',
+ style: `width: ${width}px; max-height: ${maxHeight}px;`,
+ x_expand: true,
});
-
panel.add_child(this._buildHeader());
- panel.add_child(this._buildBody());
- return panel;
- }
+ const route = this._route();
+ if (route.type === 'modules' || route.type === 'devtools')
+ panel.add_child(this._buildViewSwitcher(route.type));
+ const viewport = new St.Viewport({ layout_manager: new Clutter.BinLayout() });
+ const page = this._buildRoute(route);
+ viewport.add_child(page);
+ if (route.type === 'modules') panel.add_child(this._moduleBrowser.buildSearch(viewport));
+
+ if (animatePage) {
+ page.opacity = 0;
+ page.translation_x = 10;
+ page.ease({
+ opacity: 255,
+ translationX: 0,
+ duration: 180,
+ mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ });
+ }
- private _buildHeader(): St.Widget {
- const header = new St.BoxLayout({
- style_class: 'aurora-devtool-header',
+ const scroll = new St.ScrollView({
+ style_class: 'aurora-devtool-scroll',
+ hscrollbar_policy: St.PolicyType.NEVER,
+ vscrollbar_policy: St.PolicyType.AUTOMATIC,
+ x_expand: true,
});
+ scroll.add_child(viewport);
+ panel.add_child(scroll);
- header.add_child(
- createIcon('applications-engineering-symbolic', {
- icon_size: 18,
- style_class: 'aurora-devtool-header-icon',
- }),
- );
- header.add_child(
- new St.Label({
- text: 'Aurora DevTool',
- y_align: Clutter.ActorAlign.CENTER,
- style_class: 'aurora-devtool-title',
- }),
- );
+ const stack = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ x_expand: true,
+ y_expand: true,
+ });
+ stack.add_child(panel);
+ if (this._toast)
+ stack.add_child(
+ new St.Label({
+ text: this._toast,
+ style_class: 'aurora-devtool-toast',
+ accessible_role: Atk.Role.NOTIFICATION,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.END,
+ }),
+ );
+ return stack;
+ }
- return header;
+ private _showToast(message: string): void {
+ if (!this._toastTimer || !this._button) return;
+
+ this._toast = message;
+ this._rebuildMenu();
+ this._toastTimer.schedule(2600, () => {
+ this._toast = '';
+ this._rebuildMenu();
+ });
}
- private _buildBody(): St.Widget {
- const body = new St.BoxLayout({
- orientation: Clutter.Orientation.VERTICAL,
- style_class: 'aurora-devtool-body',
+ private _buildHeader(): St.Widget {
+ const route = this._route();
+ const header = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ style_class: 'aurora-devtool-header',
x_expand: true,
- y_expand: true,
});
+ const canGoBack = this._routes.length > 1;
+
+ const nav = new St.BoxLayout({ style_class: 'aurora-devtool-header-nav', x_expand: true });
+ const back = new St.Button({
+ child: new St.Icon({ icon_name: 'go-previous-symbolic', icon_size: 16 }),
+ style_class: 'button aurora-devtool-header-button aurora-devtool-back-button',
+ can_focus: canGoBack,
+ reactive: canGoBack,
+ visible: canGoBack,
+ y_align: Clutter.ActorAlign.CENTER,
+ accessible_name: _('Back'),
+ name: 'aurora-devtool-focus-back',
+ });
+ back.connect('clicked', () => this._back());
+ nav.add_child(back);
+ nav.add_child(new St.Widget({ x_expand: true }));
+ header.add_child(nav);
- body.add_child(this._buildSectionDropdown());
-
- const content = new St.BoxLayout({
+ const labels = new St.BoxLayout({
orientation: Clutter.Orientation.VERTICAL,
- style_class: 'aurora-devtool-content',
+ style_class: 'aurora-devtool-header-labels',
x_expand: true,
y_expand: true,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER,
+ reactive: false,
});
+ labels.add_child(
+ new St.Label({ text: this._routeTitle(route), style_class: 'aurora-devtool-title' }),
+ );
+ labels.add_child(
+ new St.Label({ text: this._routeSubtitle(route), style_class: 'aurora-devtool-subtitle' }),
+ );
+ header.add_child(labels);
+ return header;
+ }
- const activeSection = this._activeSection();
- if (activeSection) {
- content.add_child(activeSection.buildPanel());
+ private _routeTitle(route: Route): string {
+ if (route.type === 'module') {
+ const manifest = MODULE_CATALOG.find((entry) => entry.key === route.key);
+ return manifest ? plainText(manifest.title) : _('Module');
}
-
- body.add_child(content);
- return body;
+ if (route.type === 'commands') return _('Custom Commands');
+ return _('Aurora DevTool');
}
- private _buildSectionDropdown(): St.Widget {
- const dropdown = new St.BoxLayout({
- orientation: Clutter.Orientation.VERTICAL,
- style_class: 'aurora-devtool-section-dropdown',
- x_expand: true,
- });
+ private _routeSubtitle(route: Route): string {
+ if (route.type === 'modules') return _('Modules');
+ if (route.type === 'devtools') return _('Developer Tools');
+ if (route.type === 'module') return _('Module Settings');
+ if (route.type === 'commands') return _('Aurora Menu');
- const activeSection = this._activeSection();
- if (activeSection) {
- dropdown.add_child(this._buildSectionTrigger(activeSection));
- }
+ const section = this._section(route.key);
+ return section ? section.title : _('Developer Tool');
+ }
- if (this._sectionDropdownOpen) {
- const list = new St.BoxLayout({
- orientation: Clutter.Orientation.VERTICAL,
- style_class: 'aurora-devtool-section-menu',
+ private _buildViewSwitcher(active: 'modules' | 'devtools'): St.Widget {
+ const switcher = new St.BoxLayout({ style_class: 'aurora-devtool-view-switcher' });
+ const track = new St.BoxLayout({ style_class: 'aurora-devtool-view-track', x_expand: true });
+ for (const [key, label, iconName] of [
+ ['modules', _('Modules'), 'view-app-grid-symbolic'],
+ ['devtools', _('Dev Tools'), 'applications-engineering-symbolic'],
+ ] as const) {
+ const content = new St.BoxLayout({
+ style_class: 'aurora-devtool-view-button-content',
+ x_align: Clutter.ActorAlign.CENTER,
+ });
+ content.add_child(new St.Icon({ icon_name: iconName, icon_size: 14 }));
+ content.add_child(new St.Label({ text: label }));
+ const button = new St.Button({
+ child: content,
+ style_class:
+ key === active
+ ? 'button aurora-devtool-view-button active'
+ : 'button aurora-devtool-view-button',
+ can_focus: true,
x_expand: true,
+ toggle_mode: true,
+ checked: key === active,
+ accessible_name: label,
+ name: `aurora-devtool-focus-view-${key}`,
});
- for (const section of this._sections()) {
- list.add_child(this._buildSectionOption(section));
- }
- dropdown.add_child(list);
+ button.connect('clicked', () => {
+ this._routes = [{ type: key }];
+ this._returnFocusNames = [];
+ this._moduleBrowser.resetQuery();
+ this._moduleSettings.resetEditor();
+ this._rebuildMenu(`aurora-devtool-focus-view-${key}`);
+ });
+ track.add_child(button);
}
-
- return dropdown;
+ switcher.add_child(track);
+ return switcher;
}
- private _buildSectionTrigger(section: DevToolSection): St.Button {
- const content = this._buildSectionButtonContent(section, 'pan-down-symbolic');
- const button = new St.Button({
- child: content,
- style_class: this._sectionDropdownOpen
- ? 'button aurora-devtool-section-trigger active'
- : 'button aurora-devtool-section-trigger',
- can_focus: true,
- x_expand: true,
- accessible_name: section.title,
- });
- button.connect('clicked', () => {
- this._sectionDropdownOpen = !this._sectionDropdownOpen;
- this._rebuildMenu();
- });
- return button;
- }
-
- private _buildSectionOption(section: DevToolSection): St.Button {
- const active = section.key === this._activeSectionKey;
- const content = this._buildSectionButtonContent(
- section,
- active ? 'object-select-symbolic' : null,
- );
- const button = new St.Button({
- child: content,
- style_class: active
- ? 'button aurora-devtool-section-option active'
- : 'button aurora-devtool-section-option',
- can_focus: true,
- x_expand: true,
- accessible_name: section.title,
- });
- button.connect('clicked', () => {
- this._activeSectionKey = section.key;
- this._sectionDropdownOpen = false;
- this._rebuildMenu();
- });
- return button;
+ private _buildRoute(route: Route): St.Widget {
+ let page: St.Widget;
+ if (route.type === 'modules') page = this._moduleBrowser.buildRoot();
+ else if (route.type === 'devtools') page = this._buildDevTools();
+ else if (route.type === 'module') page = this._moduleSettings.buildModule(route.key);
+ else if (route.type === 'commands')
+ page = this._moduleSettings.buildCommands(route.key, route.optionKey);
+ else page = this._buildDevTool(route.key);
+ page.x_expand = true;
+ return page;
}
- private _buildSectionButtonContent(
- section: DevToolSection,
- suffixIconName: string | null,
- ): St.Widget {
+ private _buildDevTools(): St.Widget {
const content = new St.BoxLayout({
- style_class: 'aurora-devtool-section-button-content',
- x_expand: true,
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
});
-
- content.add_child(
- new St.Icon({
- icon_name: section.iconName,
- icon_size: 16,
- style_class: 'aurora-devtool-section-icon',
- }),
+ const group = createDevToolGroup(
+ _('Developer Tools'),
+ _('Simulate runtime states and trigger module actions'),
);
- content.add_child(
- new St.Label({
- text: section.title,
- y_align: Clutter.ActorAlign.CENTER,
+ for (const section of this._sections()) {
+ const focusName = `aurora-devtool-focus-devtool-${section.key}`;
+ const button = new St.Button({
+ child: createDevToolRow(
+ section.title,
+ DEVTOOL_SECTION_SUBTITLES[section.key] || _('Inspect and simulate runtime state'),
+ new St.Icon({ icon_name: 'go-next-symbolic', icon_size: 16 }),
+ section.iconName,
+ ),
+ style_class: 'button aurora-devtool-list-button',
+ can_focus: true,
x_expand: true,
- }),
- );
- if (suffixIconName) {
- content.add_child(
- new St.Icon({
- icon_name: suffixIconName,
- icon_size: 16,
- style_class: 'aurora-devtool-section-suffix-icon',
- }),
+ name: focusName,
+ });
+ button.connect('clicked', () =>
+ this._navigate({ type: 'devtool', key: section.key }, focusName),
);
+ group.list.add_child(button);
}
+ content.add_child(group.container);
+ return content;
+ }
+
+ private _buildDevTool(key: string): St.Widget {
+ const section = this._section(key);
+ const content = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
+ });
+ if (!section) return content;
+ content.add_child(this._buildHero(section.title, _('Live development controls')));
+ content.add_child(section.buildPanel());
return content;
}
- private _activeSection(): DevToolSection | null {
- const activeSection = this._sections().find(
- (section) => section.key === this._activeSectionKey,
- );
- if (!activeSection) return null;
+ private _buildHero(title: string, subtitle: string): St.Widget {
+ const hero = new St.BoxLayout({ style_class: 'aurora-devtool-hero', x_expand: true });
+ const labels = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-hero-labels',
+ x_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-hero-title' }));
+ const subtitleLabel = new St.Label({
+ text: subtitle,
+ style_class: 'aurora-devtool-hero-subtitle',
+ x_expand: true,
+ });
+ subtitleLabel.clutter_text.line_wrap = true;
+ labels.add_child(subtitleLabel);
+ hero.add_child(labels);
+ return hero;
+ }
- return activeSection;
+ private _section(key: string): DevToolSection | undefined {
+ return this._sections().find((section) => section.key === key);
}
private _sections(): DevToolSection[] {
@@ -331,10 +571,6 @@ export class DevTool extends Module {
}
private _getMenu(): PopupMenu.PopupMenu | null {
- if (!this._button) {
- return null;
- }
-
- return this._button.menu as PopupMenu.PopupMenu;
+ return this._button ? (this._button.menu as PopupMenu.PopupMenu) : null;
}
}
diff --git a/src/dev/devToolModuleBrowser.ts b/src/dev/devToolModuleBrowser.ts
new file mode 100644
index 0000000..762c80b
--- /dev/null
+++ b/src/dev/devToolModuleBrowser.ts
@@ -0,0 +1,236 @@
+import St from '@girs/st-18';
+import Clutter from '@girs/clutter-18';
+import Pango from '@girs/pango-1.0';
+
+import type { SettingsManager } from '~/core/settings.ts';
+import type { ModuleManifest } from '~/module.ts';
+import { getSections, MODULE_CATALOG } from '~/moduleCatalog.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
+import { createIcon } from '~/shared/icons.ts';
+
+import { createDevToolGroup, createDevToolSwitch } from './devToolUi.ts';
+
+type ModuleBrowserCallbacks = {
+ openModule(key: string, returnFocusName: string): void;
+};
+
+const MODULE_ICONS: Record = {
+ 'no-overview': 'view-app-grid-symbolic',
+ 'pip-on-top': 'view-pin-symbolic',
+ 'focus-launched-windows': 'focus-windows-symbolic',
+ 'capture-tools': 'camera-photo-symbolic',
+ 'theme-changer': 'preferences-desktop-appearance-symbolic',
+ dock: 'view-app-grid-symbolic',
+ 'aurora-menu': 'aurora-shell-menu-symbolic',
+ 'power-menu-avatar': 'avatar-default-symbolic',
+ 'volume-mixer': 'audio-volume-high-symbolic',
+ 'low-battery-percentage': 'battery-level-20-symbolic',
+ 'lock-key-indicators': 'input-keyboard-symbolic',
+ 'xwayland-indicator': 'video-display-symbolic',
+ privacy: 'changes-prevent-symbolic',
+ 'icon-weave': 'applications-graphics-symbolic',
+ 'app-search-tooltip': 'system-search-symbolic',
+ 'vela-vpn-quick-settings': 'network-vpn-symbolic',
+ 'auto-theme-switcher': 'weather-clear-night-symbolic',
+ 'bluetooth-menu': 'bluetooth-symbolic',
+ 'weather-clock': 'weather-clear-symbolic',
+ 'calendar-reminders': 'x-office-calendar-symbolic',
+ 'tray-icons': 'view-grid-symbolic',
+ 'clipboard-history': 'edit-paste-symbolic',
+};
+
+export function plainText(text: string): string {
+ return text.replaceAll('&', '&');
+}
+
+export class DevToolModuleBrowser {
+ private _query = '';
+
+ constructor(
+ private readonly _settings: SettingsManager,
+ private readonly _callbacks: ModuleBrowserCallbacks,
+ ) {}
+
+ resetQuery(): void {
+ this._query = '';
+ }
+
+ buildSearch(viewport: St.Viewport): St.Widget {
+ const box = new St.BoxLayout({ style_class: 'aurora-devtool-search-box' });
+ const entry = new St.Entry({
+ text: this._query,
+ hint_text: _('Search modules'),
+ can_focus: true,
+ style_class: 'aurora-devtool-search',
+ x_expand: true,
+ name: 'aurora-devtool-focus-search',
+ });
+ entry.set_primary_icon(
+ new St.Icon({
+ icon_name: 'system-search-symbolic',
+ icon_size: 15,
+ style_class: 'aurora-devtool-search-icon',
+ }),
+ );
+ entry.clutter_text.connect('text-changed', () => {
+ this._query = entry.get_text();
+ for (const child of viewport.get_children()) child.destroy();
+
+ const page = this.buildRoot();
+ page.x_expand = true;
+ viewport.add_child(page);
+ });
+ box.add_child(entry);
+ return box;
+ }
+
+ buildRoot(): St.Widget {
+ const query = this._query.trim().toLocaleLowerCase();
+ let matches = 0;
+ const groups: St.Widget[] = [];
+ const sections = getSections();
+ const knownSections = new Set(sections.map((section) => section.id));
+ for (const section of [...sections, { id: 'other', title: _('Other') }]) {
+ const manifests = MODULE_CATALOG.filter((manifest) => {
+ const belongs =
+ section.id === 'other'
+ ? !knownSections.has(manifest.section)
+ : manifest.section === section.id;
+ if (!belongs) return false;
+
+ const haystack =
+ `${plainText(manifest.title)} ${plainText(manifest.subtitle)}`.toLocaleLowerCase();
+ return !query || haystack.includes(query);
+ });
+ if (manifests.length === 0) continue;
+
+ matches += manifests.length;
+ const group = createDevToolGroup(plainText(section.title));
+ for (const manifest of manifests) group.list.add_child(this._buildModuleRow(manifest));
+ groups.push(group.container);
+ }
+ if (query && matches === 0) return this._buildSearchStatusPage();
+
+ const content = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
+ });
+ content.add_child(
+ new St.Label({ text: _('Modules'), style_class: 'aurora-devtool-page-title' }),
+ );
+ content.add_child(
+ new St.Label({
+ text: _('%d of %d modules · toggle a module or open its settings').format(
+ matches,
+ MODULE_CATALOG.length,
+ ),
+ style_class: 'aurora-devtool-page-subtitle',
+ }),
+ );
+ for (const group of groups) content.add_child(group);
+ return content;
+ }
+
+ private _buildSearchStatusPage(): St.Widget {
+ const content = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
+ });
+ const status = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-status-page',
+ x_expand: true,
+ });
+ status.add_child(
+ new St.Icon({
+ icon_name: 'system-search-symbolic',
+ icon_size: 72,
+ style_class: 'aurora-devtool-status-icon',
+ x_align: Clutter.ActorAlign.CENTER,
+ opacity: 140,
+ }),
+ );
+ status.add_child(
+ new St.Label({
+ text: _('No Results Found'),
+ style_class: 'aurora-devtool-status-title',
+ }),
+ );
+ const description = new St.Label({
+ text: _('No modules match “%s”. Try a different search.').format(this._query.trim()),
+ style_class: 'aurora-devtool-status-description',
+ x_expand: true,
+ });
+ description.clutter_text.line_wrap = true;
+ status.add_child(description);
+ content.add_child(status);
+ return content;
+ }
+
+ private _buildModuleRow(manifest: ModuleManifest): St.Widget {
+ const enabled = this._settings.getBoolean(manifest.settingsKey);
+ const row = new St.BoxLayout({
+ style_class: 'aurora-devtool-row aurora-devtool-module-row',
+ x_expand: true,
+ });
+ const openContent = new St.BoxLayout({
+ style_class: 'aurora-devtool-navigation-content',
+ x_expand: true,
+ });
+ openContent.add_child(
+ createIcon(MODULE_ICONS[manifest.key] || 'application-x-addon-symbolic', {
+ icon_size: 18,
+ style_class: 'aurora-devtool-row-icon',
+ }),
+ );
+ openContent.add_child(
+ this._buildRowLabels(plainText(manifest.title), plainText(manifest.subtitle)),
+ );
+ openContent.add_child(
+ new St.Icon({
+ icon_name: 'go-next-symbolic',
+ icon_size: 16,
+ style_class: 'aurora-devtool-chevron',
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ const focusName = `aurora-devtool-focus-module-${manifest.key}`;
+ const open = new St.Button({
+ child: openContent,
+ style_class: 'button aurora-devtool-module-open',
+ can_focus: true,
+ x_expand: true,
+ accessible_name: plainText(manifest.title),
+ name: focusName,
+ });
+ open.connect('clicked', () => this._callbacks.openModule(manifest.key, focusName));
+ row.add_child(open);
+ row.add_child(
+ createDevToolSwitch(
+ enabled,
+ (state) => this._settings.setBoolean(manifest.settingsKey, state),
+ _('Enable %s').format(plainText(manifest.title)),
+ ),
+ );
+ return row;
+ }
+
+ private _buildRowLabels(title: string, subtitle: string): St.Widget {
+ const labels = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-row-labels',
+ x_expand: true,
+ });
+ labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' }));
+ const subtitleLabel = new St.Label({
+ text: subtitle,
+ style_class: 'aurora-devtool-row-subtitle',
+ x_expand: true,
+ });
+ subtitleLabel.clutter_text.line_wrap = true;
+ subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
+ subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ labels.add_child(subtitleLabel);
+ return labels;
+ }
+}
diff --git a/src/dev/devToolModuleSettings.ts b/src/dev/devToolModuleSettings.ts
new file mode 100644
index 0000000..aa187b4
--- /dev/null
+++ b/src/dev/devToolModuleSettings.ts
@@ -0,0 +1,636 @@
+import St from '@girs/st-18';
+import Clutter from '@girs/clutter-18';
+import Meta from '@girs/meta-18';
+import Pango from '@girs/pango-1.0';
+import Atk from '@girs/atk-1.0';
+
+import type { SettingsManager } from '~/core/settings.ts';
+import type { Module, ModuleManifest, ModuleOption } from '~/module.ts';
+import { MODULE_CATALOG } from '~/moduleCatalog.ts';
+import { parseCustomCommand, serializeCustomCommand } from '~/panel/auroraMenuState.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
+import { createIcon } from '~/shared/icons.ts';
+
+import { plainText } from './devToolModuleBrowser.ts';
+import {
+ createDevToolButton,
+ createDevToolGroup,
+ createDevToolRow,
+ createDevToolSwitch,
+} from './devToolUi.ts';
+
+type ModuleSettingsCallbacks = {
+ getModule(key: string): Module | null;
+ rebuild(requestedFocusName?: string): void;
+ openCommands(key: string, optionKey: string, returnFocusName: string): void;
+ openDeveloperTool(key: string, returnFocusName: string): void;
+ hasDeveloperTool(key: string): boolean;
+ buildHero(title: string, subtitle: string): St.Widget;
+};
+
+type CommandEditor = {
+ index: number | null;
+ original: string | null;
+ label: string;
+ command: string;
+};
+
+const MODIFIER_KEYSYMS = new Set([
+ Clutter.KEY_Alt_L,
+ Clutter.KEY_Alt_R,
+ Clutter.KEY_Control_L,
+ Clutter.KEY_Control_R,
+ Clutter.KEY_Hyper_L,
+ Clutter.KEY_Hyper_R,
+ Clutter.KEY_Meta_L,
+ Clutter.KEY_Meta_R,
+ Clutter.KEY_Shift_L,
+ Clutter.KEY_Shift_R,
+ Clutter.KEY_Super_L,
+ Clutter.KEY_Super_R,
+]);
+
+export class DevToolModuleSettings {
+ private _commandEditor: CommandEditor | null = null;
+
+ constructor(
+ private readonly _settings: SettingsManager,
+ private readonly _callbacks: ModuleSettingsCallbacks,
+ ) {}
+
+ resetEditor(): void {
+ this._commandEditor = null;
+ }
+
+ buildModule(key: string): St.Widget {
+ const manifest = MODULE_CATALOG.find((entry) => entry.key === key);
+ const content = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
+ });
+ if (!manifest) return content;
+
+ const enabled = this._settings.getBoolean(manifest.settingsKey);
+ const running = this._callbacks.getModule(manifest.key) !== null;
+ content.add_child(
+ this._callbacks.buildHero(plainText(manifest.title), plainText(manifest.subtitle)),
+ );
+ if (!enabled) content.add_child(this._buildDisabledBanner(manifest));
+
+ const moduleGroup = createDevToolGroup(_('Module'));
+ moduleGroup.list.add_child(
+ createDevToolRow(
+ _('Enable %s').format(plainText(manifest.title)),
+ running
+ ? _('Module is running in this session')
+ : enabled
+ ? _('Module is enabled but not running in this session')
+ : _('Module is off'),
+ createDevToolSwitch(
+ enabled,
+ (state) => {
+ this._settings.setBoolean(manifest.settingsKey, state);
+ this._callbacks.rebuild();
+ },
+ _('Enable %s').format(plainText(manifest.title)),
+ `module-enabled-${manifest.key}`,
+ ),
+ ),
+ );
+ content.add_child(moduleGroup.container);
+
+ if (manifest.options && manifest.options.length > 0) {
+ const settingsGroup = createDevToolGroup(_('Settings'));
+ for (const option of manifest.options) {
+ const row = this._buildOption(manifest, option);
+ if (!enabled) this._disableActor(row);
+ settingsGroup.list.add_child(row);
+ }
+ content.add_child(settingsGroup.container);
+ }
+ if (this._callbacks.hasDeveloperTool(key)) {
+ const developerTitle = _('%s Dev Tools').format(plainText(manifest.title));
+ const developerFocusName = `aurora-devtool-focus-developer-${key}`;
+ const developer = createDevToolGroup(_('Developer'));
+ developer.list.add_child(
+ this._buildNavigationRow(
+ developerTitle,
+ _('Simulate runtime states and trigger module actions'),
+ () => this._callbacks.openDeveloperTool(key, developerFocusName),
+ 'applications-engineering-symbolic',
+ developerFocusName,
+ ),
+ );
+ content.add_child(developer.container);
+ }
+ return content;
+ }
+
+ buildCommands(key: string, optionKey: string): St.Widget {
+ const content = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-content',
+ });
+ content.add_child(
+ new St.Label({ text: _('Custom Menu Commands'), style_class: 'aurora-devtool-page-title' }),
+ );
+ content.add_child(
+ new St.Label({
+ text: _('Shortcuts that run commands from Aurora Menu'),
+ style_class: 'aurora-devtool-page-subtitle',
+ }),
+ );
+ const group = createDevToolGroup(_('Commands'));
+ const rawCommands = this._settings.getStrv(optionKey);
+ let validCommands = 0;
+ rawCommands.forEach((raw, index) => {
+ const command = parseCustomCommand(raw);
+ if (!command) return;
+
+ validCommands++;
+ const actions = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' });
+ actions.add_child(
+ createDevToolButton(
+ _('Edit'),
+ () => {
+ this._commandEditor = { index, original: raw, ...command };
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Edit %s').format(command.label),
+ false,
+ 'flat',
+ `command-edit-${index}`,
+ ),
+ );
+ actions.add_child(
+ createDevToolButton(
+ _('Delete'),
+ () => {
+ const currentCommands = this._settings.getStrv(optionKey);
+ if (currentCommands[index] !== raw) {
+ this._callbacks.rebuild();
+ return;
+ }
+
+ currentCommands.splice(index, 1);
+ this._settings.setStrv(optionKey, currentCommands);
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Delete %s').format(command.label),
+ false,
+ 'destructive',
+ `command-delete-${index}`,
+ ),
+ );
+ group.list.add_child(createDevToolRow(command.label, command.command, actions));
+ });
+ if (validCommands === 0)
+ group.list.add_child(createDevToolRow(_('No custom commands'), _('Add one below.')));
+
+ if (this._commandEditor) {
+ content.add_child(group.container);
+ content.add_child(this._buildCommandEditor(optionKey, this._commandEditor));
+ } else {
+ const buttonRow = new St.BoxLayout({
+ style_class: 'aurora-devtool-button-row',
+ x_expand: true,
+ });
+ const add = createDevToolButton(
+ _('Add Command'),
+ () => {
+ this._commandEditor = { index: null, original: null, label: '', command: '' };
+ this._callbacks.rebuild();
+ },
+ 'list-add-symbolic',
+ undefined,
+ false,
+ 'suggested',
+ );
+ add.x_expand = true;
+ buttonRow.add_child(add);
+ group.list.add_child(buttonRow);
+ content.add_child(group.container);
+ }
+
+ const note = new St.BoxLayout({ style_class: 'aurora-devtool-commands-note' });
+ note.add_child(new St.Label({ text: _('Stored in'), y_align: Clutter.ActorAlign.CENTER }));
+ note.add_child(
+ new St.Label({
+ text: optionKey,
+ style_class: 'aurora-devtool-kbd-chip',
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ note.add_child(
+ new St.Label({
+ text: _('· module %s').format(key),
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ content.add_child(note);
+ return content;
+ }
+
+ private _buildDisabledBanner(manifest: ModuleManifest): St.Widget {
+ const banner = new St.BoxLayout({ style_class: 'aurora-devtool-banner', x_expand: true });
+ banner.add_child(
+ new St.Icon({
+ icon_name: 'dialog-warning-symbolic',
+ icon_size: 16,
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ const bannerLabel = new St.Label({
+ text: _('This module is disabled. Its settings are stored but have no effect.'),
+ style_class: 'aurora-devtool-banner-label',
+ x_expand: true,
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ bannerLabel.clutter_text.line_wrap = true;
+ banner.add_child(bannerLabel);
+ banner.add_child(
+ createDevToolButton(
+ _('Enable'),
+ () => {
+ this._settings.setBoolean(manifest.settingsKey, true);
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Enable %s').format(plainText(manifest.title)),
+ ),
+ );
+ return banner;
+ }
+
+ private _buildOption(manifest: ModuleManifest, option: ModuleOption): St.Widget {
+ const title = plainText(option.title);
+ const subtitle = plainText(option.subtitle);
+ if (option.type === 'switch' && option.key) {
+ const optionKey = option.key;
+ return createDevToolRow(
+ title,
+ subtitle,
+ createDevToolSwitch(
+ this._settings.getBoolean(optionKey),
+ (state) => this._settings.setBoolean(optionKey, state),
+ title,
+ ),
+ );
+ }
+ if (option.type === 'entry' && option.key) {
+ const optionKey = option.key;
+ const entry = new St.Entry({
+ text: this._settings.getString(optionKey),
+ can_focus: true,
+ accessible_name: title,
+ style_class: 'aurora-devtool-entry',
+ });
+ entry.clutter_text.connect('text-changed', () =>
+ this._settings.setString(optionKey, entry.get_text()),
+ );
+ const entryWrap = new St.Widget({
+ layout_manager: new Clutter.BinLayout(),
+ style_class: 'aurora-devtool-entry-wrap',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ entryWrap.add_child(entry);
+ return createDevToolRow(title, subtitle, entryWrap);
+ }
+ if (option.type === 'spin' && option.key)
+ return createDevToolRow(title, subtitle, this._buildStepper(option.key, option, title));
+ if (option.type === 'time' && option.hourKey && option.minuteKey) {
+ const time = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' });
+ time.add_child(
+ this._buildStepper(
+ option.hourKey,
+ { ...option, min: 0, max: 23 },
+ `${title}: ${_('Hours')}`,
+ ),
+ );
+ time.add_child(
+ new St.Label({
+ text: ':',
+ style_class: 'aurora-devtool-time-colon',
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ time.add_child(
+ this._buildStepper(
+ option.minuteKey,
+ { ...option, min: 0, max: 59 },
+ `${title}: ${_('Minutes')}`,
+ ),
+ );
+ return createDevToolRow(title, subtitle, time);
+ }
+ if ((option.type === 'select' || option.type === 'icon-select') && option.key) {
+ const optionKey = option.key;
+ const choices = option.choices || [];
+ const container = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ x_expand: true,
+ });
+ container.add_child(createDevToolRow(title, subtitle));
+ const segmented = new St.BoxLayout({
+ style_class: 'aurora-devtool-segmented',
+ x_expand: true,
+ });
+ const buttons: Array<{ value: string; button: St.Button }> = [];
+ for (const choice of choices) {
+ const button = createDevToolButton(
+ plainText(choice.title),
+ () => {
+ this._settings.setString(optionKey, choice.value);
+ for (const entry of buttons) entry.button.checked = entry.value === choice.value;
+ },
+ choice.iconName,
+ `${title}: ${plainText(choice.title)}`,
+ choice.value === this._settings.getString(optionKey),
+ undefined,
+ `select-${optionKey}-${choice.value}`,
+ true,
+ );
+ button.x_expand = true;
+ button.add_style_class_name('aurora-devtool-segmented-button');
+ buttons.push({ value: choice.value, button });
+ segmented.add_child(button);
+ }
+ container.add_child(segmented);
+ return container;
+ }
+ if (option.type === 'shortcut' && option.key)
+ return createDevToolRow(title, subtitle, this._buildShortcut(option.key));
+ if (option.type === 'command-list' && option.key) {
+ const optionKey = option.key;
+ const focusName = `aurora-devtool-focus-commands-${manifest.key}`;
+ return this._buildNavigationRow(
+ title,
+ subtitle,
+ () => this._callbacks.openCommands(manifest.key, optionKey, focusName),
+ undefined,
+ focusName,
+ );
+ }
+ return createDevToolRow(title, subtitle || _('Unsupported option'));
+ }
+
+ private _buildStepper(key: string, option: ModuleOption, title: string): St.Widget {
+ const value = this._settings.getInt(key);
+ const control = new St.BoxLayout({
+ style_class: 'aurora-devtool-stepper-control',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ control.add_child(
+ new St.Label({
+ text: String(value).padStart(2, '0'),
+ style_class: 'aurora-devtool-stepper-value',
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ const stepper = new St.BoxLayout({ style_class: 'aurora-devtool-stepper' });
+ const decrease = createDevToolButton(
+ '−',
+ () => {
+ const minimum = option.min === undefined ? value - 1 : option.min;
+ this._settings.setInt(key, Math.max(minimum, value - 1));
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Decrease %s').format(title),
+ false,
+ undefined,
+ `decrease-${key}`,
+ );
+ decrease.add_style_class_name('aurora-devtool-stepper-button');
+ stepper.add_child(decrease);
+ const increase = createDevToolButton(
+ '+',
+ () => {
+ const maximum = option.max === undefined ? value + 1 : option.max;
+ this._settings.setInt(key, Math.min(maximum, value + 1));
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Increase %s').format(title),
+ false,
+ undefined,
+ `increase-${key}`,
+ );
+ increase.add_style_class_name('aurora-devtool-stepper-button');
+ stepper.add_child(increase);
+ control.add_child(stepper);
+ if (option.resettable) {
+ const reset = new St.Button({
+ child: new St.Icon({ icon_name: 'edit-undo-symbolic', icon_size: 16 }),
+ style_class: 'button aurora-devtool-header-button aurora-devtool-stepper-reset',
+ can_focus: true,
+ accessible_name: _('Reset'),
+ name: `aurora-devtool-focus-Reset ${key}`,
+ });
+ reset.connect('clicked', () => {
+ this._settings.getRawSettings().reset(key);
+ this._callbacks.rebuild();
+ });
+ control.add_child(reset);
+ }
+ return control;
+ }
+
+ private _buildShortcut(key: string): St.Widget {
+ const shortcut = this._settings.getStrv(key)[0] || '';
+ let capturing = false;
+ const label = new St.Label({ text: shortcut || _('Disabled') });
+ const content = new St.BoxLayout({ style_class: 'aurora-devtool-button-content' });
+ content.add_child(label);
+ const button = new St.Button({
+ child: content,
+ style_class: 'button aurora-devtool-button aurora-devtool-kbd',
+ can_focus: true,
+ accessible_name: _('Keyboard shortcut'),
+ name: `aurora-devtool-focus-shortcut-${key}`,
+ });
+ button.connect('clicked', () => {
+ capturing = !capturing;
+ label.text = capturing ? _('Press shortcut…') : shortcut || _('Disabled');
+ if (capturing) button.grab_key_focus();
+ });
+ button.connect('event', (_actor, event) => {
+ if (!capturing || event.type() !== Clutter.EventType.KEY_PRESS)
+ return Clutter.EVENT_PROPAGATE;
+
+ const keyval = event.get_key_symbol();
+ if (keyval === Clutter.KEY_Escape) {
+ this._callbacks.rebuild();
+ return Clutter.EVENT_STOP;
+ }
+ if (keyval === Clutter.KEY_BackSpace || keyval === Clutter.KEY_Delete) {
+ this._settings.setStrv(key, []);
+ this._callbacks.rebuild();
+ return Clutter.EVENT_STOP;
+ }
+ const supportedModifiers =
+ Clutter.ModifierType.CONTROL_MASK |
+ Clutter.ModifierType.MOD1_MASK |
+ Clutter.ModifierType.SHIFT_MASK |
+ Clutter.ModifierType.SUPER_MASK;
+ const modifiers = event.get_state() & supportedModifiers;
+ if (modifiers === 0 || MODIFIER_KEYSYMS.has(keyval)) return Clutter.EVENT_STOP;
+
+ const accelerator = Meta.accelerator_name(modifiers, keyval);
+ if (!accelerator) return Clutter.EVENT_STOP;
+
+ this._settings.setStrv(key, [accelerator]);
+ this._callbacks.rebuild();
+ return Clutter.EVENT_STOP;
+ });
+ return button;
+ }
+
+ private _buildCommandEditor(optionKey: string, editor: CommandEditor): St.Widget {
+ const group = createDevToolGroup(editor.index === null ? _('Add Command') : _('Edit Command'));
+ const name = new St.Entry({
+ text: editor.label,
+ hint_text: _('Name'),
+ accessible_name: _('Name'),
+ can_focus: true,
+ style_class: 'aurora-devtool-entry',
+ x_expand: true,
+ });
+ const command = new St.Entry({
+ text: editor.command,
+ hint_text: _('Command'),
+ accessible_name: _('Command'),
+ can_focus: true,
+ style_class: 'aurora-devtool-entry',
+ x_expand: true,
+ });
+ const error = new St.Label({
+ style_class: 'aurora-devtool-error',
+ accessible_role: Atk.Role.NOTIFICATION,
+ });
+ group.list.add_child(createDevToolRow(_('Name'), _('Shown in the Aurora menu'), name));
+ group.list.add_child(createDevToolRow(_('Command'), _('Executed when selected'), command));
+
+ const actions = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' });
+ actions.add_child(
+ createDevToolButton(_('Cancel'), () => {
+ this._commandEditor = null;
+ this._callbacks.rebuild();
+ }),
+ );
+ actions.add_child(
+ createDevToolButton(
+ _('Save'),
+ () => {
+ const label = name.get_text().trim();
+ const value = command.get_text().trim();
+ if (!label || !value || label.includes('|')) {
+ error.text =
+ !label || !value
+ ? _('Name and command are required.')
+ : _('Name cannot contain “|”.');
+ return;
+ }
+
+ const serialized = serializeCustomCommand({ label, command: value });
+ const next = this._settings.getStrv(optionKey);
+ if (editor.index === null) next.push(serialized);
+ else {
+ if (next[editor.index] !== editor.original) {
+ error.text = _('Command list changed. Reopen this command before saving.');
+ return;
+ }
+ next[editor.index] = serialized;
+ }
+ this._settings.setStrv(optionKey, next);
+ this._commandEditor = null;
+ this._callbacks.rebuild();
+ },
+ undefined,
+ _('Save'),
+ false,
+ 'suggested',
+ ),
+ );
+
+ const footer = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-group-footer',
+ x_expand: true,
+ });
+ footer.add_child(error);
+ footer.add_child(actions);
+ group.container.add_child(footer);
+ return group.container;
+ }
+
+ private _buildNavigationRow(
+ title: string,
+ subtitle: string,
+ onClick: () => void,
+ iconName?: string,
+ focusName?: string,
+ ): St.Button {
+ const content = new St.BoxLayout({
+ style_class: 'aurora-devtool-navigation-content',
+ x_expand: true,
+ });
+ if (iconName)
+ content.add_child(
+ createIcon(iconName, {
+ icon_size: 18,
+ style_class: 'aurora-devtool-row-icon',
+ }),
+ );
+ content.add_child(this._buildRowLabels(title, subtitle));
+ content.add_child(
+ new St.Icon({
+ icon_name: 'go-next-symbolic',
+ icon_size: 16,
+ style_class: 'aurora-devtool-chevron',
+ y_align: Clutter.ActorAlign.CENTER,
+ }),
+ );
+ const button = new St.Button({
+ child: content,
+ style_class: 'button aurora-devtool-row aurora-devtool-list-button',
+ can_focus: true,
+ x_expand: true,
+ accessible_name: title,
+ name: focusName || null,
+ });
+ button.connect('clicked', onClick);
+ return button;
+ }
+
+ private _buildRowLabels(title: string, subtitle: string): St.Widget {
+ const labels = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-row-labels',
+ x_expand: true,
+ });
+ labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' }));
+ const subtitleLabel = new St.Label({
+ text: subtitle,
+ style_class: 'aurora-devtool-row-subtitle',
+ x_expand: true,
+ });
+ subtitleLabel.clutter_text.line_wrap = true;
+ subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
+ subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ labels.add_child(subtitleLabel);
+ return labels;
+ }
+
+ private _disableActor(actor: Clutter.Actor): void {
+ actor.opacity = 128;
+ const disable = (child: Clutter.Actor): void => {
+ child.reactive = false;
+ if (child instanceof St.Widget) child.can_focus = false;
+ for (const descendant of child.get_children()) disable(descendant);
+ };
+ disable(actor);
+ }
+}
diff --git a/src/dev/devToolUi.ts b/src/dev/devToolUi.ts
index 3b44ffc..6ad39f2 100644
--- a/src/dev/devToolUi.ts
+++ b/src/dev/devToolUi.ts
@@ -1,5 +1,17 @@
import St from '@girs/st-18';
import Clutter from '@girs/clutter-18';
+import Pango from '@girs/pango-1.0';
+import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu';
+
+import { gettext as _ } from '~/shared/i18n.ts';
+import { createIcon } from '~/shared/icons.ts';
+
+export type DevToolButtonVariant = 'suggested' | 'destructive' | 'flat';
+
+export type DevToolGroup = {
+ container: St.BoxLayout;
+ list: St.BoxLayout;
+};
export function createDevToolModulePanel(): St.BoxLayout {
return new St.BoxLayout({
@@ -35,12 +47,146 @@ export function createDevToolActionRow(): St.BoxLayout {
});
}
+export function createDevToolGroup(title?: string, description?: string): DevToolGroup {
+ const container = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-group',
+ x_expand: true,
+ });
+ if (title) {
+ container.add_child(
+ new St.Label({
+ text: title,
+ style_class: 'aurora-devtool-group-title',
+ }),
+ );
+ }
+ if (description) {
+ const descriptionLabel = new St.Label({
+ text: description,
+ style_class: 'aurora-devtool-group-description',
+ x_expand: true,
+ });
+ descriptionLabel.clutter_text.line_wrap = true;
+ container.add_child(descriptionLabel);
+ }
+
+ const list = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-group-list',
+ x_expand: true,
+ });
+ container.add_child(list);
+ return { container, list };
+}
+
+export function createDevToolRow(
+ title: string,
+ subtitle = '',
+ suffix?: St.Widget,
+ iconName?: string,
+ activatable = false,
+): St.BoxLayout {
+ const row = new St.BoxLayout({
+ style_class: activatable ? 'aurora-devtool-row activatable' : 'aurora-devtool-row',
+ x_expand: true,
+ reactive: activatable,
+ track_hover: activatable,
+ });
+ if (iconName)
+ row.add_child(
+ new St.Icon({
+ icon_name: iconName,
+ icon_size: 18,
+ style_class: 'aurora-devtool-row-icon',
+ }),
+ );
+
+ const labels = new St.BoxLayout({
+ orientation: Clutter.Orientation.VERTICAL,
+ style_class: 'aurora-devtool-row-labels',
+ x_expand: true,
+ });
+ labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' }));
+ if (subtitle) {
+ const subtitleLabel = new St.Label({
+ text: subtitle,
+ style_class: 'aurora-devtool-row-subtitle',
+ x_expand: true,
+ });
+ subtitleLabel.clutter_text.line_wrap = true;
+ subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
+ subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
+ labels.add_child(subtitleLabel);
+ }
+ row.add_child(labels);
+ if (suffix) row.add_child(suffix);
+ return row;
+}
+
+export function createDevToolSwitch(
+ active: boolean,
+ onToggle: (active: boolean) => void,
+ accessibleName: string,
+ focusId = accessibleName,
+): St.Button {
+ const control = new PopupMenu.Switch(active);
+ control.reactive = false;
+ const button = new St.Button({
+ child: control,
+ style_class: 'button aurora-devtool-switch-button',
+ can_focus: true,
+ toggle_mode: true,
+ checked: active,
+ name: `aurora-devtool-focus-${focusId}`,
+ accessible_name: `${accessibleName}: ${active ? _('On') : _('Off')}`,
+ });
+ button.connect('clicked', () => control.toggle());
+ control.connect('notify::state', () => {
+ button.checked = control.state;
+ button.accessible_name = `${accessibleName}: ${control.state ? _('On') : _('Off')}`;
+ onToggle(control.state);
+ });
+ return button;
+}
+
+export function createDevToolButton(
+ label: string,
+ onClick: () => void,
+ iconName?: string,
+ accessibleName = label,
+ active = false,
+ variant?: DevToolButtonVariant,
+ focusId = accessibleName,
+ toggleMode = false,
+): St.Button {
+ const content = new St.BoxLayout({ style_class: 'aurora-devtool-button-content' });
+ if (iconName) content.add_child(createIcon(iconName, { icon_size: 16 }));
+ content.add_child(new St.Label({ text: label }));
+ const button = new St.Button({
+ child: content,
+ style_class: variant
+ ? `button aurora-devtool-button ${variant}`
+ : 'button aurora-devtool-button',
+ can_focus: true,
+ accessible_name: accessibleName,
+ name: `aurora-devtool-focus-${focusId}`,
+ toggle_mode: toggleMode,
+ checked: active,
+ });
+ button.connect('clicked', onClick);
+ return button;
+}
+
export function createDevToolActionButton(
iconName: string,
label: string,
onClick: () => void,
disabled = false,
active = false,
+ variant?: DevToolButtonVariant,
+ focusId = label,
+ toggleMode = false,
): St.Button {
const content = new St.BoxLayout({
style_class: 'aurora-devtool-action-content',
@@ -57,13 +203,18 @@ export function createDevToolActionButton(
child: content,
style_class: active
? 'button aurora-devtool-action-button active'
- : 'button aurora-devtool-action-button',
+ : variant
+ ? `button aurora-devtool-action-button ${variant}`
+ : 'button aurora-devtool-action-button',
can_focus: !disabled,
reactive: !disabled,
x_expand: true,
accessible_name: label,
+ name: `aurora-devtool-focus-${focusId}`,
+ toggle_mode: toggleMode,
+ checked: active,
});
- if (disabled) button.opacity = 120;
+ if (disabled) button.opacity = 115;
button.connect('clicked', onClick);
return button;
}
diff --git a/src/dev/dockDevTool.ts b/src/dev/dockDevTool.ts
index 5bf1d20..819426b 100644
--- a/src/dev/dockDevTool.ts
+++ b/src/dev/dockDevTool.ts
@@ -61,7 +61,14 @@ export class DockDevTool {
const firstRow = createDevToolActionRow();
firstRow.add_child(
- createDevToolActionButton('go-up-symbolic', 'Reveal All', () => this.revealAll(), !dock),
+ createDevToolActionButton(
+ 'go-up-symbolic',
+ 'Reveal All',
+ () => this.revealAll(),
+ !dock,
+ false,
+ 'suggested',
+ ),
);
firstRow.add_child(
createDevToolActionButton('go-down-symbolic', 'Hide All', () => this.hideAll(), !dock),
@@ -83,6 +90,9 @@ export class DockDevTool {
`Always Show: ${dock?.alwaysShow ? 'On' : 'Off'}`,
() => this.toggleAlwaysShow(),
!dock,
+ false,
+ undefined,
+ 'dock-always-show',
),
);
panel.add_child(secondRow);
@@ -237,13 +247,25 @@ export class DockDevTool {
const actions = createDevToolActionRow();
actions.add_child(
- createDevToolActionButton('go-up-symbolic', 'Show', () =>
- this.showMonitor(binding.monitorIndex),
+ createDevToolActionButton(
+ 'go-up-symbolic',
+ 'Show',
+ () => this.showMonitor(binding.monitorIndex),
+ false,
+ false,
+ undefined,
+ `dock-monitor-${binding.monitorIndex}-show`,
),
);
actions.add_child(
- createDevToolActionButton('go-down-symbolic', 'Hide', () =>
- this.hideMonitor(binding.monitorIndex),
+ createDevToolActionButton(
+ 'go-down-symbolic',
+ 'Hide',
+ () => this.hideMonitor(binding.monitorIndex),
+ false,
+ false,
+ undefined,
+ `dock-monitor-${binding.monitorIndex}-hide`,
),
);
actions.add_child(
@@ -252,6 +274,9 @@ export class DockDevTool {
'Hot Area',
() => this.triggerMonitorHotArea(binding.monitorIndex),
!binding.hotArea,
+ false,
+ undefined,
+ `dock-monitor-${binding.monitorIndex}-hot-area`,
),
);
panel.add_child(actions);
diff --git a/src/dev/generalDevTool.ts b/src/dev/generalDevTool.ts
index a82cd63..90c99f4 100644
--- a/src/dev/generalDevTool.ts
+++ b/src/dev/generalDevTool.ts
@@ -1,28 +1,62 @@
import '@girs/gjs';
import type St from '@girs/st-18';
+import GLib from '@girs/glib-2.0';
+import * as Config from '@girs/gnome-shell/misc/config';
+import * as Main from '@girs/gnome-shell/ui/main';
import {
createDevToolActionButton,
createDevToolActionRow,
+ createDevToolGroup,
createDevToolModulePanel,
+ createDevToolRow,
createDevToolSummary,
} from '~/dev/devToolUi.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
export class GeneralDevTool {
readonly key = 'general';
readonly title = 'General';
readonly iconName = 'emblem-system-symbolic';
- constructor(private readonly _openPreferences: () => void) {}
+ constructor(
+ private readonly _openPreferences: () => void,
+ private readonly _closeDevTool: () => void,
+ private readonly _versionName: string,
+ private readonly _enabledModules: () => number,
+ ) {}
buildPanel(): St.Widget {
const panel = createDevToolModulePanel();
- panel.add_child(createDevToolSummary(this.iconName, 'Extension tools'));
+ panel.add_child(createDevToolSummary(this.iconName, `Aurora Shell ${this._versionName}`));
+
+ const environment = createDevToolGroup(_('Environment'));
+ environment.list.add_child(createDevToolRow(_('GNOME Shell'), Config.PACKAGE_VERSION));
+ environment.list.add_child(
+ createDevToolRow(_('Session'), GLib.getenv('XDG_SESSION_TYPE') || _('Unknown')),
+ );
+ environment.list.add_child(
+ createDevToolRow(_('Enabled modules'), String(this._enabledModules())),
+ );
+ panel.add_child(environment.container);
const row = createDevToolActionRow();
row.add_child(
- createDevToolActionButton('emblem-system-symbolic', 'Open Settings', this._openPreferences),
+ createDevToolActionButton(
+ 'emblem-system-symbolic',
+ _('Open Settings'),
+ this._openPreferences,
+ false,
+ false,
+ 'suggested',
+ ),
+ );
+ row.add_child(
+ createDevToolActionButton('utilities-terminal-symbolic', _('Looking Glass'), () => {
+ this._closeDevTool();
+ Main.createLookingGlass().open();
+ }),
);
panel.add_child(row);
diff --git a/src/dev/trayIconsDevTool.ts b/src/dev/trayIconsDevTool.ts
index bb6fded..bf565e2 100644
--- a/src/dev/trayIconsDevTool.ts
+++ b/src/dev/trayIconsDevTool.ts
@@ -6,10 +6,13 @@ import * as Main from '@girs/gnome-shell/ui/main';
import {
createDevToolActionButton,
createDevToolActionRow,
+ createDevToolGroup,
createDevToolModulePanel,
+ createDevToolRow,
createDevToolSummary,
} from '~/dev/devToolUi.ts';
import type { TrayItem } from '~/desktop/trayIcons/trayState.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
const TRAY_ID = 'aurora-tray-icons';
const FAKE_ICON_NAMES = [
@@ -66,6 +69,9 @@ export class TrayIconsDevTool {
() => this.toggleAttentionOnAll(),
!hasFakeItems,
this._attentionEnabled,
+ undefined,
+ 'tray-attention',
+ true,
),
);
panel.add_child(primaryRow);
@@ -81,6 +87,20 @@ export class TrayIconsDevTool {
);
panel.add_child(secondaryRow);
+ if (hasFakeItems) {
+ const preview = createDevToolGroup(_('Fake tray preview'));
+ for (const item of this._fakeItems.values())
+ preview.list.add_child(
+ createDevToolRow(
+ item.tooltip || item.id,
+ item.status || _('Active'),
+ undefined,
+ typeof item.icon === 'string' ? item.icon : undefined,
+ ),
+ );
+ panel.add_child(preview.container);
+ }
+
if (!tray) {
for (const row of [primaryRow, secondaryRow]) {
for (const button of row.get_children()) {
diff --git a/src/dev/weatherClockDevTool.ts b/src/dev/weatherClockDevTool.ts
index 89f4e31..3994880 100644
--- a/src/dev/weatherClockDevTool.ts
+++ b/src/dev/weatherClockDevTool.ts
@@ -10,6 +10,7 @@ import {
} from '~/dev/devToolUi.ts';
import type { Module } from '~/module.ts';
import { WeatherClock } from '~/panel/clock/weatherClock/weatherClock.ts';
+import { gettext as _ } from '~/shared/i18n.ts';
const DEVTOOL_SOURCE_KEY = 'aurora-devtool';
@@ -18,6 +19,9 @@ export class WeatherClockDevTool {
readonly title = 'Weather Clock';
readonly iconName = 'weather-clear-symbolic';
+ private _preview = _('No fake weather');
+ private _previewOwner: WeatherClock | null = null;
+
constructor(
private readonly _getModule: (key: string) => Module | null,
private readonly _requestMenuRebuild: () => void,
@@ -25,6 +29,10 @@ export class WeatherClockDevTool {
buildPanel(): St.Widget {
const weatherClock = this._getWeatherClock();
+ if (weatherClock !== this._previewOwner) {
+ this._preview = _('No fake weather');
+ this._previewOwner = null;
+ }
const panel = createDevToolModulePanel();
panel.add_child(
createDevToolSummary(
@@ -34,6 +42,7 @@ export class WeatherClockDevTool {
: 'Weather Clock disabled',
),
);
+ panel.add_child(createDevToolSummary('weather-severe-alerts-symbolic', this._preview));
const firstRow = createDevToolActionRow();
firstRow.add_child(
@@ -92,37 +101,41 @@ export class WeatherClockDevTool {
}
showSunny(): boolean {
- return this._setSnapshot({
- iconName: 'weather-clear-symbolic',
- temperature: '24°',
- description: 'Clear sky',
- });
+ return this._setSnapshot(
+ {
+ iconName: 'weather-clear-symbolic',
+ temperature: '24°',
+ description: 'Clear sky',
+ },
+ '24° · Clear sky',
+ );
}
showRain(): boolean {
- return this._setSnapshot({
- iconName: 'weather-showers-symbolic',
- temperature: '18°',
- description: 'Rain showers',
- });
+ return this._setSnapshot(
+ {
+ iconName: 'weather-showers-symbolic',
+ temperature: '18°',
+ description: 'Rain showers',
+ },
+ '18° · Rain showers',
+ );
}
showOffline(): boolean {
- return this._setSnapshot({
- hasConnectivity: false,
- });
+ return this._setSnapshot({ hasConnectivity: false }, _('Offline'));
}
showUnavailable(): boolean {
- return this._setSnapshot({
- available: false,
- });
+ return this._setSnapshot({ available: false }, _('Weather service unavailable'));
}
clearWeather(): void {
const weatherClock = this._getWeatherClock();
if (weatherClock) weatherClock.clearWeatherSnapshot(DEVTOOL_SOURCE_KEY);
+ this._preview = _('No fake weather');
+ this._previewOwner = null;
this._requestMenuRebuild();
}
@@ -133,11 +146,16 @@ export class WeatherClockDevTool {
return weatherClock.isVisible;
}
- private _setSnapshot(snapshot: Parameters[1]): boolean {
+ private _setSnapshot(
+ snapshot: Parameters[1],
+ preview: string,
+ ): boolean {
const weatherClock = this._getWeatherClock();
if (!weatherClock) return false;
weatherClock.setWeatherSnapshot(DEVTOOL_SOURCE_KEY, snapshot);
+ this._preview = preview;
+ this._previewOwner = weatherClock;
this._requestMenuRebuild();
return true;
}
diff --git a/src/device/device.ts b/src/device/device.ts
index 16e5e16..b3bad9e 100644
--- a/src/device/device.ts
+++ b/src/device/device.ts
@@ -1,7 +1,7 @@
import Clutter from '@girs/clutter-18';
import Gio from '@girs/gio-2.0';
-import GLib from '@girs/glib-2.0';
import * as Main from '@girs/gnome-shell/ui/main';
+import Meta from '@girs/meta-18';
import type { RuntimeCapability } from '~/module.ts';
import {
@@ -26,8 +26,6 @@ const SENSOR_DBUS_NAME = 'net.hadess.SensorProxy';
const SENSOR_PATH = '/net/hadess/SensorProxy';
const SENSOR_IFACE = 'net.hadess.SensorProxy';
const MODEM_MANAGER_NAME = 'org.freedesktop.ModemManager1';
-const DISPLAY_CONFIG_NAME = 'org.gnome.Mutter.DisplayConfig';
-const DISPLAY_CONFIG_PATH = '/org/gnome/Mutter/DisplayConfig';
export class DefaultDeviceService implements DeviceService {
private readonly _listeners = new Set();
@@ -35,22 +33,43 @@ export class DefaultDeviceService implements DeviceService {
private _monitorChangedId: number | null;
private _deviceAddedId: number | null;
private _deviceRemovedId: number | null;
- private _nameWatchIds: number[] | null;
+ private _sensorWatchId: number | null = null;
+ private _modemManagerWatchId: number | null = null;
+ private _sensorOwner: string | null = null;
+ private _modemManagerOwned = false;
+ private _sensorProxyRequest: Gio.Cancellable | null = null;
+ private _sensorProxy: Gio.DBusProxy | null = null;
+ private _sensorPropertiesChangedId: number | null = null;
+ private _refreshLaterId: number | null = null;
private _snapshot: DeviceSnapshot;
constructor() {
this._snapshot = this._detect();
- this._monitorChangedId = Main.layoutManager.connect('monitors-changed', () => this.refresh());
- this._deviceAddedId = this._seat.connect('device-added', () => this.refresh());
- this._deviceRemovedId = this._seat.connect('device-removed', () => this.refresh());
- this._nameWatchIds = [SENSOR_DBUS_NAME, MODEM_MANAGER_NAME].map((name) =>
- Gio.bus_watch_name(
- Gio.BusType.SYSTEM,
- name,
- Gio.BusNameWatcherFlags.NONE,
- () => this.refresh(),
- () => this.refresh(),
- ),
+ this._monitorChangedId = Main.layoutManager.connect('monitors-changed', () =>
+ this._queueRefresh(),
+ );
+ this._deviceAddedId = this._seat.connect('device-added', () => this._queueRefresh());
+ this._deviceRemovedId = this._seat.connect('device-removed', () => this._queueRefresh());
+
+ this._sensorWatchId = Gio.bus_watch_name(
+ Gio.BusType.SYSTEM,
+ SENSOR_DBUS_NAME,
+ Gio.BusNameWatcherFlags.NONE,
+ (_connection, _name, owner) => this._sensorAppeared(owner),
+ () => this._sensorVanished(),
+ );
+ this._modemManagerWatchId = Gio.bus_watch_name(
+ Gio.BusType.SYSTEM,
+ MODEM_MANAGER_NAME,
+ Gio.BusNameWatcherFlags.NONE,
+ () => {
+ this._modemManagerOwned = true;
+ this._queueRefresh();
+ },
+ () => {
+ this._modemManagerOwned = false;
+ this._queueRefresh();
+ },
);
}
@@ -63,7 +82,8 @@ export class DefaultDeviceService implements DeviceService {
}
refresh(): DeviceSnapshot {
- if (!this._nameWatchIds) return this._snapshot;
+ if (this._sensorWatchId === null || this._modemManagerWatchId === null) return this._snapshot;
+
const next = this._detect();
if (!sameDeviceSnapshot(this._snapshot, next)) {
this._snapshot = next;
@@ -78,15 +98,32 @@ export class DefaultDeviceService implements DeviceService {
}
destroy(): void {
- if (!this._nameWatchIds) return;
+ if (this._sensorWatchId === null || this._modemManagerWatchId === null) return;
+
+ if (this._refreshLaterId !== null) {
+ global.compositor.get_laters().remove(this._refreshLaterId);
+ this._refreshLaterId = null;
+ }
+
+ Gio.bus_unwatch_name(this._sensorWatchId);
+ Gio.bus_unwatch_name(this._modemManagerWatchId);
+ this._sensorWatchId = null;
+ this._modemManagerWatchId = null;
+
+ this._sensorOwner = null;
+ this._modemManagerOwned = false;
+ if (this._sensorProxyRequest) {
+ this._sensorProxyRequest.cancel();
+ this._sensorProxyRequest = null;
+ }
+ this._clearSensorProxy();
+
if (this._monitorChangedId !== null) Main.layoutManager.disconnect(this._monitorChangedId);
if (this._deviceAddedId !== null) this._seat.disconnect(this._deviceAddedId);
if (this._deviceRemovedId !== null) this._seat.disconnect(this._deviceRemovedId);
- for (const id of this._nameWatchIds) Gio.bus_unwatch_name(id);
this._monitorChangedId = null;
this._deviceAddedId = null;
this._deviceRemovedId = null;
- this._nameWatchIds = null;
this._listeners.clear();
}
@@ -127,71 +164,31 @@ export class DefaultDeviceService implements DeviceService {
const capabilities = new Set();
if (hasTouch) capabilities.add('touch');
if (this._hasBacklight()) capabilities.add('backlight');
- if (this._hasDBusNameOwner(MODEM_MANAGER_NAME)) capabilities.add('cellular');
+ if (this._modemManagerOwned) capabilities.add('cellular');
- const sensorProxy = this._getSensorProxy();
- if (sensorProxy) {
- if (this._getBooleanProperty(sensorProxy, 'HasAccelerometer'))
+ if (this._sensorProxy) {
+ if (this._getBooleanProperty(this._sensorProxy, 'HasAccelerometer'))
capabilities.add('accelerometer');
- if (this._getBooleanProperty(sensorProxy, 'HasAmbientLight'))
+ if (this._getBooleanProperty(this._sensorProxy, 'HasAmbientLight'))
capabilities.add('light-sensor');
- if (this._getBooleanProperty(sensorProxy, 'HasProximity'))
+ if (this._getBooleanProperty(this._sensorProxy, 'HasProximity'))
capabilities.add('proximity-sensor');
}
return capabilities;
}
private _detectBuiltinMonitorIndices(): ReadonlySet {
- try {
- const result = Gio.DBus.session.call_sync(
- DISPLAY_CONFIG_NAME,
- DISPLAY_CONFIG_PATH,
- DISPLAY_CONFIG_NAME,
- 'GetCurrentState',
- null,
- null,
- Gio.DBusCallFlags.NONE,
- 200,
- null,
- );
- return this._parseBuiltinMonitorIndices(result?.recursiveUnpack());
- } catch {
- return new Set();
- }
- }
-
- private _parseBuiltinMonitorIndices(state: unknown): ReadonlySet {
- if (!Array.isArray(state) || !Array.isArray(state[1]) || !Array.isArray(state[2]))
- return new Set();
-
- const builtinConnectors = new Set();
- for (const physical of state[1]) {
- if (!Array.isArray(physical) || !Array.isArray(physical[0])) continue;
- const connector = physical[0][0];
- const properties = physical[2];
- if (
- typeof connector === 'string' &&
- this._isRecord(properties) &&
- properties['is-builtin'] === true
- )
- builtinConnectors.add(connector);
- }
-
+ const logicalMonitors = global.backend.get_monitor_manager().get_logical_monitors();
const indices = new Set();
- for (const [index, logical] of state[2].entries()) {
- if (!Array.isArray(logical) || !Array.isArray(logical[5])) continue;
- const containsBuiltin = logical[5].some(
- (spec) => Array.isArray(spec) && builtinConnectors.has(spec[0]),
- );
- if (containsBuiltin) indices.add(index);
+ if (!logicalMonitors) return indices;
+
+ for (const logicalMonitor of logicalMonitors) {
+ if (logicalMonitor.get_monitors().some((monitor) => monitor.is_builtin()))
+ indices.add(logicalMonitor.get_number());
}
return indices;
}
- private _isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
- }
-
private _hasBacklight(): boolean {
try {
const dir = Gio.File.new_for_path('/sys/class/backlight');
@@ -210,43 +207,75 @@ export class DefaultDeviceService implements DeviceService {
}
}
- private _getSensorProxy(): Gio.DBusProxy | null {
- if (!this._hasDBusNameOwner(SENSOR_DBUS_NAME)) return null;
- try {
- return Gio.DBusProxy.new_for_bus_sync(
- Gio.BusType.SYSTEM,
- Gio.DBusProxyFlags.NONE,
- null,
- SENSOR_DBUS_NAME,
- SENSOR_PATH,
- SENSOR_IFACE,
- null,
- );
- } catch {
- return null;
- }
+ private _sensorAppeared(owner: string): void {
+ if (this._sensorWatchId === null) return;
+
+ this._sensorOwner = owner;
+ if (this._sensorProxyRequest) this._sensorProxyRequest.cancel();
+ this._sensorProxyRequest = new Gio.Cancellable();
+ this._clearSensorProxy();
+ this._queueRefresh();
+
+ const sensorProxyRequest = this._sensorProxyRequest;
+ Gio.DBusProxy.new_for_bus(
+ Gio.BusType.SYSTEM,
+ Gio.DBusProxyFlags.NONE,
+ null,
+ owner,
+ SENSOR_PATH,
+ SENSOR_IFACE,
+ sensorProxyRequest,
+ (_source, result) => {
+ let proxy: Gio.DBusProxy;
+ try {
+ proxy = Gio.DBusProxy.new_for_bus_finish(result);
+ } catch {
+ if (this._sensorProxyRequest === sensorProxyRequest) this._sensorProxyRequest = null;
+ return;
+ }
+
+ if (this._sensorProxyRequest !== sensorProxyRequest || this._sensorOwner !== owner) return;
+
+ this._sensorProxyRequest = null;
+ this._sensorProxy = proxy;
+ this._sensorPropertiesChangedId = proxy.connect('g-properties-changed', () =>
+ this._queueRefresh(),
+ );
+ this._queueRefresh();
+ },
+ );
}
- private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean {
- return Boolean(proxy.get_cached_property(propertyName)?.unpack());
+ private _sensorVanished(): void {
+ if (this._sensorWatchId === null) return;
+
+ this._sensorOwner = null;
+ if (this._sensorProxyRequest) {
+ this._sensorProxyRequest.cancel();
+ this._sensorProxyRequest = null;
+ }
+ this._clearSensorProxy();
+ this._queueRefresh();
}
- private _hasDBusNameOwner(name: string): boolean {
- try {
- const result = Gio.DBus.system.call_sync(
- 'org.freedesktop.DBus',
- '/org/freedesktop/DBus',
- 'org.freedesktop.DBus',
- 'NameHasOwner',
- new GLib.Variant('(s)', [name]),
- new GLib.VariantType('(b)'),
- Gio.DBusCallFlags.NONE,
- 200,
- null,
- );
- return Boolean(result?.get_child_value(0).unpack());
- } catch {
+ private _queueRefresh(): void {
+ if (this._sensorWatchId === null || this._refreshLaterId !== null) return;
+
+ this._refreshLaterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._refreshLaterId = null;
+ this.refresh();
return false;
- }
+ });
+ }
+
+ private _clearSensorProxy(): void {
+ if (this._sensorProxy && this._sensorPropertiesChangedId !== null)
+ this._sensorProxy.disconnect(this._sensorPropertiesChangedId);
+ this._sensorProxy = null;
+ this._sensorPropertiesChangedId = null;
+ }
+
+ private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean {
+ return Boolean(proxy.get_cached_property(propertyName)?.unpack());
}
}
diff --git a/src/dock/dock.ts b/src/dock/dock.ts
index d5392df..3a34b58 100644
--- a/src/dock/dock.ts
+++ b/src/dock/dock.ts
@@ -770,8 +770,9 @@ export class Dock extends Module {
private _enableHotAreaWhenDockHidden(binding: ManagedDockBinding): void {
this._clearHotAreaEnable(binding);
+ // Polls while the dash stays visible; idle priority so it can never starve redraws.
binding.hotAreaEnable.replace(() =>
- GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, 100, () => {
if (binding.dash.visible) return GLib.SOURCE_CONTINUE;
binding.hotAreaEnable.complete();
diff --git a/src/dock/intellihide.ts b/src/dock/intellihide.ts
index b171cf1..bbebf00 100644
--- a/src/dock/intellihide.ts
+++ b/src/dock/intellihide.ts
@@ -43,9 +43,11 @@ const OVERLAP_WINDOW_TYPES: Meta.WindowType[] = [
Meta.WindowType.SPLASHSCREEN,
];
-const TRACKED_WINDOW_SIGNALS = [
- 'position-changed',
- 'size-changed',
+/** High-frequency signals during interactive drag/resize — coalesced per frame. */
+const GEOMETRY_SIGNALS = ['position-changed', 'size-changed'] as const;
+
+/** Discrete state transitions — checked immediately. */
+const STATE_SIGNALS = [
'workspace-changed',
'notify::fullscreen',
'notify::main-monitor',
@@ -53,7 +55,6 @@ const TRACKED_WINDOW_SIGNALS = [
'notify::maximized-vertically',
'notify::minimized',
'notify::on-all-workspaces',
- 'notify::title',
] as const;
export enum OverlapStatus {
@@ -77,6 +78,9 @@ export const DockIntellihide = GObject.registerClass(
declare private _trackedWindowActors: Set;
declare private _trackedWindows: Set;
declare private _queuedRefreshes: ManagedTimeoutBatch;
+ private _dirty = false;
+ private _dirtyReason = 'unspecified';
+ private _dirtyLaterId: number | null = null;
override _init(monitorIndex: number) {
super._init();
@@ -150,6 +154,10 @@ export const DockIntellihide = GObject.registerClass(
destroy(): void {
this._cancelPendingStatus();
+ if (this._dirtyLaterId !== null) {
+ global.compositor.get_laters().remove(this._dirtyLaterId);
+ this._dirtyLaterId = null;
+ }
this._lifecycle.dispose();
this._clearTrackedWindows();
global.display.disconnectObject(this);
@@ -159,7 +167,43 @@ export const DockIntellihide = GObject.registerClass(
Main.overview.disconnectObject(this);
}
+ /**
+ * Coalesce high-frequency window geometry signals into a single
+ * `_checkOverlap` per render frame via `Meta.LaterType.BEFORE_REDRAW`.
+ *
+ * During interactive window drag/resize, `position-changed`,
+ * `size-changed`, and `notify::allocation` fire on every pixel of
+ * movement. Without coalescing, each fires a full `_checkOverlap`
+ * (enumerating all window actors, filtering, querying geometries) —
+ * hundreds of times per second.
+ *
+ * `BEFORE_REDRAW` aligns the check with Mutter's frame boundary,
+ * matching how GNOME Shell coalesces region updates in `layout.js`.
+ */
+ private _markDirty(reason: string): void {
+ this._dirtyReason = reason;
+ if (this._dirty) return;
+ this._dirty = true;
+
+ if (this._dirtyLaterId !== null) return;
+ this._dirtyLaterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => {
+ this._dirtyLaterId = null;
+ if (this._dirty) {
+ this._dirty = false;
+ this._checkOverlap(this._dirtyReason);
+ }
+ return false;
+ });
+ global.stage.queue_redraw();
+ }
+
private _checkOverlap(reason = 'unspecified', force = false): void {
+ if (this._dirtyLaterId !== null) {
+ global.compositor.get_laters().remove(this._dirtyLaterId);
+ this._dirtyLaterId = null;
+ }
+ this._dirty = false;
+
if (!this._isMonitorValid()) return;
if (Main.overview.visible) {
@@ -365,7 +409,7 @@ export const DockIntellihide = GObject.registerClass(
this._trackedWindowActors.add(actor);
actor.connectObject(
'notify::allocation',
- () => this._checkOverlap('window-allocation'),
+ () => this._markDirty('window-allocation'),
'destroy',
() => this._trackedWindowActors.delete(actor),
this,
@@ -389,7 +433,10 @@ export const DockIntellihide = GObject.registerClass(
private _connectTrackedWindow(win: Meta.Window): void {
const signalArgs: any[] = [];
- for (const signal of TRACKED_WINDOW_SIGNALS) {
+ for (const signal of GEOMETRY_SIGNALS) {
+ signalArgs.push(signal, () => this._markDirty(signal));
+ }
+ for (const signal of STATE_SIGNALS) {
signalArgs.push(signal, () => this._checkOverlap(signal));
}
signalArgs.push('unmanaged', () => this._trackedWindows.delete(win));
diff --git a/src/extension.dev.ts b/src/extension.dev.ts
index 723ac67..fe9e511 100644
--- a/src/extension.dev.ts
+++ b/src/extension.dev.ts
@@ -3,7 +3,7 @@ import '@girs/gjs';
import GLib from '@girs/glib-2.0';
import { Extension } from '@girs/gnome-shell/extensions/extension';
-import { logger } from '~/core/logger.ts';
+import { enableDebugLogging, logger } from '~/core/logger.ts';
import { ShellRuntime } from '~/core/shellRuntime.ts';
import { DevTool } from '~/dev/devTool.ts';
@@ -14,6 +14,8 @@ export default class AuroraShellDevelopmentExtension extends Extension {
private _devTool: DevTool | null = null;
override enable(): void {
+ enableDebugLogging();
+
const runtime = new ShellRuntime(this);
this._runtime = runtime;
runtime.start();
@@ -25,6 +27,9 @@ export default class AuroraShellDevelopmentExtension extends Extension {
this._devTool = new DevTool(context, {
getModule: (key) => runtime.getModule(key),
openPreferences: () => this.openPreferences(),
+ versionName: String(
+ this.metadata['version-name'] || this.metadata['version'] || 'Development',
+ ),
});
this._devTool.enable();
} catch (error) {
diff --git a/src/panel/clock/calendarReminders/calendarRemindersLogic.ts b/src/panel/clock/calendarReminders/calendarRemindersLogic.ts
index bab2010..ce99457 100644
--- a/src/panel/clock/calendarReminders/calendarRemindersLogic.ts
+++ b/src/panel/clock/calendarReminders/calendarRemindersLogic.ts
@@ -20,6 +20,17 @@ export type CalendarPanelPresentation = {
isInProgress: boolean;
} | null;
+export function removeCalendarEventsByPrefix(
+ eventsById: Map,
+ prefix: string,
+): boolean {
+ let changed = false;
+ for (const id of eventsById.keys()) {
+ if (id.startsWith(prefix)) changed = eventsById.delete(id) || changed;
+ }
+ return changed;
+}
+
const MAX_PANEL_TITLE_LENGTH = 24;
function _deepUnpack(value: unknown): unknown {
diff --git a/src/panel/clock/calendarReminders/calendarServerBackend.ts b/src/panel/clock/calendarReminders/calendarServerBackend.ts
index b21251b..42d308f 100644
--- a/src/panel/clock/calendarReminders/calendarServerBackend.ts
+++ b/src/panel/clock/calendarReminders/calendarServerBackend.ts
@@ -6,18 +6,26 @@ import GLib from '@girs/glib-2.0';
import { logger } from '~/core/logger.ts';
import { LifecycleScope } from '~/core/lifecycleScope.ts';
-import { normalizeCalendarServerEvent, type CalendarEvent } from './calendarRemindersLogic.ts';
+import {
+ normalizeCalendarServerEvent,
+ removeCalendarEventsByPrefix,
+ type CalendarEvent,
+} from './calendarRemindersLogic.ts';
const LOG_PREFIX = 'CalendarReminders';
const CALENDAR_SERVER_BUS_NAME = 'org.gnome.Shell.CalendarServer';
const CALENDAR_SERVER_OBJECT_PATH = '/org/gnome/Shell/CalendarServer';
const CALENDAR_SERVER_INTERFACE = 'org.gnome.Shell.CalendarServer';
+const DBUS_TIMEOUT_MS = 10_000;
type EventsChangedCallback = (events: CalendarEvent[]) => void;
export class CalendarServerBackend {
private _proxy: Gio.DBusProxy | null = null;
private _lifecycle: LifecycleScope | null = null;
+ private _cancellable: Gio.Cancellable | null = null;
+ private _requestCancellable: Gio.Cancellable | null = null;
+ private _refreshWindowHours: number | null = null;
private _eventsById = new Map();
private _onEventsChanged: EventsChangedCallback;
@@ -26,67 +34,143 @@ export class CalendarServerBackend {
}
start(): void {
- if (this._proxy) return;
+ if (this._lifecycle) return;
+
+ const lifecycle = new LifecycleScope();
+ const cancellable = new Gio.Cancellable();
+ const proxy = new Gio.DBusProxy({
+ g_connection: Gio.DBus.session,
+ g_interface_name: CALENDAR_SERVER_INTERFACE,
+ g_name: CALENDAR_SERVER_BUS_NAME,
+ g_object_path: CALENDAR_SERVER_OBJECT_PATH,
+ });
+ lifecycle.onDispose(() => cancellable.cancel());
+ this._lifecycle = lifecycle;
+ this._cancellable = cancellable;
+
+ void this._initializeProxy(proxy, lifecycle, cancellable);
+ }
+
+ private async _initializeProxy(
+ proxy: Gio.DBusProxy,
+ lifecycle: LifecycleScope,
+ cancellable: Gio.Cancellable,
+ ): Promise {
+ let loaded = false;
+
try {
- this._proxy = Gio.DBusProxy.new_for_bus_sync(
- Gio.BusType.SESSION,
- Gio.DBusProxyFlags.NONE,
- null,
- CALENDAR_SERVER_BUS_NAME,
- CALENDAR_SERVER_OBJECT_PATH,
- CALENDAR_SERVER_INTERFACE,
- null,
- );
+ await proxy.init_async(GLib.PRIORITY_DEFAULT, cancellable);
+ loaded = true;
} catch (e) {
- logger.warn(`Failed to connect to CalendarServer: ${e}`, { prefix: LOG_PREFIX });
- this._proxy = null;
- return;
+ if (this._lifecycle !== lifecycle) return;
+
+ const timedOut = e instanceof GLib.Error && e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT);
+ if (!timedOut) {
+ logger.warn(`Failed to connect to CalendarServer: ${e}`, { prefix: LOG_PREFIX });
+ this.stop();
+ return;
+ }
}
- this._lifecycle = new LifecycleScope();
- this._lifecycle.connect(this._proxy, 'g-signal', (_proxy, _senderName, signalName, params) => {
+ if (this._lifecycle !== lifecycle) return;
+
+ this._proxy = proxy;
+ lifecycle.connect(proxy, 'g-signal', (_proxy, _senderName, signalName, params) => {
+ if (this._lifecycle !== lifecycle) return;
+
if (signalName === 'EventsAddedOrUpdated') {
- if (!this._proxy) return;
const [rawEvents = []] = params.deepUnpack() as [unknown[]?];
this._onEventsAddedOrUpdated(rawEvents);
} else if (signalName === 'EventsRemoved') {
- if (!this._proxy) return;
const [rawIds = []] = params.deepUnpack() as [string[]?];
this._onEventsRemoved(rawIds);
+ } else if (signalName === 'ClientDisappeared') {
+ const [sourceUid = ''] = params.deepUnpack() as [string?];
+ this._removeMatching(sourceUid + '\n');
}
});
+ lifecycle.connect(proxy, 'notify::g-name-owner', () => {
+ if (this._lifecycle !== lifecycle) return;
+
+ if (proxy.g_name_owner) this._handleServiceAppeared(lifecycle);
+ else this._handleServiceVanished();
+ });
+
+ if (loaded) this._handleServiceAppeared(lifecycle);
}
stop(): void {
+ if (this._requestCancellable) this._requestCancellable.cancel();
+ this._requestCancellable = null;
this._lifecycle?.dispose();
this._lifecycle = null;
+ this._cancellable = null;
this._proxy = null;
+ this._refreshWindowHours = null;
this._eventsById.clear();
}
refresh(windowHours: number): void {
+ if (!this._lifecycle) return;
+
+ this._refreshWindowHours = windowHours;
if (!this._proxy) return;
+ this._requestTimeRange(windowHours, this._lifecycle);
+ }
+
+ private async _requestTimeRange(windowHours: number, lifecycle: LifecycleScope): Promise {
+ if (!this._proxy || !this._cancellable) return;
+
+ const proxy = this._proxy;
+ if (this._requestCancellable) this._requestCancellable.cancel();
+ const requestCancellable = new Gio.Cancellable();
+ this._requestCancellable = requestCancellable;
const sinceEpochSeconds = Math.floor(Date.now() / 1000) - 3600;
const untilEpochSeconds = sinceEpochSeconds + Math.max(1, windowHours) * 3600;
try {
- this._proxy.call_sync(
+ await proxy.call(
'SetTimeRange',
GLib.Variant.new('(xxb)', [sinceEpochSeconds, untilEpochSeconds, true]),
Gio.DBusCallFlags.NONE,
- -1,
- null,
+ DBUS_TIMEOUT_MS,
+ requestCancellable,
);
} catch (e) {
+ if (this._lifecycle !== lifecycle || requestCancellable.is_cancelled()) return;
+
logger.warn(`Failed to refresh CalendarServer events: ${e}`, { prefix: LOG_PREFIX });
+ } finally {
+ if (this._requestCancellable === requestCancellable) this._requestCancellable = null;
}
}
+ private _handleServiceAppeared(lifecycle: LifecycleScope): void {
+ this._eventsById.clear();
+ this._emitEventsChanged();
+
+ if (this._refreshWindowHours !== null)
+ this._requestTimeRange(this._refreshWindowHours, lifecycle);
+ }
+
+ private _handleServiceVanished(): void {
+ this._eventsById.clear();
+ this._emitEventsChanged();
+ }
+
private _onEventsAddedOrUpdated(rawEvents: unknown[]): void {
+ const clearedRecurringEvents = new Set();
for (const rawEvent of rawEvents) {
const event = normalizeCalendarServerEvent(rawEvent);
if (!event) continue;
+ if (!event.id.endsWith('\n')) {
+ const parentId = event.id.slice(0, event.id.lastIndexOf('\n') + 1);
+ if (!clearedRecurringEvents.has(parentId)) {
+ clearedRecurringEvents.add(parentId);
+ this._removeMatching(parentId, false);
+ }
+ }
this._eventsById.set(event.id, event);
}
@@ -94,11 +178,16 @@ export class CalendarServerBackend {
}
private _onEventsRemoved(rawIds: string[]): void {
- for (const rawId of rawIds) this._eventsById.delete(String(rawId));
+ for (const rawId of rawIds) this._removeMatching(String(rawId), false);
this._emitEventsChanged();
}
+ private _removeMatching(prefix: string, emit = true): void {
+ const changed = removeCalendarEventsByPrefix(this._eventsById, prefix);
+ if (changed && emit) this._emitEventsChanged();
+ }
+
private _emitEventsChanged(): void {
if (!this._proxy) return;
diff --git a/src/patches/iconWeaveInspector.ts b/src/patches/iconWeaveInspector.ts
index 35bdf69..cd4252a 100644
--- a/src/patches/iconWeaveInspector.ts
+++ b/src/patches/iconWeaveInspector.ts
@@ -1,4 +1,4 @@
-import GioUnix from '@girs/giounix-2.0';
+import type GioUnix from '@girs/giounix-2.0';
import GLib from '@girs/glib-2.0';
import Meta from '@girs/meta-18';
import Shell from '@girs/shell-18';
@@ -7,9 +7,13 @@ import { LifecycleScope } from '~/core/lifecycleScope.ts';
import { logger } from '~/core/logger.ts';
import { createManagedSource } from '~/core/mainLoop.ts';
-import { normalize, scoreIconWeaveCandidate } from './iconWeaveScoring.ts';
+import {
+ createIconWeaveCandidateMetadata,
+ scoreIconWeaveCandidate,
+ type IconWeaveCandidateMetadata,
+} from './iconWeaveScoring.ts';
import type { NativeWindowAppResolver } from './iconWeavePatches.ts';
-import type { IconWeaveWindowRegistry } from './iconWeaveRegistry.ts';
+import { createIconWeaveResolutionKey, type IconWeaveWindowRegistry } from './iconWeaveRegistry.ts';
const WINDOW_INSPECT_DELAY_MS = 500;
const MIN_MATCH_SCORE = 50;
@@ -40,6 +44,7 @@ export class IconWeaveInspector {
private _lifecycle = new LifecycleScope();
private _windowScopes = new Map();
private _titleScopes = new Map();
+ private _candidateMetadata = new Map();
constructor(private _options: IconWeaveInspectorOptions) {}
@@ -47,6 +52,9 @@ export class IconWeaveInspector {
this._lifecycle.connect(global.display, 'window-created', (_display: any, window: any) => {
this._schedule(window);
});
+ this._lifecycle.connect(Shell.AppSystem.get_default(), 'installed-changed', () => {
+ this._handleInstalledChanged();
+ });
}
destroy(): void {
@@ -61,6 +69,7 @@ export class IconWeaveInspector {
scope.dispose();
}
this._windowScopes.clear();
+ this._candidateMetadata.clear();
}
private _schedule(window: any): void {
@@ -95,10 +104,26 @@ export class IconWeaveInspector {
);
}
+ private _handleInstalledChanged(): void {
+ this._candidateMetadata.clear();
+ this._options.registry.clearResolutions();
+
+ const previousApps = new Set();
+ for (const [window, app] of this._options.registry.mappings) {
+ previousApps.add(app);
+ this._options.registry.remove(window);
+ }
+ if (previousApps.size > 0) {
+ this._options.onMappingChanged();
+ Shell.WindowTracker.get_default().emit('tracked-windows-changed');
+ for (const app of previousApps) app.emit('windows-changed');
+ }
+
+ for (const window of this._windowScopes.keys()) this._matchWindow(window);
+ }
+
private _removeWindow(window: any): void {
- const wmClass: string = window.get_wm_class() || '';
- const appId: string = window.get_gtk_application_id() || '';
- this._options.registry.remove(window, wmClass, appId);
+ this._options.registry.remove(window);
this._titleScopes.get(window)?.dispose();
this._titleScopes.delete(window);
@@ -163,18 +188,24 @@ export class IconWeaveInspector {
if (wmClass.toLowerCase() === appId.toLowerCase()) return;
const tracker = Shell.WindowTracker.get_default();
- const identity = wmClass || appId;
+ const title: string = window.get_title() || '';
+ const resolutionKey = createIconWeaveResolutionKey(wmClass, appId, title);
+
+ if (this._options.registry.hasResolved(resolutionKey)) {
+ const resolvedId = this._options.registry.getResolvedApp(resolutionKey);
+ if (resolvedId) {
+ const app = Shell.AppSystem.get_default().lookup_app(resolvedId);
+ if (app) {
+ this._applyMapping(window, app, tracker);
+ return;
+ }
- if (this._options.registry.hasProcessed(identity)) {
- const mappedApp = this._options.registry.findMappedApp(wmClass, appId);
- if (mappedApp) {
- this._applyMapping(window, mappedApp, tracker, false);
+ this._options.registry.removeResolution(resolutionKey);
+ } else {
+ return;
}
-
- return;
}
- const title: string = window.get_title() || '';
logger.log(`untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, {
prefix: LOG_PREFIX,
});
@@ -186,7 +217,7 @@ export class IconWeaveInspector {
prefix: LOG_PREFIX,
});
this._applyMapping(window, deterministic, tracker);
- this._options.registry.markProcessed(identity);
+ this._options.registry.setResolvedApp(resolutionKey, deterministic.get_id());
return;
}
@@ -201,11 +232,11 @@ export class IconWeaveInspector {
prefix: LOG_PREFIX,
});
this._applyMapping(window, candidate, tracker);
+ this._options.registry.setResolvedApp(resolutionKey, candidate.get_id());
} else {
logger.log(`no candidate found for wm_class="${wmClass}"`, { prefix: LOG_PREFIX });
+ this._options.registry.setResolvedApp(resolutionKey, null);
}
-
- this._options.registry.markProcessed(identity);
}
private _applyMapping(window: any, app: any, tracker: any, notifyApp = true): void {
@@ -278,7 +309,8 @@ export class IconWeaveInspector {
const app = appSystem.lookup_app(id);
if (!app) continue;
- const score = this._scoreCandidate(app, wmClass, appId, title);
+ const candidate = this._getCandidateMetadata(appInfo, id);
+ const score = this._scoreCandidate(candidate, wmClass, appId, title);
if (score <= bestScore) continue;
bestScore = score;
@@ -298,28 +330,26 @@ export class IconWeaveInspector {
return BLACKLISTED_PREFIXES.some((prefix) => normalizedClass.startsWith(prefix));
}
- private _scoreCandidate(app: any, wmClass: string, appId: string, title: string): number {
- const desktopId = (app.get_id() || '').toLowerCase().replace(/\.desktop$/, '');
- const appName = String(app.get_name() || '').toLowerCase();
+ private _getCandidateMetadata(appInfo: any, id: string): IconWeaveCandidateMetadata {
+ const cached = this._candidateMetadata.get(id);
+ if (cached) return cached;
- if (this._isSteamGame(app, wmClass)) return 99;
-
- return scoreIconWeaveCandidate({ desktopId, appName, wmClass, appId, title });
+ const desktopInfo = appInfo as GioUnix.DesktopAppInfo;
+ const metadata = createIconWeaveCandidateMetadata(
+ id,
+ String(appInfo.get_name() || ''),
+ desktopInfo.get_string('Exec') || '',
+ );
+ this._candidateMetadata.set(id, metadata);
+ return metadata;
}
- private _isSteamGame(app: any, wmClass: string): boolean {
- const info = GioUnix.DesktopAppInfo.new(app.get_id());
- const executable: string = info?.get_string('Exec') || '';
- const steamMatch = executable.match(/steam:\/\/rungameid\/(\d+)/);
- if (!steamMatch) return false;
-
- const gameId = steamMatch[1];
- const normalizedClass = normalize(wmClass);
- if (normalizedClass === `steamapp${gameId}`) return true;
-
- const appName = String(app.get_name() || '').toLowerCase();
- const words = appName.split(/[^a-z0-9]/).filter((word: string) => word.length > 0);
- const abbreviation = words.map((word: string) => word[0]).join('');
- return normalizedClass === abbreviation && abbreviation.length >= 2;
+ private _scoreCandidate(
+ candidate: IconWeaveCandidateMetadata,
+ wmClass: string,
+ appId: string,
+ title: string,
+ ): number {
+ return scoreIconWeaveCandidate({ candidate, wmClass, appId, title });
}
}
diff --git a/src/patches/iconWeaveRegistry.ts b/src/patches/iconWeaveRegistry.ts
index 3142fd2..beaeb23 100644
--- a/src/patches/iconWeaveRegistry.ts
+++ b/src/patches/iconWeaveRegistry.ts
@@ -1,53 +1,55 @@
+export function createIconWeaveResolutionKey(
+ wmClass: string,
+ appId: string,
+ title: string,
+): string {
+ return JSON.stringify([wmClass, appId, title]);
+}
+
+const MAX_RESOLVED_APPS = 256;
+
export class IconWeaveWindowRegistry {
private _mappings = new Map();
- private _processed = new Set();
+ private _resolvedApps = new Map();
get mappings(): ReadonlyMap {
return this._mappings;
}
- hasProcessed(identity: string): boolean {
- return this._processed.has(identity);
+ hasResolved(key: string): boolean {
+ return this._resolvedApps.has(key);
}
- markProcessed(identity: string): void {
- this._processed.add(identity);
+ getResolvedApp(key: string): string | null | undefined {
+ return this._resolvedApps.get(key);
}
- map(window: any, app: any): void {
- this._mappings.set(window, app);
+ setResolvedApp(key: string, desktopId: string | null): void {
+ if (!this._resolvedApps.has(key) && this._resolvedApps.size >= MAX_RESOLVED_APPS) {
+ const oldestKey = this._resolvedApps.keys().next().value;
+ if (oldestKey !== undefined) this._resolvedApps.delete(oldestKey);
+ }
+ this._resolvedApps.set(key, desktopId);
}
- findMappedApp(wmClass: string, appId: string): any | null {
- for (const [window, app] of this._mappings) {
- const sameWmClass = Boolean(wmClass && window.get_wm_class() === wmClass);
- const sameAppId = Boolean(appId && window.get_gtk_application_id() === appId);
+ removeResolution(key: string): void {
+ this._resolvedApps.delete(key);
+ }
- if (sameWmClass || sameAppId) return app;
- }
+ clearResolutions(): void {
+ this._resolvedApps.clear();
+ }
- return null;
+ map(window: any, app: any): void {
+ this._mappings.set(window, app);
}
- remove(window: any, wmClass: string, appId: string): void {
+ remove(window: any): void {
this._mappings.delete(window);
-
- const identity = wmClass || appId;
- if (!identity) return;
-
- const stillMapped = [...this._mappings.keys()].some(
- (mappedWindow) =>
- (wmClass && mappedWindow.get_wm_class() === wmClass) ||
- (appId && mappedWindow.get_gtk_application_id() === appId),
- );
-
- if (!stillMapped) {
- this._processed.delete(identity);
- }
}
clear(): void {
- this._processed.clear();
this._mappings.clear();
+ this._resolvedApps.clear();
}
}
diff --git a/src/patches/iconWeaveScoring.ts b/src/patches/iconWeaveScoring.ts
index e9dc3c7..2115084 100644
--- a/src/patches/iconWeaveScoring.ts
+++ b/src/patches/iconWeaveScoring.ts
@@ -1,6 +1,16 @@
-export type IconWeaveScoreInput = {
+export type IconWeaveCandidateMetadata = {
desktopId: string;
appName: string;
+ shortId: string;
+ normalizedDesktopId: string;
+ normalizedAppName: string;
+ normalizedShortId: string;
+ abbreviation: string;
+ steamGameId: string | null;
+};
+
+export type IconWeaveScoreInput = {
+ candidate: IconWeaveCandidateMetadata;
wmClass: string;
appId: string;
title: string;
@@ -28,61 +38,101 @@ export function unregisterIconWeaveWindow(
const SHORT_ID_MIN_COVERAGE = 0.45;
+export function createIconWeaveCandidateMetadata(
+ desktopId: string,
+ appName: string,
+ executable: string,
+): IconWeaveCandidateMetadata {
+ const normalizedId = desktopId.toLowerCase().replace(/\.desktop$/, '');
+ const normalizedName = appName.toLowerCase();
+ const shortId = normalizedId.split('.').pop() || normalizedId;
+ const words = normalizedName.split(/[^a-z0-9]/).filter((word) => word.length > 0);
+ const steamMatch = executable.match(/steam:\/\/rungameid\/(\d+)/);
+
+ return {
+ desktopId: normalizedId,
+ appName: normalizedName,
+ shortId,
+ normalizedDesktopId: normalize(normalizedId),
+ normalizedAppName: normalize(normalizedName),
+ normalizedShortId: normalize(shortId),
+ abbreviation: words.map((word) => word[0]).join(''),
+ steamGameId: steamMatch && steamMatch[1] ? steamMatch[1] : null,
+ };
+}
+
export function scoreIconWeaveCandidate(input: IconWeaveScoreInput): number {
- const desktopId = input.desktopId.toLowerCase().replace(/\.desktop$/, '');
- const appName = input.appName.toLowerCase();
- const shortId = desktopId.split('.').pop() || desktopId;
+ const candidate = input.candidate;
+ if (isIconWeaveSteamGame(candidate, input.wmClass)) return 99;
- if (isSubprocessClass(wmClassLower(input.wmClass), desktopId, shortId)) return 0;
+ if (isSubprocessClass(wmClassLower(input.wmClass), candidate.desktopId, candidate.shortId)) {
+ return 0;
+ }
let score = 0;
- const words = appName.split(/[^a-z0-9]/).filter((w) => w.length > 0);
- const abbreviation = words.map((w) => w[0]).join('');
-
const nWm = normalize(input.wmClass);
- const nAppName = normalize(appName);
- const nDesktopId = normalize(desktopId);
- const nShortId = normalize(shortId);
if (input.wmClass) {
const wm = wmClassLower(input.wmClass);
- if (desktopId === wm) score = Math.max(score, 93);
- if (desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80);
- if (wm.includes(desktopId) && desktopId.length >= 3) score = Math.max(score, 70);
- if (isSpecificShortIdMatch(shortId, wm)) score = Math.max(score, 66);
- if (appName === wm) score = Math.max(score, 85);
- if (appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60);
- if (wm.includes(appName) && appName.length >= 3) score = Math.max(score, 55);
-
- if (nWm === abbreviation && abbreviation.length >= 2) {
+ if (candidate.desktopId === wm) score = Math.max(score, 93);
+ if (candidate.desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80);
+ if (wm.includes(candidate.desktopId) && candidate.desktopId.length >= 3) {
+ score = Math.max(score, 70);
+ }
+ if (isSpecificShortIdMatch(candidate.shortId, wm)) score = Math.max(score, 66);
+ if (candidate.appName === wm) score = Math.max(score, 85);
+ if (candidate.appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60);
+ if (wm.includes(candidate.appName) && candidate.appName.length >= 3) {
+ score = Math.max(score, 55);
+ }
+
+ if (nWm === candidate.abbreviation && candidate.abbreviation.length >= 2) {
score = Math.max(score, 88);
}
- if (nAppName.includes(nWm) && nWm.length >= 3) score = Math.max(score, 62);
- if (nDesktopId.includes(nWm) && nWm.length >= 3) score = Math.max(score, 61);
+ if (candidate.normalizedAppName.includes(nWm) && nWm.length >= 3) {
+ score = Math.max(score, 62);
+ }
+ if (candidate.normalizedDesktopId.includes(nWm) && nWm.length >= 3) {
+ score = Math.max(score, 61);
+ }
}
if (input.appId) {
const aId = input.appId.toLowerCase();
const nAId = normalize(input.appId);
- if (desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75);
- if (nAId === abbreviation && abbreviation.length >= 2) score = Math.max(score, 88);
+ if (candidate.desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75);
+ if (nAId === candidate.abbreviation && candidate.abbreviation.length >= 2) {
+ score = Math.max(score, 88);
+ }
}
const tNorm = normalize(input.title);
if (tNorm && tNorm.length >= 3) {
- if (tNorm === nDesktopId) score = Math.max(score, 98);
- if (tNorm === nAppName) score = Math.max(score, 95);
- if (tNorm === nShortId) score = Math.max(score, 94);
- if (nAppName.includes(tNorm)) score = Math.max(score, 65);
- if (tNorm.includes(nDesktopId)) score = Math.max(score, 68);
+ if (tNorm === candidate.normalizedDesktopId) score = Math.max(score, 98);
+ if (tNorm === candidate.normalizedAppName) score = Math.max(score, 95);
+ if (tNorm === candidate.normalizedShortId) score = Math.max(score, 94);
+ if (candidate.normalizedAppName.includes(tNorm)) score = Math.max(score, 65);
+ if (tNorm.includes(candidate.normalizedDesktopId)) score = Math.max(score, 68);
}
return score;
}
+export function isIconWeaveSteamGame(
+ candidate: IconWeaveCandidateMetadata,
+ wmClass: string,
+): boolean {
+ if (!candidate.steamGameId) return false;
+
+ const normalizedClass = normalize(wmClass);
+ if (normalizedClass === `steamapp${candidate.steamGameId}`) return true;
+
+ return normalizedClass === candidate.abbreviation && candidate.abbreviation.length >= 2;
+}
+
export function normalize(str: string): string {
return (str || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
diff --git a/src/shared/ui/dashVisibility.ts b/src/shared/ui/dashVisibility.ts
index c11e8c5..e16abbd 100644
--- a/src/shared/ui/dashVisibility.ts
+++ b/src/shared/ui/dashVisibility.ts
@@ -206,8 +206,11 @@ export class DashVisibilityController {
return;
}
+ // Below Clutter's redraw priority: this used to poll at PRIORITY_DEFAULT for as long as
+ // a window blocked autohide, and once GC pressure made each tick outlast the 100 ms
+ // interval it was always ready and starved painting, freezing the whole screen.
this._autohideTimeout.replace(() =>
- GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTOHIDE_TIMEOUT, () => {
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, AUTOHIDE_TIMEOUT, () => {
if (!this._options.getContentActor()) {
this._autohideTimeout.complete();
return GLib.SOURCE_REMOVE;
@@ -218,18 +221,22 @@ export class DashVisibilityController {
return GLib.SOURCE_REMOVE;
}
+ const menuOpen = this._options.isMenuOpen();
const shouldHide = shouldHideDash({
target: this._target,
blocked: this._blockAutoHide,
hovered: this._hovered,
- menuOpen: this._options.isMenuOpen(),
+ menuOpen,
dragHeld: this._itemDragHold,
});
- if (!shouldHide) {
+ if (shouldHide) {
+ this.hide(true);
+ } else if (menuOpen) {
+ // A closing menu may not change hover, so nothing else would re-arm us.
+ // Blocking, hover and drag changes call updateAutoHide() themselves.
return GLib.SOURCE_CONTINUE;
}
- this.hide(true);
this._autohideTimeout.complete();
return GLib.SOURCE_REMOVE;
}),
diff --git a/src/styles/_devtool.scss b/src/styles/_devtool.scss
index ff89061..ab58fd6 100644
--- a/src/styles/_devtool.scss
+++ b/src/styles/_devtool.scss
@@ -1,86 +1,608 @@
+@use 'variables' as vars;
+
+// St parses only a single box-shadow layer, so the multi-layer prototype
+// shadows keep their dominant layer and the 0 0 0 1px outline becomes a border.
+$devtool-window-bg: #36363a;
+$devtool-card-bg: #46464b;
+$devtool-fg: #ffffff;
+$devtool-heading: #ffffff;
+$devtool-dim: rgba(255, 255, 255, 0.62);
+$devtool-separator: rgba(255, 255, 255, 0.08);
+$devtool-button-bg: rgba(255, 255, 255, 0.09);
+$devtool-button-bg-hover: rgba(255, 255, 255, 0.13);
+$devtool-button-bg-active: rgba(255, 255, 255, 0.18);
+$devtool-row-hover: rgba(255, 255, 255, 0.05);
+$devtool-row-active: rgba(255, 255, 255, 0.1);
+$devtool-entry-bg: rgba(255, 255, 255, 0.08);
+$devtool-switch-off: rgba(255, 255, 255, 0.2);
+$devtool-accent-soft: st-mix(-st-accent-color, transparent, 16%);
+$devtool-destructive-bg: #c01c28;
+$devtool-error: #ff7b63;
+$devtool-toast-bg: #e8e8e8;
+$devtool-toast-fg: #1a1a1a;
+$devtool-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.08);
+$devtool-outline-window: rgba(255, 255, 255, 0.08);
+
+@if vars.$variant == 'light' {
+ $devtool-window-bg: #fafafb;
+ $devtool-card-bg: #ffffff;
+ $devtool-fg: #222226;
+ $devtool-heading: #222226;
+ $devtool-dim: rgba(0, 0, 0, 0.55);
+ $devtool-separator: rgba(0, 0, 0, 0.09);
+ $devtool-button-bg: rgba(0, 0, 0, 0.07);
+ $devtool-button-bg-hover: rgba(0, 0, 0, 0.11);
+ $devtool-button-bg-active: rgba(0, 0, 0, 0.15);
+ $devtool-row-hover: rgba(0, 0, 0, 0.04);
+ $devtool-row-active: rgba(0, 0, 0, 0.08);
+ $devtool-entry-bg: rgba(0, 0, 0, 0.05);
+ $devtool-switch-off: rgba(0, 0, 0, 0.16);
+ $devtool-accent-soft: st-mix(-st-accent-color, transparent, 12%);
+ $devtool-destructive-bg: #e01b24;
+ $devtool-error: #c01c28;
+ $devtool-toast-bg: #1a1a1a;
+ $devtool-toast-fg: #ffffff;
+ $devtool-outline-window: rgba(0, 0, 0, 0.08);
+}
+
+// Shell popup-menu chrome hosting the card: strip the frame, the card below
+// carries its own radius/shadow. -arrow-rise/-boxpointer-gap hide the beak,
+// following the .background-menu pattern in gnome-shell-sass.
+.aurora-devtool-menu {
+ -arrow-rise: 0px;
+ -boxpointer-gap: 10px;
+
+ .popup-menu-content {
+ padding: 0;
+ border: none;
+ border-radius: 14px;
+ background-color: transparent;
+ box-shadow: none;
+ }
+}
+
.aurora-devtool-panel {
- width: 360px;
- padding: 8px;
- spacing: 8px;
+ width: 448px;
+ max-height: 690px;
+ spacing: 0;
+ padding: 0;
+ border: 1px solid $devtool-outline-window;
+ border-radius: 14px;
+ color: $devtool-fg;
+ background-color: $devtool-window-bg;
+ box-shadow: $devtool-shadow-card;
+ font-size: 15px;
}
+// Header (Adwaita header bar): BinLayout stack — .aurora-devtool-header-nav
+// holds the buttons, .aurora-devtool-header-labels overlays dead-center.
.aurora-devtool-header {
- spacing: 8px;
- padding: 2px 4px 0;
+ min-height: 47px;
+ border-bottom: 1px solid $devtool-separator;
+}
+
+.aurora-devtool-header-nav {
+ spacing: 6px;
+ padding: 0 8px;
+}
+
+.aurora-devtool-header-labels {
+ spacing: 0;
}
.aurora-devtool-title {
+ font-size: 15px;
font-weight: 700;
+ color: $devtool-heading;
+ text-align: center;
}
-.aurora-devtool-body {
- spacing: 10px;
+.aurora-devtool-subtitle {
+ font-size: 12px;
+ color: $devtool-dim;
+ text-align: center;
+}
+
+.aurora-devtool-header-button {
+ min-width: 34px;
+ min-height: 34px;
+ padding: 0;
+ border-radius: 8px;
+ color: $devtool-fg;
+ background-color: transparent;
+ box-shadow: none;
+ transition-duration: 120ms;
+
+ &:hover {
+ background-color: $devtool-button-bg;
+ }
+
+ &:active {
+ background-color: $devtool-button-bg-active;
+ }
+}
+
+.aurora-devtool-back-button {
+ padding: 0;
+}
+
+.aurora-devtool-view-switcher {
+ padding: 12px 16px 2px;
}
-.aurora-devtool-section-dropdown {
+.aurora-devtool-view-track {
spacing: 4px;
+ padding: 3px;
+ border-radius: 999px;
+ background-color: $devtool-entry-bg;
}
-.aurora-devtool-section-trigger,
-.aurora-devtool-section-option {
- border-radius: 8px;
- padding: 7px 9px;
- border: 1px solid transparent;
+.aurora-devtool-view-button {
+ padding: 7px 4px;
+ border-radius: 999px;
+ color: $devtool-dim;
+ background-color: transparent;
+ box-shadow: none;
+ font-size: 13.5px;
+ font-weight: 600;
+ transition-duration: 250ms;
- &:active,
&.active {
- border-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.35), 35%);
- background-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.12), 24%);
+ color: $devtool-heading;
+ background-color: $devtool-button-bg;
+ box-shadow: none;
}
}
-.aurora-devtool-section-menu {
- spacing: 4px;
- padding: 4px;
- border-radius: 8px;
- border: 1px solid rgba(127, 127, 127, 0.18);
- background-color: rgba(127, 127, 127, 0.08);
+.aurora-devtool-view-button-content {
+ spacing: 7px;
}
-.aurora-devtool-section-button-content {
- spacing: 7px;
+.aurora-devtool-search-box {
+ padding: 10px 16px 2px;
+}
+
+.aurora-devtool-search {
+ padding: 7px 14px;
+ border: 1px solid $devtool-separator;
+ border-radius: 9px;
+ background-color: $devtool-entry-bg;
+ font-size: 14px;
+
+ StLabel.hint-text {
+ color: $devtool-dim;
+ }
+}
+
+.aurora-devtool-search-icon {
+ color: $devtool-dim;
+}
+
+.aurora-devtool-scroll {
+ // card max-height 690px minus the 47px header; switcher/search shrink it further
+ max-height: 643px;
+ padding-top: 8px;
}
.aurora-devtool-content {
- spacing: 8px;
+ spacing: 0;
+ padding: 12px 16px 16px;
}
.aurora-devtool-module-panel {
- spacing: 8px;
+ spacing: 10px;
}
-.aurora-devtool-summary {
- spacing: 8px;
- padding: 7px 9px;
+.aurora-devtool-page-title {
+ margin: 6px 6px 2px;
+ font-size: 18px;
+ font-weight: 700;
+ color: $devtool-heading;
+}
+
+.aurora-devtool-page-subtitle {
+ margin: 0 6px 18px;
+ font-size: 13.5px;
+ color: $devtool-dim;
+}
+
+.aurora-devtool-group {
+ spacing: 0;
+ margin-bottom: 28px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.aurora-devtool-group-title {
+ margin: 0 14px 8px;
+ font-size: 14px;
+ font-weight: 700;
+ color: $devtool-heading;
+}
+
+.aurora-devtool-group-description {
+ // St parses negative margins as huge unsigned values.
+ margin: 0 14px 8px;
+ font-size: 13px;
+ color: $devtool-dim;
+}
+
+.aurora-devtool-group-list {
+ spacing: 0;
+ padding: 0;
+ border: 1px solid $devtool-separator;
+ border-radius: 12px;
+ background-color: $devtool-card-bg;
+
+ > * {
+ border-top: 1px solid $devtool-separator;
+ }
+
+ > *:first-child {
+ border-top: none;
+ border-radius-topleft: 12px;
+ border-radius-topright: 12px;
+ }
+
+ > *:last-child {
+ border-radius-bottomleft: 12px;
+ border-radius-bottomright: 12px;
+ }
+}
+
+.aurora-devtool-row,
+.aurora-devtool-module-row {
+ min-height: 52px;
+ spacing: 14px;
+ padding: 12px 14px;
+}
+
+.aurora-devtool-row.activatable {
+ transition-duration: 100ms;
+
+ &:hover {
+ background-color: $devtool-row-hover;
+ }
+
+ &:active {
+ background-color: $devtool-row-active;
+ }
+}
+
+.aurora-devtool-row-trailing {
+ spacing: 10px;
+}
+
+.aurora-devtool-row-icon {
+ color: $devtool-dim;
+}
+
+.aurora-devtool-row-title {
+ font-size: 15px;
+ color: $devtool-heading;
+}
+
+.aurora-devtool-row-subtitle {
+ // Reset: inherits bold from the `.button` ancestor on module/nav rows.
+ font-weight: 400;
+ font-size: 13px;
+ color: $devtool-dim;
+}
+
+.aurora-devtool-module-open {
+ padding: 0;
+ background-color: transparent;
+ box-shadow: none;
+
+ &:hover,
+ &:active {
+ background-color: transparent;
+ }
+}
+
+.aurora-devtool-navigation-content {
+ spacing: 14px;
+}
+
+.aurora-devtool-list-button {
+ background-color: transparent;
+ box-shadow: none;
+ border-radius: 0;
+ transition-duration: 100ms;
+
+ &:hover {
+ background-color: $devtool-row-hover;
+ }
+
+ &:active {
+ background-color: $devtool-row-active;
+ }
+}
+
+.aurora-devtool-chevron {
+ color: $devtool-dim;
+}
+
+.aurora-devtool-switch-button {
+ padding: 4px;
+ border-radius: 999px;
+ background-color: transparent;
+ box-shadow: none;
+
+ .toggle-switch {
+ background-color: $devtool-switch-off;
+ transition-duration: 180ms;
+
+ &:checked {
+ background-color: -st-accent-color;
+ }
+ }
+}
+
+.aurora-devtool-hero {
+ margin: 6px 10px 20px;
+}
+
+.aurora-devtool-hero-title {
+ font-size: 20px;
+ font-weight: 700;
+ color: $devtool-heading;
+}
+
+.aurora-devtool-hero-subtitle {
+ font-size: 13.5px;
+ color: $devtool-dim;
+}
+
+.aurora-devtool-banner {
+ min-height: 0;
+ spacing: 12px;
+ padding: 12px 16px;
+ background-color: $devtool-accent-soft;
+ border-bottom: 1px solid $devtool-separator;
+
+ .aurora-devtool-button {
+ padding: 6px 12px;
+ font-size: 13px;
+ }
+}
+
+.aurora-devtool-banner-label {
+ font-size: 14px;
+ color: $devtool-fg;
+}
+
+.aurora-devtool-button,
+.aurora-devtool-action-button {
+ padding: 7px 12px;
border-radius: 8px;
- border: 1px solid rgba(127, 127, 127, 0.16);
- background-color: rgba(127, 127, 127, 0.08);
+ color: $devtool-fg;
+ background-color: $devtool-button-bg;
+ font-size: 14px;
+ font-weight: 500;
+ transition-duration: 120ms;
+
+ &:hover {
+ background-color: $devtool-button-bg-hover;
+ }
+
+ &:active {
+ background-color: $devtool-button-bg-active;
+ }
+
+ // St has no filter: brightness(); the prototype's 1.07 hover lighten is
+ // approximated with a white st-mix.
+ &.suggested {
+ color: -st-accent-fg-color;
+ background-color: -st-accent-color;
+ font-weight: 700;
+
+ &:hover {
+ background-color: st-mix(-st-accent-color, #ffffff, 93%);
+ }
+ }
+
+ &.destructive {
+ color: #ffffff;
+ background-color: $devtool-destructive-bg;
+ font-weight: 700;
+
+ &:hover {
+ background-color: st-mix($devtool-destructive-bg, #ffffff, 92%);
+ }
+ }
+
+ &.flat {
+ background-color: transparent;
+
+ &:hover {
+ background-color: $devtool-button-bg;
+ }
+
+ &:active {
+ background-color: $devtool-button-bg-hover;
+ }
+ }
}
-.aurora-devtool-summary-label {
- font-weight: 600;
+.aurora-devtool-action-button.active {
+ color: -st-accent-fg-color;
+ background-color: -st-accent-color;
+}
+
+.aurora-devtool-button-content,
+.aurora-devtool-action-content {
+ spacing: 8px;
}
.aurora-devtool-action-row {
+ spacing: 10px;
+}
+
+.aurora-devtool-button-row {
+ spacing: 10px;
+ padding: 12px 14px;
+}
+
+.aurora-devtool-group-footer {
+ spacing: 7px;
+ margin-top: 10px;
+}
+
+.aurora-devtool-inline-controls {
+ spacing: 7px;
+}
+
+.aurora-devtool-stepper-control {
spacing: 8px;
}
-.aurora-devtool-action-button {
+.aurora-devtool-stepper {
+ spacing: 6px;
+}
+
+.aurora-devtool-stepper-button {
+ min-width: 28px;
+ min-height: 28px;
+ padding: 0;
+ border-radius: 999px;
+}
+
+.aurora-devtool-stepper-value {
+ min-width: 22px;
+ padding: 0;
+ color: $devtool-fg;
+ font-size: 14px;
+ font-feature-settings: 'tnum';
+ text-align: center;
+}
+
+.aurora-devtool-stepper-reset {
+ min-width: 28px;
+ min-height: 28px;
+ border-radius: 999px;
+}
+
+.aurora-devtool-time-colon {
+ color: $devtool-dim;
+ font-weight: 700;
+}
+
+// Fixed-width anchor for the entry (entryWrap in devTool.ts): St.Entry
+// ignores width/min-width set directly on it, so the cap has to come from
+// the wrapper's allocation instead.
+.aurora-devtool-entry-wrap {
+ width: 130px;
+}
+
+.aurora-devtool-entry {
+ padding: 6px 8px;
+ border: 1px solid $devtool-separator;
border-radius: 8px;
- padding: 8px 10px;
- border: 1px solid transparent;
+ background-color: $devtool-entry-bg;
+ color: $devtool-fg;
+ font-size: 13px;
- &.active {
- border-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.35), 42%);
- background-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.12), 30%);
+ &:focus {
+ border-color: -st-accent-color;
+ box-shadow: inset 0 0 0 1px -st-accent-color;
+ }
+
+ StLabel.hint-text {
+ color: $devtool-dim;
}
}
-.aurora-devtool-action-content {
+.aurora-devtool-kbd {
+ padding: 3px 8px;
+ border: 1px solid $devtool-separator;
+ border-radius: 6px;
+ background-color: $devtool-button-bg;
+ font-size: 12.5px;
+}
+
+.aurora-devtool-kbd-chip {
+ padding: 2px 6px;
+ border: 1px solid $devtool-separator;
+ border-radius: 6px;
+ background-color: $devtool-button-bg;
+ font-size: 12.5px;
+}
+
+.aurora-devtool-commands-note {
+ spacing: 6px;
+ margin: 0 14px;
+ color: $devtool-dim;
+ font-size: 12.5px;
+}
+
+.aurora-devtool-segmented {
+ spacing: 6px;
+ padding: 0 14px 12px;
+}
+
+.aurora-devtool-segmented-button {
+ padding: 6px 8px;
+ font-size: 13px;
+
+ &:checked {
+ background-color: $devtool-button-bg-active;
+ }
+}
+
+.aurora-devtool-summary {
spacing: 7px;
+ padding: 8px 9px;
+ border-radius: 9px;
+ background-color: $devtool-entry-bg;
+}
+
+.aurora-devtool-summary-icon {
+ color: $devtool-dim;
+}
+
+.aurora-devtool-summary-label {
+ font-weight: 600;
+}
+
+.aurora-devtool-error {
+ padding: 3px 8px;
+ color: $devtool-error;
+}
+
+.aurora-devtool-status-page {
+ spacing: 6px;
+ padding: 60px 40px;
+}
+
+.aurora-devtool-status-icon {
+ margin-bottom: 10px;
+ color: $devtool-dim;
+}
+
+.aurora-devtool-status-title {
+ font-size: 19px;
+ font-weight: 700;
+ color: $devtool-heading;
+ text-align: center;
+}
+
+.aurora-devtool-status-description {
+ max-width: 380px;
+ font-size: 14px;
+ color: $devtool-dim;
+ text-align: center;
+}
+
+.aurora-devtool-toast {
+ margin-bottom: 24px;
+ padding: 10px 18px;
+ border-radius: 999px;
+ color: $devtool-toast-fg;
+ background-color: $devtool-toast-bg;
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
+ font-size: 14px;
+ text-align: center;
}
diff --git a/tests/performance/runtimePerformance.ts b/tests/performance/runtimePerformance.ts
new file mode 100644
index 0000000..956bb76
--- /dev/null
+++ b/tests/performance/runtimePerformance.ts
@@ -0,0 +1,249 @@
+import assert from 'node:assert/strict';
+import os from 'node:os';
+import { performance } from 'node:perf_hooks';
+
+import {
+ createIconWeaveCandidateMetadata,
+ scoreIconWeaveCandidate,
+} from '~/patches/iconWeaveScoring.ts';
+import { parseClipboardLog } from '~/clipboard/clipboardLog.ts';
+
+type BenchmarkResult = {
+ medianMs: number;
+ p95Ms: number;
+};
+
+type ClipboardEntry = {
+ id: string;
+ kind: 'text' | 'image';
+ text: string;
+ pinned: boolean;
+ timestamp: number;
+ contentKey: string;
+};
+
+let checksum = 0;
+
+function percentile(samples: number[], fraction: number): number {
+ const sorted = samples.toSorted((a, b) => a - b);
+ return sorted[Math.ceil(sorted.length * fraction) - 1] || 0;
+}
+
+function sample(operation: () => number, samples: number[]): void {
+ const started = performance.now();
+ checksum += operation();
+ samples.push(performance.now() - started);
+}
+
+function measureComparison(
+ baselineOperation: () => number,
+ optimizedOperation: () => number,
+ sampleCount = 25,
+): { baseline: BenchmarkResult; optimized: BenchmarkResult } {
+ for (let index = 0; index < 5; index++) {
+ checksum += baselineOperation();
+ checksum += optimizedOperation();
+ }
+
+ const baselineSamples: number[] = [];
+ const optimizedSamples: number[] = [];
+ for (let index = 0; index < sampleCount; index++) {
+ if (index % 2 === 0) {
+ sample(baselineOperation, baselineSamples);
+ sample(optimizedOperation, optimizedSamples);
+ } else {
+ sample(optimizedOperation, optimizedSamples);
+ sample(baselineOperation, baselineSamples);
+ }
+ }
+
+ const result = (samples: number[]): BenchmarkResult => ({
+ medianMs: percentile(samples, 0.5),
+ p95Ms: percentile(samples, 0.95),
+ });
+
+ return {
+ baseline: result(baselineSamples),
+ optimized: result(optimizedSamples),
+ };
+}
+
+function parseClipboardLogBaseline(source: string) {
+ const pinned: ClipboardEntry[] = [];
+ const history: ClipboardEntry[] = [];
+ const byId = new Map();
+
+ for (const line of source.split('\n')) {
+ if (!line) continue;
+
+ const op = JSON.parse(line) as {
+ op: 'add' | 'delete' | 'move' | 'pin' | 'unpin';
+ id: string;
+ text?: string;
+ timestamp?: number;
+ };
+ if (op.op === 'add') {
+ const entry: ClipboardEntry = {
+ id: op.id,
+ kind: 'text',
+ text: op.text || '',
+ pinned: false,
+ timestamp: op.timestamp || 0,
+ contentKey: op.text || '',
+ };
+ byId.set(entry.id, entry);
+ history.unshift(entry);
+ continue;
+ }
+
+ const entry = byId.get(op.id);
+ if (!entry) continue;
+
+ if (op.op === 'delete') {
+ removeEntry(pinned, entry);
+ removeEntry(history, entry);
+ byId.delete(op.id);
+ } else if (op.op === 'move') {
+ moveToFront(entry.pinned ? pinned : history, entry);
+ } else if (op.op === 'pin') {
+ removeEntry(history, entry);
+ entry.pinned = true;
+ moveToFront(pinned, entry);
+ } else {
+ removeEntry(pinned, entry);
+ entry.pinned = false;
+ moveToFront(history, entry);
+ }
+ }
+
+ return { pinned, history };
+}
+
+function removeEntry(entries: ClipboardEntry[], entry: ClipboardEntry): void {
+ const index = entries.indexOf(entry);
+ if (index !== -1) entries.splice(index, 1);
+}
+
+function moveToFront(entries: ClipboardEntry[], entry: ClipboardEntry): void {
+ removeEntry(entries, entry);
+ entries.unshift(entry);
+}
+
+function fragmentedClipboardLog(entryCount: number): string {
+ const operations: string[] = [];
+ for (let index = 1; index <= entryCount; index++) {
+ operations.push(
+ JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }),
+ );
+ }
+ for (let index = 1; index <= entryCount; index++) {
+ operations.push(JSON.stringify({ op: 'move', id: String(index) }));
+ }
+ return operations.join('\n');
+}
+
+function compactedClipboardLog(entryCount: number): string {
+ const operations: string[] = [];
+ for (let index = 1; index <= entryCount; index++) {
+ operations.push(
+ JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }),
+ );
+ }
+ return operations.join('\n');
+}
+
+function gainPercent(baseline: number, optimized: number): number {
+ return ((baseline - optimized) / baseline) * 100;
+}
+
+function benchmarkClipboard(size: number, fragmented: boolean): void {
+ const source = fragmented ? fragmentedClipboardLog(size) : compactedClipboardLog(size);
+ const baselineState = parseClipboardLogBaseline(source);
+ const optimizedState = parseClipboardLog(source);
+ assert.deepEqual(
+ optimizedState.history.map((entry) => entry.id),
+ baselineState.history.map((entry) => entry.id),
+ );
+
+ const { baseline, optimized } = measureComparison(
+ () => parseClipboardLogBaseline(source).history.length,
+ () => parseClipboardLog(source).history.length,
+ );
+ const gain = gainPercent(baseline.medianMs, optimized.medianMs);
+
+ console.log(
+ JSON.stringify({
+ benchmark: 'clipboard-replay',
+ path: fragmented ? 'fragmented' : 'compacted',
+ operations: fragmented ? size * 2 : size,
+ baseline,
+ optimized,
+ medianGainPercent: Number(gain.toFixed(1)),
+ }),
+ );
+
+ if (size === 10_000)
+ assert.ok(gain >= 20, `Clipboard median gain ${gain.toFixed(1)}% is below 20%`);
+}
+
+function benchmarkIconWeave(): void {
+ const iterations = 100_000;
+ const scoreInput = { wmClass: 'steam_app_220', appId: '', title: 'Half-Life 2' };
+ const metadata = createIconWeaveCandidateMetadata(
+ 'com.valvesoftware.HalfLife2.desktop',
+ 'Half-Life 2',
+ 'steam steam://rungameid/220',
+ );
+ const { baseline, optimized } = measureComparison(
+ () => {
+ let score = 0;
+ for (let index = 0; index < iterations; index++) {
+ const candidate = createIconWeaveCandidateMetadata(
+ 'com.valvesoftware.HalfLife2.desktop',
+ 'Half-Life 2',
+ 'steam steam://rungameid/220',
+ );
+ score += scoreIconWeaveCandidate({ candidate, ...scoreInput });
+ }
+ return score;
+ },
+ () => {
+ let score = 0;
+ for (let index = 0; index < iterations; index++)
+ score += scoreIconWeaveCandidate({ candidate: metadata, ...scoreInput });
+ return score;
+ },
+ );
+ const gain = gainPercent(baseline.medianMs, optimized.medianMs);
+
+ console.log(
+ JSON.stringify({
+ benchmark: 'icon-weave-derived-metadata',
+ path: 'warm-cache',
+ operations: iterations,
+ baseline,
+ optimized,
+ medianGainPercent: Number(gain.toFixed(1)),
+ }),
+ );
+ assert.ok(gain >= 20, `IconWeave median gain ${gain.toFixed(1)}% is below 20%`);
+}
+
+console.log(
+ JSON.stringify({
+ benchmark: 'environment',
+ node: process.version,
+ platform: process.platform,
+ arch: process.arch,
+ cpu: os.cpus()[0]?.model || 'unknown',
+ samples: 25,
+ }),
+);
+
+for (const size of [1_000, 10_000]) {
+ benchmarkClipboard(size, false);
+ benchmarkClipboard(size, true);
+}
+benchmarkIconWeave();
+
+if (checksum === 0) throw new Error('Benchmark output was not consumed');
diff --git a/tests/shell/clipboard/clipboardHistory.test.js b/tests/shell/clipboard/clipboardHistory.test.js
index 387a390..ff5d431 100644
--- a/tests/shell/clipboard/clipboardHistory.test.js
+++ b/tests/shell/clipboard/clipboardHistory.test.js
@@ -1,5 +1,6 @@
/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */
+import GdkPixbuf from 'gi://GdkPixbuf';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import St from 'gi://St';
@@ -91,6 +92,18 @@ async function waitForFloatingActions(
});
}
+async function waitForHistoryWrite(evaluate, description) {
+ const historyFile = Gio.File.new_for_path(
+ `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`,
+ );
+ const historyMonitor = historyFile.monitor_file(Gio.FileMonitorFlags.NONE, null);
+ try {
+ await waitForCondition({ evaluate, signals: [[historyMonitor, 'changed']], description });
+ } finally {
+ historyMonitor.cancel();
+ }
+}
+
export var METRICS = {};
export function init() {
@@ -145,53 +158,90 @@ export async function run() {
Scripting.scriptEvent('workspacePanelOk');
const beforeTextCount = getClipboardModule().entryCount;
- const textHistoryFile = Gio.File.new_for_path(
- `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`,
+ St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-clipboard-entry');
+ await waitForHistoryWrite(
+ () => getClipboardModule().entryCount > beforeTextCount,
+ 'clipboard text to be persisted by the monitor',
);
- const textHistoryMonitor = textHistoryFile.monitor_file(Gio.FileMonitorFlags.NONE, null);
- try {
- St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-clipboard-entry');
- await waitForCondition({
- evaluate: () => getClipboardModule().entryCount > beforeTextCount,
- signals: [[textHistoryMonitor, 'changed']],
- description: 'clipboard text to be persisted by the monitor',
- });
- } finally {
- textHistoryMonitor.cancel();
- }
Scripting.scriptEvent('clipboardWritten');
- const beforeImageCount = getClipboardModule().entryCount;
- const historyFile = Gio.File.new_for_path(
- `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`,
+ const clipboard = St.Clipboard.get_default();
+ const clipboardModule = getClipboardModule();
+ const newestEntry = () => clipboardModule._store.getHistory()[0];
+
+ const screenshot = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, false, 8, 3840, 2160);
+ screenshot.fill(0x3366ccff);
+ const [, screenshotPng] = screenshot.save_to_bufferv('png', [], []);
+ const beforeImageCount = clipboardModule.entryCount;
+ clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', screenshotPng);
+ await waitForHistoryWrite(
+ () => clipboardModule.entryCount > beforeImageCount,
+ 'screenshot-sized clipboard image to be captured by the monitor',
);
- const historyMonitor = historyFile.monitor_file(Gio.FileMonitorFlags.NONE, null);
+
+ const imageEntry = newestEntry();
+ if (imageEntry?.kind !== 'image') throw new Error('Newest clipboard entry is not the image');
+ const thumbnailPath = imageEntry.filePath.replace(/\.[^./]+$/, '') + '.thumb.png';
+ const [, thumbnailWidth, thumbnailHeight] = GdkPixbuf.Pixbuf.get_file_info(thumbnailPath);
+ if (!thumbnailWidth || Math.max(thumbnailWidth, thumbnailHeight) > 640) {
+ throw new Error(`Image thumbnail has wrong size: ${thumbnailWidth}x${thumbnailHeight}`);
+ }
+
+ const store = clipboardModule._store;
+ const originalWriteThumbnail = store._writeThumbnail;
+ store._writeThumbnail = async () => {
+ throw new Error('Injected thumbnail failure');
+ };
+ let imageWithoutThumbnailAdded;
try {
- St.Clipboard.get_default().set_content(
- St.ClipboardType.CLIPBOARD,
- 'image/png',
- new Uint8Array([
- 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
- 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255,
- 127, 0, 9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
- ]),
- );
+ imageWithoutThumbnailAdded = await clipboardModule.addImage({
+ mimeType: 'image/png',
+ bytes: new GLib.Bytes(screenshotPng),
+ fingerprint: `thumbnail-failure-${Date.now()}`,
+ });
+ } finally {
+ store._writeThumbnail = originalWriteThumbnail;
+ }
+ const imageWithoutThumbnail = newestEntry();
+ if (!imageWithoutThumbnailAdded || imageWithoutThumbnail?.kind !== 'image')
+ throw new Error('Valid image was dropped when thumbnail generation failed');
+ if (!Gio.File.new_for_path(imageWithoutThumbnail.filePath).query_exists(null))
+ throw new Error('Original image was removed when thumbnail generation failed');
+ if (
+ Gio.File.new_for_path(
+ imageWithoutThumbnail.filePath.replace(/\.[^./]+$/, '') + '.thumb.png',
+ ).query_exists(null)
+ )
+ throw new Error('Failed thumbnail unexpectedly exists');
+
+ const marker = 'aurora-test-before-image-restore';
+ clipboard.set_text(St.ClipboardType.CLIPBOARD, marker);
+ await waitForHistoryWrite(() => newestEntry()?.text === marker, 'marker text to be captured');
+
+ const selection = global.display.get_selection();
+ const originalTransfer = selection.transfer_async;
+ let imageReads = 0;
+ selection.transfer_async = function (...args) {
+ imageReads++;
+ return originalTransfer.apply(this, args);
+ };
+ try {
+ clipboardModule._onActivate(imageEntry);
await waitForCondition({
- evaluate: () => getClipboardModule().entryCount > beforeImageCount,
- signals: [[historyMonitor, 'changed']],
- description: 'clipboard image to be persisted by the monitor',
+ evaluate: () => clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD).includes('image/png'),
+ signals: [[global.display.get_selection(), 'owner-changed']],
+ description: 'restored image to own the clipboard',
});
+ await waitForTiming(250, 'negative check: the 50 ms monitor read delay passes with no read');
} finally {
- historyMonitor.cancel();
+ selection.transfer_async = originalTransfer;
}
-
- if (getClipboardModule().entryCount <= beforeImageCount) {
- throw new Error('Clipboard image was not captured by the monitor');
+ if (imageReads !== 0) {
+ throw new Error(`Restoring an image made the monitor read it back (${imageReads} reads)`);
}
Scripting.scriptEvent('clipboardImageWritten');
- const clipboardModule = getClipboardModule();
const longText =
'This long clipboard entry must wrap onto several visual lines while remaining inside the fixed panel width. '.repeat(
6,
@@ -480,6 +530,14 @@ export async function run() {
auroraSettings.set_boolean('module-clipboard-history', true);
await Scripting.waitLeisure();
+
+ const reenabledModule = getClipboardModule();
+ const beforeReenabledCapture = reenabledModule.entryCount;
+ clipboard.set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-after-reenable');
+ await waitForHistoryWrite(
+ () => getClipboardModule().entryCount > beforeReenabledCapture,
+ 'clipboard monitor to resume after re-enable',
+ );
}
let _moduleEnabled = false;
diff --git a/tests/shell/desktop/trayIcons/stability.test.js b/tests/shell/desktop/trayIcons/stability.test.js
index ce5c928..8a79df1 100644
--- a/tests/shell/desktop/trayIcons/stability.test.js
+++ b/tests/shell/desktop/trayIcons/stability.test.js
@@ -78,7 +78,7 @@ export async function run() {
state.collapsed = !state.collapsed;
trayContainer._syncLayout(true);
await waitForCondition({
- evaluate: () => !clipArea._viewportTimeout.active,
+ evaluate: () => clipArea.get_transition('viewport-progress') === null,
signals: [[animationCompleted, 'activate']],
description: 'TrayClipArea viewport animation to finish before the next collapse toggle',
});
diff --git a/tests/shell/dev/devTool.test.js b/tests/shell/dev/devTool.test.js
index 8f5f2ee..a7f5801 100644
--- a/tests/shell/dev/devTool.test.js
+++ b/tests/shell/dev/devTool.test.js
@@ -11,6 +11,7 @@ import {
waitForModuleState,
} from '../support/testUtils.js';
import { exerciseCaptureTools, exerciseClipboardHistory } from './scenarios/captureAndClipboard.js';
+import { exerciseDevToolUi } from './scenarios/devToolUi.js';
import { exerciseDock } from './scenarios/dock.js';
import {
exerciseCalendarReminders,
@@ -27,6 +28,7 @@ export function init() {
Scripting.defineScriptEvent('extensionEnabled', 'Extension enabled');
Scripting.defineScriptEvent('devToolAbsent', 'DevTool absent without AURORA_DEVTOOLS');
Scripting.defineScriptEvent('devToolFound', 'DevTool found with AURORA_DEVTOOLS');
+ Scripting.defineScriptEvent('devToolUiPassed', 'DevTool card navigation and reset passed');
Scripting.defineScriptEvent('captureToolPassed', 'Capture Tool DevTool actions passed');
Scripting.defineScriptEvent('clipboardToolPassed', 'Clipboard History DevTool actions passed');
Scripting.defineScriptEvent('trayIconsToolPassed', 'Tray Icons DevTool actions passed');
@@ -71,10 +73,13 @@ export async function run() {
if (!devTool.generalTool) throw new Error('General DevTool section not found');
- await exerciseCaptureTools(settings, devTool);
+ await exerciseDevToolUi(panelButton, settings);
+ Scripting.scriptEvent('devToolUiPassed');
+
+ await exerciseCaptureTools(panelButton, settings, devTool);
Scripting.scriptEvent('captureToolPassed');
- await exerciseClipboardHistory(settings, devTool);
+ await exerciseClipboardHistory(panelButton, settings, devTool);
Scripting.scriptEvent('clipboardToolPassed');
await exerciseTrayIcons(devTool, tray);
@@ -93,6 +98,7 @@ export async function run() {
let _extensionEnabled = false;
let _devToolAbsent = false;
let _devToolFound = false;
+let _devToolUiPassed = false;
let _captureToolPassed = false;
let _clipboardToolPassed = false;
let _trayIconsToolPassed = false;
@@ -112,6 +118,10 @@ export function script_devToolFound() {
_devToolFound = true;
}
+export function script_devToolUiPassed() {
+ _devToolUiPassed = true;
+}
+
export function script_captureToolPassed() {
_captureToolPassed = true;
}
@@ -141,6 +151,7 @@ export function finish() {
if (GLib.getenv('AURORA_DEVTOOLS') === '1') {
if (!_devToolFound) throw new Error('DevTool was not found with AURORA_DEVTOOLS=1');
+ if (!_devToolUiPassed) throw new Error('DevTool card UI checks did not complete');
if (!_captureToolPassed) throw new Error('Capture Tool DevTool actions did not complete');
if (!_clipboardToolPassed)
throw new Error('Clipboard History DevTool actions did not complete');
diff --git a/tests/shell/dev/scenarios/captureAndClipboard.js b/tests/shell/dev/scenarios/captureAndClipboard.js
index 370faa9..f810a1a 100644
--- a/tests/shell/dev/scenarios/captureAndClipboard.js
+++ b/tests/shell/dev/scenarios/captureAndClipboard.js
@@ -2,6 +2,53 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js';
import { waitForCondition } from '../../support/testUtils.js';
+function findDescendant(actor, predicate) {
+ if (predicate(actor)) return actor;
+ if (!actor?.get_children) return null;
+ for (const child of actor.get_children()) {
+ const match = findDescendant(child, predicate);
+ if (match) return match;
+ }
+ return null;
+}
+
+async function openDevToolSection(panelButton, key) {
+ if (panelButton.menu.isOpen) panelButton.menu.close();
+ panelButton.menu.open();
+ await Scripting.waitLeisure();
+
+ const devTools = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-view-devtools',
+ );
+ if (!devTools) throw new Error('Dev Tools view button not found');
+ devTools.emit('clicked', 1);
+ await Scripting.waitLeisure();
+
+ const section = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === `aurora-devtool-focus-devtool-${key}`,
+ );
+ if (!section) throw new Error(`${key} DevTool section button not found`);
+ section.emit('clicked', 1);
+ await Scripting.waitLeisure();
+}
+
+async function clickAction(panelButton, accessibleName, expectedFocusName = null) {
+ const button = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === accessibleName,
+ );
+ if (!button) throw new Error(`${accessibleName} action not found`);
+
+ if (expectedFocusName) button.grab_key_focus();
+ button.emit('clicked', 1);
+ await Scripting.waitLeisure();
+
+ if (expectedFocusName && global.stage.get_key_focus()?.name !== expectedFocusName)
+ throw new Error(`${accessibleName} action did not restore focus to ${expectedFocusName}`);
+}
+
function actorSignals(root) {
if (!root) return [];
const children = root.get_children ? root.get_children() : [];
@@ -24,13 +71,14 @@ function waitForInteractionState(tool, interaction, opacity) {
});
}
-export async function exerciseCaptureTools(settings, devTool) {
+export async function exerciseCaptureTools(panelButton, settings, devTool) {
settings.set_boolean('module-capture-tools', true);
await Scripting.waitLeisure();
const tool = devTool.captureToolsTool;
if (!tool) throw new Error('Capture Tool DevTool section not found');
- if (!(await tool.openPreview())) throw new Error('Capture Tool did not open the preview');
+ await openDevToolSection(panelButton, 'capture-tools');
+ await clickAction(panelButton, 'Open Preview');
await waitForCondition({
evaluate: () => tool.state?.captureVisible && tool.state.toolbarVisible,
signals: [[Main.uiGroup, 'child-added'], ...actorSignals(Main.screenshotUI)],
@@ -39,46 +87,58 @@ export async function exerciseCaptureTools(settings, devTool) {
if (!tool.state?.captureVisible || !tool.state.toolbarVisible)
throw new Error('Capture Tool preview is not visible');
- if (!tool.cycleTool()) throw new Error('Capture Tool did not change tool');
- if (!tool.cycleColor()) throw new Error('Capture Tool did not change color');
- if (!tool.cycleWidth()) throw new Error('Capture Tool did not change width');
- if (!tool.toggleInteraction('selection'))
- throw new Error('Capture Tool did not simulate selection movement');
+
+ const initialTool = tool.state.tool;
+ const initialColor = tool.state.color;
+ const initialWidth = tool.state.width;
+ await clickAction(panelButton, `Tool: ${initialTool}`, 'aurora-devtool-focus-capture-tool');
+ await clickAction(panelButton, `Color: ${initialColor}`, 'aurora-devtool-focus-capture-color');
+ await clickAction(panelButton, `Width: ${initialWidth}`, 'aurora-devtool-focus-capture-width');
+ if (
+ tool.state?.tool === initialTool ||
+ tool.state?.color === initialColor ||
+ tool.state?.width === initialWidth
+ )
+ throw new Error('Capture Tool appearance controls did not update runtime state');
+
+ await clickAction(panelButton, 'Move Selection');
await waitForInteractionState(tool, 'selection', 100);
- if (!tool.toggleInteraction('drawing')) throw new Error('Capture Tool did not simulate drawing');
+ await clickAction(panelButton, 'Draw');
await waitForInteractionState(tool, 'drawing', 100);
- if (!tool.setTesseractAvailable(false) || tool.state?.ocrAvailable !== false)
+ await clickAction(panelButton, 'Tesseract Off');
+ if (tool.state?.ocrAvailable !== false)
throw new Error('Capture Tool did not simulate unavailable Tesseract');
- if (!tool.setTesseractAvailable(true) || tool.state?.ocrAvailable !== true)
+ await clickAction(panelButton, 'Tesseract On');
+ if (tool.state?.ocrAvailable !== true)
throw new Error('Capture Tool did not simulate available Tesseract');
- if (!tool.injectOcr() || !tool.state?.ocrHasResult)
- throw new Error('Capture Tool did not inject an OCR result');
+ await clickAction(panelButton, 'Inject OCR');
+ if (!tool.state?.ocrHasResult) throw new Error('Capture Tool did not inject an OCR result');
if (!tool.state.searchUri?.includes('Aurora%20simulated%20OCR%20result'))
throw new Error('Capture Tool did not expose the simulated OCR search URI');
- if (!tool.copyOcr()) throw new Error('Capture Tool did not copy simulated OCR');
- if (!tool.clearAnnotations()) throw new Error('Capture Tool did not clear annotations');
- if (!tool.reset()) throw new Error('Capture Tool did not reset its state');
+ await clickAction(panelButton, 'Copy OCR');
+ await clickAction(panelButton, 'Clear Annotations');
+ await clickAction(panelButton, 'Reset');
if (tool.state?.interaction !== 'idle' || tool.state.ocrAvailabilityOverridden)
throw new Error('Capture Tool reset left simulated state active');
Main.screenshotUI.close(true);
}
-export async function exerciseClipboardHistory(settings, devTool) {
+export async function exerciseClipboardHistory(panelButton, settings, devTool) {
settings.set_boolean('module-clipboard-history', true);
await Scripting.waitLeisure();
const tool = devTool.clipboardHistoryTool;
if (!tool) throw new Error('Clipboard History DevTool section not found');
+ await openDevToolSection(panelButton, 'clipboard-history');
const previousEntryCount = tool.entryCount;
- if (tool.addRandomMessages(2).length !== 2)
- throw new Error('Clipboard History did not add random messages');
- if (tool.entryCount < previousEntryCount + 2)
+ await clickAction(panelButton, 'Add 5 Messages');
+ if (tool.entryCount < previousEntryCount + 5)
throw new Error('Clipboard History entry count did not increase');
- if (!tool.openPanel()) throw new Error('Clipboard History did not open the panel');
+ await clickAction(panelButton, 'Open Panel');
await waitForCondition({
evaluate: () => tool.isPanelOpen,
@@ -94,6 +154,7 @@ export async function exerciseClipboardHistory(settings, devTool) {
);
if (panel && panel.close) panel.close();
- if (!tool.clearHistory()) throw new Error('Clipboard History did not clear the history');
+ await openDevToolSection(panelButton, 'clipboard-history');
+ await clickAction(panelButton, 'Clear History');
if (tool.entryCount !== 0) throw new Error('Clipboard History still has entries after clear');
}
diff --git a/tests/shell/dev/scenarios/devToolUi.js b/tests/shell/dev/scenarios/devToolUi.js
new file mode 100644
index 0000000..3f9e279
--- /dev/null
+++ b/tests/shell/dev/scenarios/devToolUi.js
@@ -0,0 +1,478 @@
+import Pango from 'gi://Pango';
+import St from 'gi://St';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js';
+import { waitForCondition } from '../../support/testUtils.js';
+
+function findDescendant(actor, predicate) {
+ if (predicate(actor)) return actor;
+ if (!actor?.get_children) return null;
+ for (const child of actor.get_children()) {
+ const match = findDescendant(child, predicate);
+ if (match) return match;
+ }
+ return null;
+}
+
+function findAncestor(actor, predicate) {
+ let current = actor;
+ while (current) {
+ if (predicate(current)) return current;
+ current = current.get_parent();
+ }
+ return null;
+}
+
+export async function exerciseDevToolUi(panelButton, settings) {
+ panelButton.menu.open();
+ await waitForCondition({
+ evaluate: () => panelButton.menu.isOpen,
+ signals: [[panelButton.menu, 'open-state-changed']],
+ description: 'DevTool card to open',
+ });
+
+ await Scripting.waitLeisure();
+
+ const cardPanel = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-panel'),
+ );
+ const headerBox = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-header'),
+ );
+ const scrollView = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'),
+ );
+ if (!cardPanel || !headerBox || !scrollView)
+ throw new Error('DevTool card structure incomplete (panel/header/scroll)');
+ if (cardPanel.get_children()[0] !== headerBox)
+ throw new Error('Header is not the first child of the DevTool panel');
+ const monitor = Main.layoutManager.findMonitorForActor(panelButton);
+ const monitorIndex = monitor ? monitor.index : Main.layoutManager.primaryIndex;
+ const workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex);
+ const [cardX, cardY] = cardPanel.get_transformed_position();
+ const [cardWidth, cardHeight] = cardPanel.get_transformed_size();
+ if (
+ cardX < workArea.x ||
+ cardX + cardWidth > workArea.x + workArea.width ||
+ cardY < workArea.y ||
+ cardY + cardHeight > workArea.y + workArea.height
+ )
+ throw new Error(
+ `DevTool card ${cardX},${cardY} ${cardWidth}x${cardHeight} exceeds ` +
+ `work area ${workArea.x},${workArea.y} ${workArea.width}x${workArea.height}`,
+ );
+ const [, headerY] = headerBox.get_transformed_position();
+ const [, scrollY] = scrollView.get_transformed_position();
+ if (!(headerY < scrollY))
+ throw new Error(`Header (${headerY}) must be above the scroll view (${scrollY})`);
+
+ const scrollViewport = findDescendant(scrollView, (actor) => actor instanceof St.Viewport);
+ const pageContent = scrollViewport ? scrollViewport.get_children()[0] : null;
+ if (!pageContent) throw new Error('DevTool scroll viewport has no page content');
+ if (pageContent.width > scrollViewport.width)
+ throw new Error(
+ `Page content (${pageContent.width}px) overflows the ` +
+ `${scrollViewport.width}px scroll viewport`,
+ );
+
+ const moduleSwitch = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'),
+ );
+ if (!moduleSwitch) throw new Error('Module switch not found');
+ const initialSwitchState = moduleSwitch.child.state;
+ moduleSwitch.emit('clicked', 1);
+ if (moduleSwitch.child.state === initialSwitchState)
+ throw new Error('Module switch did not toggle');
+ moduleSwitch.emit('clicked', 1);
+ const devToolsButton = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Dev Tools',
+ );
+ if (!devToolsButton) throw new Error('Dev Tools view switcher button not found');
+ const switcherIcon = findDescendant(devToolsButton, (actor) => actor instanceof St.Icon);
+ if (!switcherIcon || switcherIcon.icon_size !== 14)
+ throw new Error('Dev Tools view switcher button is missing its 14px icon');
+ devToolsButton.emit('clicked', 1);
+ await Scripting.waitLeisure();
+ const activeDevToolsButton = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Dev Tools',
+ );
+ if (!activeDevToolsButton?.checked)
+ throw new Error('Dev Tools view switcher button did not activate');
+ const devSectionRow = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-list-button'),
+ );
+ if (!devSectionRow) throw new Error('Dev Tools section row not found');
+ const [devRowWidth, devRowHeight] = devSectionRow.get_transformed_size();
+ if (!(devRowWidth > 0) || !(devRowHeight > 0))
+ throw new Error(`Dev Tools section rows are not visible (${devRowWidth}x${devRowHeight})`);
+ devSectionRow.emit('clicked', 1);
+ const sectionPanel = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-panel'),
+ );
+ if (!sectionPanel) throw new Error('Dev Tool section panel did not render');
+ const lookingGlassButton = findDescendant(
+ sectionPanel,
+ (actor) => actor.accessible_name === 'Looking Glass',
+ );
+ if (!lookingGlassButton) throw new Error('Looking Glass action not found');
+ lookingGlassButton.emit('clicked', 1);
+ if (panelButton.menu.isOpen) throw new Error('DevTool stayed open behind Looking Glass');
+ if (!Main.lookingGlass?.isOpen) throw new Error('Looking Glass did not open');
+ Main.lookingGlass.close();
+ panelButton.menu.open();
+ const modulesTab = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Modules',
+ );
+ if (!modulesTab?.checked)
+ throw new Error('DevTool did not reset to the Modules root when reopened');
+ const resetSearchEntry = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-search'),
+ );
+ if (!resetSearchEntry || resetSearchEntry.get_text() !== '')
+ throw new Error('DevTool did not clear the search when reopened');
+
+ const backAtRoot = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-back',
+ );
+ if (!backAtRoot || backAtRoot.visible)
+ throw new Error('Back button should exist but stay hidden at the Modules root');
+
+ const moduleOpen = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-open'),
+ );
+ if (!moduleOpen) throw new Error('Module row open button not found');
+ moduleOpen.emit('clicked', 1);
+ const hero = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-hero'),
+ );
+ if (!hero) throw new Error('Module page did not open (hero block missing)');
+ if (
+ findDescendant(
+ hero,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-hero-icon'),
+ )
+ )
+ throw new Error('Module detail heading should not show a decorative side icon');
+ const backButton = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-back',
+ );
+ if (!backButton?.visible) throw new Error('Back button not visible on a subpage');
+ const backLabel = findDescendant(backButton, (actor) => actor.text === 'Back');
+ if (backLabel) throw new Error('Back button should use only the symbolic icon');
+ const backIcon = findDescendant(
+ backButton,
+ (actor) => actor instanceof St.Icon && actor.icon_name === 'go-previous-symbolic',
+ );
+ if (!backIcon) throw new Error('Back button has no symbolic navigation icon');
+ backButton.emit('clicked', 1);
+ const backAfterReturn = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-back',
+ );
+ if (backAfterReturn?.visible) throw new Error('Back button did not return to the Modules root');
+ const moduleRowAfterReturn = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'),
+ );
+ if (!moduleRowAfterReturn) throw new Error('Module list not shown after returning to the root');
+
+ const dockOpen = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Dock',
+ );
+ if (!dockOpen) throw new Error('Dock module row not found');
+ const dockRow = findAncestor(
+ dockOpen,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'),
+ );
+ const dockIcon = dockRow
+ ? findDescendant(
+ dockRow,
+ (actor) =>
+ actor instanceof St.Icon && actor.gicon?.to_string().includes('view-app-grid-symbolic'),
+ )
+ : null;
+ if (!dockIcon) throw new Error('Dock module does not use the app-grid icon');
+ dockOpen.emit('clicked', 1);
+
+ const maxIconLabel = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.text === 'Maximum Icon Size',
+ );
+ const maxIconRow = maxIconLabel
+ ? findAncestor(
+ maxIconLabel,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-row'),
+ )
+ : null;
+ const maxIconControl = maxIconRow
+ ? findDescendant(
+ maxIconRow,
+ (actor) =>
+ actor.has_style_class_name &&
+ actor.has_style_class_name('aurora-devtool-stepper-control'),
+ )
+ : null;
+ if (!maxIconRow || !maxIconControl) throw new Error('Maximum Icon Size stepper not found');
+ const [rowX] = maxIconRow.get_transformed_position();
+ const [rowWidth] = maxIconRow.get_transformed_size();
+ const [controlX] = maxIconControl.get_transformed_position();
+ const [controlWidth] = maxIconControl.get_transformed_size();
+ if (controlX + controlWidth > rowX + rowWidth)
+ throw new Error(
+ `Maximum Icon Size control ends at ${controlX + controlWidth}px, ` +
+ `outside its row at ${rowX + rowWidth}px`,
+ );
+ const resetIcon = findDescendant(
+ maxIconControl,
+ (actor) => actor instanceof St.Icon && actor.icon_name === 'edit-undo-symbolic',
+ );
+ if (!resetIcon) throw new Error('Maximum Icon Size reset action is not symbolic');
+ resetIcon.get_parent().emit('clicked', 1);
+ await Scripting.waitLeisure();
+
+ const developerSubtitle = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.text === 'Simulate runtime states and trigger module actions',
+ );
+ if (!developerSubtitle) throw new Error('Dock Developer section is missing');
+ if (!developerSubtitle.clutter_text.line_wrap)
+ throw new Error('Dock Developer subtitle does not wrap');
+ if (developerSubtitle.clutter_text.ellipsize !== Pango.EllipsizeMode.NONE)
+ throw new Error('Dock Developer subtitle is clipped with an ellipsis');
+
+ const subtleChoice = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Effect Intensity: Subtle',
+ );
+ const balancedChoice = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Effect Intensity: Balanced',
+ );
+ const segmentedControl = balancedChoice
+ ? findAncestor(
+ balancedChoice,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-segmented'),
+ )
+ : null;
+ if (!subtleChoice || !balancedChoice || !segmentedControl)
+ throw new Error('Effect Intensity segmented choices not found');
+ const [effectControlX, effectControlY] = segmentedControl.get_transformed_position();
+ const [effectControlWidth, effectControlHeight] = segmentedControl.get_transformed_size();
+ for (const choice of segmentedControl.get_children()) {
+ const [choiceX, choiceY] = choice.get_transformed_position();
+ const [choiceWidth, choiceHeight] = choice.get_transformed_size();
+ if (
+ choiceX < effectControlX ||
+ choiceX + choiceWidth > effectControlX + effectControlWidth ||
+ choiceY < effectControlY ||
+ choiceY + choiceHeight > effectControlY + effectControlHeight
+ )
+ throw new Error('Effect Intensity choice overflows segmented control');
+ }
+ balancedChoice.emit('clicked', 1);
+ if (settings.get_string('dock-motion-profile') !== 'balanced')
+ throw new Error('Effect Intensity selection did not update its setting');
+ if (!balancedChoice.checked || subtleChoice.checked)
+ throw new Error('Effect Intensity segmented state did not update');
+
+ const dockBack = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-back',
+ );
+ dockBack.emit('clicked', 1);
+
+ const auroraMenuOpen = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Aurora Menu',
+ );
+ if (!auroraMenuOpen) throw new Error('Aurora Menu module row not found');
+ const auroraMenuRow = findAncestor(
+ auroraMenuOpen,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'),
+ );
+ const auroraModuleIcon = auroraMenuRow
+ ? findDescendant(
+ auroraMenuRow,
+ (actor) =>
+ actor instanceof St.Icon &&
+ actor.gicon?.to_string().includes('aurora-shell-menu-symbolic.svg'),
+ )
+ : null;
+ if (!auroraModuleIcon) throw new Error('Aurora Menu module does not use its custom icon');
+ auroraMenuOpen.emit('clicked', 1);
+ await Scripting.waitLeisure();
+
+ const softwareCommandLabel = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.text === 'Software Command',
+ );
+ const softwareCommandRow = softwareCommandLabel
+ ? findAncestor(
+ softwareCommandLabel,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-row'),
+ )
+ : null;
+ const softwareCommandEntry = softwareCommandRow
+ ? findDescendant(softwareCommandRow, (actor) => actor instanceof St.Entry)
+ : null;
+ if (!softwareCommandEntry) throw new Error('Software Command input not found');
+ const entryTheme = softwareCommandEntry.get_theme_node();
+ if (
+ entryTheme.get_background_color().alpha === 0 ||
+ entryTheme.get_border_width(St.Side.TOP) === 0
+ )
+ throw new Error('Software Command input has no visible field surface');
+
+ const auroraMenuScroll = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'),
+ );
+ if (!auroraMenuScroll) throw new Error('Aurora Menu settings scroll view not found');
+ auroraMenuScroll.vadjustment.value =
+ auroraMenuScroll.vadjustment.upper - auroraMenuScroll.vadjustment.page_size;
+ const scrollBeforeRebuild = auroraMenuScroll.vadjustment.value;
+ if (!(scrollBeforeRebuild > 0))
+ throw new Error('Aurora Menu settings page did not become scrollable');
+ const auroraMenuModuleSwitch = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'),
+ );
+ if (!auroraMenuModuleSwitch) throw new Error('Aurora Menu module switch not found');
+ auroraMenuModuleSwitch.emit('clicked', 1);
+ await Scripting.waitLeisure();
+ const rebuiltAuroraMenuScroll = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'),
+ );
+ if (
+ !rebuiltAuroraMenuScroll ||
+ rebuiltAuroraMenuScroll.vadjustment.value < scrollBeforeRebuild - 1
+ )
+ throw new Error('Toggling a module lost the Module Settings scroll position');
+ const rebuiltAuroraMenuSwitch = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'),
+ );
+ if (!rebuiltAuroraMenuSwitch) throw new Error('Rebuilt Aurora Menu module switch not found');
+ rebuiltAuroraMenuSwitch.emit('clicked', 1);
+ await Scripting.waitLeisure();
+
+ const iconSelector = findDescendant(panelButton.menu.box, (actor) =>
+ actor.accessible_name?.startsWith('Menu Icon:'),
+ );
+ if (!iconSelector) throw new Error('Aurora Menu icon selector not found');
+ iconSelector.emit('clicked', 1);
+ await Scripting.waitLeisure();
+ for (const [label, filename] of [
+ ['Menu Icon: Aurora Shell', 'aurora-shell-menu-symbolic.svg'],
+ ['Menu Icon: Luminus OS', 'luminus-os-symbolic.svg'],
+ ]) {
+ const choice = findDescendant(panelButton.menu.box, (actor) => actor.accessible_name === label);
+ const loadedIcon = choice
+ ? findDescendant(
+ choice,
+ (actor) => actor instanceof St.Icon && actor.gicon?.to_string().includes(filename),
+ )
+ : null;
+ if (!loadedIcon) throw new Error(`${label} does not load its bundled icon`);
+ }
+
+ const customCommands = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.accessible_name === 'Custom Menu Commands',
+ );
+ const customCommandsSubtitle = customCommands
+ ? findDescendant(
+ customCommands,
+ (actor) => actor.text === 'Add shortcuts that run commands from Aurora Menu',
+ )
+ : null;
+ if (!customCommands || !customCommandsSubtitle)
+ throw new Error('Custom Menu Commands navigation row not found');
+ customCommandsSubtitle.text =
+ 'Add shortcuts that run commands from Aurora Menu and remain inside this settings row ' +
+ 'even when localization produces a substantially longer description';
+ await Scripting.waitLeisure();
+ if (
+ !customCommandsSubtitle.clutter_text.line_wrap ||
+ customCommandsSubtitle.clutter_text.ellipsize !== Pango.EllipsizeMode.NONE
+ )
+ throw new Error('Custom Menu Commands subtitle is not configured to wrap');
+ const [commandsX, commandsY] = customCommands.get_transformed_position();
+ const [commandsWidth, commandsHeight] = customCommands.get_transformed_size();
+ const [commandsSubtitleX, commandsSubtitleY] = customCommandsSubtitle.get_transformed_position();
+ const [commandsSubtitleWidth, commandsSubtitleHeight] =
+ customCommandsSubtitle.get_transformed_size();
+ if (
+ commandsSubtitleX < commandsX ||
+ commandsSubtitleX + commandsSubtitleWidth > commandsX + commandsWidth ||
+ commandsSubtitleY < commandsY ||
+ commandsSubtitleY + commandsSubtitleHeight > commandsY + commandsHeight
+ )
+ throw new Error('Custom Menu Commands text extends outside its row');
+
+ const auroraMenuBack = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.name === 'aurora-devtool-focus-back',
+ );
+ auroraMenuBack.emit('clicked', 1);
+
+ const searchEntry = findDescendant(
+ panelButton.menu.box,
+ (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-search'),
+ );
+ if (!searchEntry) throw new Error('Search entry not found');
+ if (searchEntry.hint_text !== 'Search modules')
+ throw new Error(`Unexpected search hint: ${searchEntry.hint_text}`);
+ searchEntry.clutter_text.set_text('zzz-no-such-module');
+ const statusPage = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-status-page'),
+ );
+ if (!statusPage) throw new Error('Empty-search status page not shown');
+ if (!findDescendant(statusPage, (actor) => actor.text === 'No Results Found'))
+ throw new Error('Empty-search status page title not found');
+ searchEntry.clutter_text.set_text('');
+ const restoredRow = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'),
+ );
+ if (!restoredRow) throw new Error('Module list was not restored after clearing the search');
+
+ const removedCardMenu = findDescendant(
+ panelButton.menu.box,
+ (actor) =>
+ actor.accessible_name === 'Menu' ||
+ (actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-card-menu')),
+ );
+ if (removedCardMenu) throw new Error('Removed DevTool action menu is still present');
+ panelButton.menu.close();
+}
diff --git a/tests/shell/performance/dockIntellihide.test.js b/tests/shell/performance/dockIntellihide.test.js
new file mode 100644
index 0000000..4c724ec
--- /dev/null
+++ b/tests/shell/performance/dockIntellihide.test.js
@@ -0,0 +1,109 @@
+/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */
+
+import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js';
+import {
+ EXTENSION_UUID,
+ getAuroraModule,
+ getAuroraSettings,
+ waitForCondition,
+ waitForExtension,
+} from '../support/testUtils.js';
+
+export var METRICS = {};
+
+export function init() {
+ Scripting.defineScriptEvent(
+ 'dockGeometryChecksCoalesced',
+ 'Dock geometry bursts produce at most one overlap check per frame',
+ );
+ Scripting.defineScriptEvent(
+ 'dockPendingCheckCancelled',
+ 'Dock teardown cancels its pending overlap check',
+ );
+}
+
+export async function run() {
+ await waitForExtension(EXTENSION_UUID);
+
+ const settings = getAuroraSettings();
+ const originalIntellihide = settings.get_boolean('dock-intellihide');
+ settings.set_boolean('dock-intellihide', true);
+ await Scripting.waitLeisure();
+
+ try {
+ const dock = getAuroraModule('dock');
+ const intellihide = dock?.bindings?.[0]?.intellihide;
+ if (!intellihide) throw new Error('Dock intellihide is unavailable');
+
+ intellihide._queuedRefreshes.clear();
+ intellihide._settle.clear();
+
+ const originalCheckOverlap = intellihide._checkOverlap;
+ let overlapChecks = 0;
+ intellihide._checkOverlap = () => {
+ overlapChecks++;
+ };
+
+ const inputSignals = 1_000;
+ try {
+ for (let index = 0; index < inputSignals; index++) intellihide._markDirty('benchmark');
+ await waitForCondition({
+ evaluate: () => overlapChecks >= 1,
+ signals: [[global.stage, 'after-paint']],
+ description: 'coalesced Dock overlap check',
+ });
+ } finally {
+ intellihide._checkOverlap = originalCheckOverlap;
+ }
+ if (overlapChecks !== 1)
+ throw new Error(`${inputSignals} geometry signals produced ${overlapChecks} overlap checks`);
+
+ METRICS = {
+ inputSignals,
+ uncoalescedPotentialChecks: inputSignals,
+ optimizedChecks: overlapChecks,
+ reductionPercent: ((inputSignals - overlapChecks) / inputSignals) * 100,
+ };
+ Scripting.scriptEvent('dockGeometryChecksCoalesced');
+
+ let mixedBurstChecks = 0;
+ intellihide._checkOverlap = function (...args) {
+ mixedBurstChecks++;
+ return originalCheckOverlap.apply(this, args);
+ };
+ try {
+ intellihide._markDirty('geometry-before-state');
+ intellihide.refresh('discrete-state');
+ await Scripting.waitLeisure();
+ } finally {
+ intellihide._checkOverlap = originalCheckOverlap;
+ }
+ if (mixedBurstChecks !== 1)
+ throw new Error(`Mixed geometry/state burst produced ${mixedBurstChecks} overlap checks`);
+
+ const teardownSubject = new intellihide.constructor(0);
+ teardownSubject._queuedRefreshes.clear();
+ teardownSubject._settle.clear();
+ let postDestroyChecks = 0;
+ teardownSubject._checkOverlap = () => {
+ postDestroyChecks++;
+ };
+ teardownSubject._markDirty('teardown-benchmark');
+ teardownSubject.destroy();
+ await Scripting.waitLeisure();
+ if (postDestroyChecks !== 0)
+ throw new Error(`Dock teardown allowed ${postDestroyChecks} pending overlap checks`);
+ Scripting.scriptEvent('dockPendingCheckCancelled');
+ } finally {
+ settings.set_boolean('dock-intellihide', originalIntellihide);
+ await Scripting.waitLeisure();
+ }
+}
+
+export function script_dockGeometryChecksCoalesced() {
+ return METRICS;
+}
+
+export function script_dockPendingCheckCancelled() {
+ return true;
+}
diff --git a/tests/unit/clipboard/clipboardLog.test.ts b/tests/unit/clipboard/clipboardLog.test.ts
index 31ea11c..6d5a859 100644
--- a/tests/unit/clipboard/clipboardLog.test.ts
+++ b/tests/unit/clipboard/clipboardLog.test.ts
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
+ type ClipboardEntrySnapshot,
encodeAddOp,
encodeCompactedLog,
encodeDeleteOp,
@@ -12,6 +13,19 @@ import {
removeClipboardEntry,
} from '~/clipboard/clipboardLog.ts';
+type ReferenceOp =
+ | {
+ op: 'add';
+ id: string;
+ kind?: 'text' | 'image';
+ text: string;
+ timestamp: number;
+ mimeType?: string;
+ filePath?: string;
+ contentKey?: string;
+ }
+ | { op: 'delete' | 'move' | 'pin' | 'unpin'; id: string };
+
const alpha = {
id: '1',
kind: 'text' as const,
@@ -111,3 +125,141 @@ test('clipboard entries are removed by identity', () => {
assert.deepEqual(entries, [beta]);
});
+
+test('clipboard log replay matches array reference for deterministic mixed operations', () => {
+ let randomState = 0x5eed1234;
+ const random = (limit: number): number => {
+ randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0;
+ return randomState % limit;
+ };
+ const operations: ReferenceOp[] = [];
+
+ for (let index = 0; index < 2_000; index++) {
+ const id = String(random(80) + 1);
+ const operation = random(10);
+ if (operation < 4) {
+ operations.push({
+ op: 'add',
+ id,
+ kind: operation === 0 ? 'image' : 'text',
+ text: `entry-${index}`,
+ timestamp: index,
+ mimeType: operation === 0 ? 'image/png' : undefined,
+ filePath: operation === 0 ? `/tmp/${index}.png` : undefined,
+ contentKey: operation % 2 === 0 ? `key-${index}` : undefined,
+ });
+ } else {
+ const names = ['delete', 'move', 'pin', 'unpin'] as const;
+ operations.push({ op: names[random(names.length)], id });
+ }
+ }
+
+ const validSource = operations.map((operation) => JSON.stringify(operation) + '\n').join('');
+ const malformedTail = '{"op":"add"';
+
+ assert.deepEqual(
+ parseClipboardLog(validSource + malformedTail),
+ parseClipboardLogReference(validSource),
+ );
+});
+
+test('large clipboard log replay avoids array scans while preserving order', () => {
+ const operations: string[] = [];
+ const entryCount = 20_000;
+ for (let index = 1; index <= entryCount; index++) {
+ operations.push(
+ JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }),
+ );
+ }
+ for (let index = 1; index <= entryCount; index++) {
+ operations.push(JSON.stringify({ op: 'move', id: String(index) }));
+ }
+
+ const originalIndexOf = Array.prototype.indexOf;
+ let arrayScans = 0;
+ Array.prototype.indexOf = function (this: T[], searchElement: T, fromIndex?: number): number {
+ arrayScans++;
+ return originalIndexOf.call(this, searchElement, fromIndex);
+ };
+
+ try {
+ const state = parseClipboardLog(operations.join('\n'));
+
+ assert.equal(arrayScans, 0);
+ assert.equal(state.history.length, entryCount);
+ assert.equal(state.history[0]?.id, String(entryCount));
+ assert.equal(state.history.at(-1)?.id, '1');
+ assert.equal(state.wastedOps, entryCount);
+ } finally {
+ Array.prototype.indexOf = originalIndexOf;
+ }
+});
+
+function parseClipboardLogReference(source: string) {
+ const pinned: ClipboardEntrySnapshot[] = [];
+ const history: ClipboardEntrySnapshot[] = [];
+ const byId = new Map();
+ let nextId = 1;
+ let wastedOps = 0;
+
+ for (const line of source.split('\n')) {
+ if (!line) continue;
+
+ let op: ReferenceOp;
+ try {
+ op = JSON.parse(line) as ReferenceOp;
+ } catch {
+ break;
+ }
+
+ if (op.op === 'add') {
+ const entry: ClipboardEntrySnapshot = {
+ id: op.id,
+ kind: op.kind || 'text',
+ text: op.text,
+ pinned: false,
+ timestamp: op.timestamp,
+ contentKey: op.contentKey || op.text,
+ };
+ if (op.mimeType) entry.mimeType = op.mimeType;
+ if (op.filePath) entry.filePath = op.filePath;
+ byId.set(entry.id, entry);
+ history.unshift(entry);
+ nextId = Math.max(nextId, Number.parseInt(entry.id, 10) + 1 || nextId);
+ continue;
+ }
+
+ const entry = byId.get(op.id);
+ if (!entry) continue;
+
+ if (op.op === 'delete') {
+ removeClipboardEntry(pinned, entry);
+ removeClipboardEntry(history, entry);
+ byId.delete(op.id);
+ wastedOps += 2;
+ } else if (op.op === 'move') {
+ const list = entry.pinned ? pinned : history;
+ moveReferenceEntryToFront(list, entry);
+ wastedOps += 1;
+ } else if (op.op === 'pin') {
+ removeClipboardEntry(history, entry);
+ entry.pinned = true;
+ moveReferenceEntryToFront(pinned, entry);
+ } else if (op.op === 'unpin') {
+ removeClipboardEntry(pinned, entry);
+ entry.pinned = false;
+ moveReferenceEntryToFront(history, entry);
+ wastedOps += 2;
+ }
+ }
+
+ return { pinned, history, nextId, wastedOps };
+}
+
+function moveReferenceEntryToFront(
+ list: ClipboardEntrySnapshot[],
+ entry: ClipboardEntrySnapshot,
+): void {
+ removeClipboardEntry(list, entry);
+ list.unshift(entry);
+}
diff --git a/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts b/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts
index 72fdbea..23844d0 100644
--- a/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts
+++ b/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts
@@ -6,6 +6,7 @@ import {
getStartReminderEvents,
getStartReminderId,
normalizeCalendarServerEvent,
+ removeCalendarEventsByPrefix,
type CalendarEvent,
} from '~/panel/clock/calendarReminders/calendarRemindersLogic.ts';
@@ -44,6 +45,18 @@ test('calendarReminders: normalizes CalendarServer identity and times', () => {
assert.strictEqual(normalized.startEpochSeconds, NOW + 60);
});
+test('calendarReminders: removes every recurring instance matching a server prefix', () => {
+ const events = new Map([
+ ['calendar-1\nevent-1\nfirst', event({ id: 'calendar-1\nevent-1\nfirst' })],
+ ['calendar-1\nevent-1\nsecond', event({ id: 'calendar-1\nevent-1\nsecond' })],
+ ['calendar-1\nevent-2\nfirst', event({ id: 'calendar-1\nevent-2\nfirst' })],
+ ]);
+
+ assert.equal(removeCalendarEventsByPrefix(events, 'calendar-1\nevent-1\n'), true);
+ assert.deepEqual([...events.keys()], ['calendar-1\nevent-2\nfirst']);
+ assert.equal(removeCalendarEventsByPrefix(events, 'missing\n'), false);
+});
+
test('calendarReminders: start reminders exclude stale, ended and all-day events', () => {
const events = [
event({ id: 'future', startEpochSeconds: NOW + 60 }),
diff --git a/tests/unit/patches/iconWeaveRegistry.test.ts b/tests/unit/patches/iconWeaveRegistry.test.ts
index c760060..5fe14a6 100644
--- a/tests/unit/patches/iconWeaveRegistry.test.ts
+++ b/tests/unit/patches/iconWeaveRegistry.test.ts
@@ -1,7 +1,10 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { IconWeaveWindowRegistry } from '~/patches/iconWeaveRegistry.ts';
+import {
+ createIconWeaveResolutionKey,
+ IconWeaveWindowRegistry,
+} from '~/patches/iconWeaveRegistry.ts';
function fakeWindow(wmClass: string, appId: string) {
return {
@@ -10,43 +13,61 @@ function fakeWindow(wmClass: string, appId: string) {
};
}
-test('icon weave registry reuses mappings with the same application identity', () => {
+test('icon weave registry exposes current window mappings', () => {
const registry = new IconWeaveWindowRegistry();
const application = { id: 'example.desktop' };
const first = fakeWindow('Example', 'com.example.App');
registry.map(first, application);
- assert.equal(registry.findMappedApp('Example', ''), application);
- assert.equal(registry.findMappedApp('', 'com.example.App'), application);
- assert.equal(registry.findMappedApp('Different', 'different.app'), null);
+ assert.equal(registry.mappings.get(first), application);
});
-test('icon weave registry retains a processed identity until its last mapping is removed', () => {
+test('icon weave registry retains cached resolutions after mappings are removed', () => {
const registry = new IconWeaveWindowRegistry();
const application = { id: 'example.desktop' };
const first = fakeWindow('Example', 'com.example.App');
const second = fakeWindow('Example', 'com.example.App');
+ const key = createIconWeaveResolutionKey('Example', 'com.example.App', 'Example');
registry.map(first, application);
registry.map(second, application);
- registry.markProcessed('Example');
+ registry.setResolvedApp(key, 'example.desktop');
- registry.remove(first, 'Example', 'com.example.App');
- assert.equal(registry.hasProcessed('Example'), true);
+ registry.remove(first);
+ assert.equal(registry.getResolvedApp(key), 'example.desktop');
- registry.remove(second, 'Example', 'com.example.App');
- assert.equal(registry.hasProcessed('Example'), false);
+ registry.remove(second);
+ assert.equal(registry.getResolvedApp(key), 'example.desktop');
});
-test('icon weave registry clears mappings and processed identities together', () => {
+test('icon weave resolution keys distinguish title-dependent matches', () => {
+ const first = createIconWeaveResolutionKey('Example', 'com.example.App', 'First');
+ const second = createIconWeaveResolutionKey('Example', 'com.example.App', 'Second');
+
+ assert.notEqual(first, second);
+});
+
+test('icon weave registry invalidates resolutions without dropping live mappings', () => {
const registry = new IconWeaveWindowRegistry();
const window = fakeWindow('Example', 'com.example.App');
+ const key = createIconWeaveResolutionKey('Example', 'com.example.App', 'Example');
registry.map(window, { id: 'example.desktop' });
- registry.markProcessed('Example');
- registry.clear();
+ registry.setResolvedApp(key, null);
+ registry.clearResolutions();
+
+ assert.equal(registry.mappings.size, 1);
+ assert.equal(registry.hasResolved(key), false);
+});
+
+test('icon weave registry bounds persistent title resolutions', () => {
+ const registry = new IconWeaveWindowRegistry();
+
+ for (let index = 0; index <= 256; index++) {
+ registry.setResolvedApp(`key-${index}`, `app-${index}.desktop`);
+ }
- assert.equal(registry.mappings.size, 0);
- assert.equal(registry.hasProcessed('Example'), false);
+ assert.equal(registry.hasResolved('key-0'), false);
+ assert.equal(registry.getResolvedApp('key-256'), 'app-256.desktop');
});
diff --git a/tests/unit/patches/iconWeaveScoring.test.ts b/tests/unit/patches/iconWeaveScoring.test.ts
index 1927d41..ff4a002 100644
--- a/tests/unit/patches/iconWeaveScoring.test.ts
+++ b/tests/unit/patches/iconWeaveScoring.test.ts
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
+ createIconWeaveCandidateMetadata,
+ isIconWeaveSteamGame,
registerIconWeaveWindow,
scoreIconWeaveCandidate,
unregisterIconWeaveWindow,
@@ -9,10 +11,13 @@ import {
const MIN_MATCH_SCORE = 50;
+function candidate(desktopId: string, appName: string, executable = '') {
+ return createIconWeaveCandidateMetadata(desktopId, appName, executable);
+}
+
test('IconWeave scoring rejects helper classes that only share a generic short token', () => {
const score = scoreIconWeaveCandidate({
- desktopId: 'io.ente.auth',
- appName: 'Ente Auth',
+ candidate: candidate('io.ente.auth', 'Ente Auth'),
wmClass: 'nm-openconnect-auth-dialog',
appId: '',
title: 'Authentication Required',
@@ -31,8 +36,7 @@ test('IconWeave registration updates immutably and removes mapped windows', () =
test('IconWeave scoring keeps exact identity matches strong', () => {
const score = scoreIconWeaveCandidate({
- desktopId: 'io.ente.auth',
- appName: 'Ente Auth',
+ candidate: candidate('io.ente.auth', 'Ente Auth'),
wmClass: 'io.ente.auth',
appId: '',
title: '',
@@ -43,8 +47,7 @@ test('IconWeave scoring keeps exact identity matches strong', () => {
test('IconWeave scoring keeps compact short-id variants matchable', () => {
const score = scoreIconWeaveCandidate({
- desktopId: 'com.discordapp.Discord',
- appName: 'Discord',
+ candidate: candidate('com.discordapp.Discord', 'Discord'),
wmClass: 'discordcanary',
appId: '',
title: '',
@@ -52,3 +55,48 @@ test('IconWeave scoring keeps compact short-id variants matchable', () => {
assert.ok(score >= MIN_MATCH_SCORE);
});
+
+test('IconWeave candidate metadata normalizes desktop identity once', () => {
+ const metadata = candidate(
+ 'Com.ValveSoftware.Game.desktop',
+ 'Half-Life 2',
+ 'steam steam://rungameid/220',
+ );
+
+ assert.deepEqual(metadata, {
+ desktopId: 'com.valvesoftware.game',
+ appName: 'half-life 2',
+ shortId: 'game',
+ normalizedDesktopId: 'comvalvesoftwaregame',
+ normalizedAppName: 'halflife2',
+ normalizedShortId: 'game',
+ abbreviation: 'hl2',
+ steamGameId: '220',
+ });
+});
+
+test('IconWeave Steam matching uses cached Exec ID and name abbreviation', () => {
+ const metadata = candidate('half-life-2.desktop', 'Half-Life 2', 'steam://rungameid/220');
+
+ assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_220'), true);
+ assert.equal(isIconWeaveSteamGame(metadata, 'hl2'), true);
+ assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_221'), false);
+ assert.equal(isIconWeaveSteamGame(candidate('half-life-2.desktop', 'Half-Life 2'), 'hl2'), false);
+});
+
+test('IconWeave Steam identity takes precedence over ordinary candidate scoring', () => {
+ const metadata = candidate(
+ 'com.valvesoftware.steam-app-220.desktop',
+ 'Half-Life 2',
+ 'steam://rungameid/220',
+ );
+ const ordinaryScore = scoreIconWeaveCandidate({
+ candidate: metadata,
+ wmClass: 'steam_app_220',
+ appId: '',
+ title: '',
+ });
+
+ assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_220'), true);
+ assert.equal(ordinaryScore, 99);
+});