diff --git a/src/components/SelectableVisualization.vue b/src/components/SelectableVisualization.vue index 8d5b225c..5da6772c 100644 --- a/src/components/SelectableVisualization.vue +++ b/src/components/SelectableVisualization.vue @@ -38,6 +38,9 @@ div(v-if="editable || !activityStore.buckets.loaded || has_prerequisites || !set :namefunc="e => e.data.classname", :colorfunc="e => e.data.app", with_limit) + b-alert.small.px-2.py-1(v-if="isBrowserVis && browserAllowlistMiss" show variant="info") + | {{ $t('activity.browserAllowlistMiss') }} + | (#[a(href="https://github.com/ActivityWatch/aw-webui/issues/927") #927]) div(v-if="type == 'top_domains'") aw-summary(:fields="activityStore.browser.top_domains", :namefunc="e => e.data.$domain", @@ -132,6 +135,7 @@ import { useCategoryStore } from '~/stores/categories'; import { useBucketsStore } from '~/stores/buckets'; import { useViewsStore } from '~/stores/views'; import { useSettingsStore } from '~/stores/settings'; +import { isBrowserAllowlistMiss } from '~/util/browserAllowlist'; import moment from 'moment'; @@ -282,6 +286,12 @@ export default { has_prerequisites() { return this.visualizations[this.type].available; }, + isBrowserVis() { + return ['top_domains', 'top_urls', 'top_browser_titles'].includes(this.type); + }, + browserAllowlistMiss() { + return isBrowserAllowlistMiss(this.activityStore.browser); + }, supports_period: function () { if (this.type == 'sunburst_clock' || this.type == 'vis_timeline') { return this.isSingleDay; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ef9fa381..d02fbe40 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -268,6 +268,8 @@ export default { }, activity: { title: 'Aktivität', + browserAllowlistMiss: + 'Für diesen Zeitraum wurde kein passendes Browserfenster gefunden. Falls Sie in einem Chromium-/Firefox-Derivat gesurft haben, wird dessen App-Name möglicherweise noch nicht erkannt.', for: 'für', host: 'Host:', timeActive: 'Aktive Zeit:', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index cced80c7..1b57c699 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -269,6 +269,8 @@ export default { }, activity: { title: 'Activity', + browserAllowlistMiss: + 'No matching browser window for this period. If you were browsing in a Chromium/Firefox fork, its app name may not be recognized yet.', for: 'for', host: 'Host:', timeActive: 'Time active:', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9eac8ae2..98bf6b6d 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -265,6 +265,8 @@ export default { }, activity: { title: 'Активность', + browserAllowlistMiss: + 'Для этого периода не найдено подходящее окно браузера. Если вы использовали форк Chromium/Firefox, имя его приложения может пока не распознаваться.', for: 'для', host: 'Хост:', timeActive: 'Активное время:', diff --git a/src/i18n/locales/sv.ts b/src/i18n/locales/sv.ts index 4af2baf1..a4ef2911 100644 --- a/src/i18n/locales/sv.ts +++ b/src/i18n/locales/sv.ts @@ -273,6 +273,8 @@ export default { }, activity: { title: 'Aktivitet', + browserAllowlistMiss: + 'Inget matchande webbläsarfönster för den här perioden. Om du surfade i en Chromium-/Firefox-fork kanske dess appnamn inte känns igen ännu.', for: 'för', host: 'Värd:', timeActive: 'Aktiv tid:', diff --git a/src/i18n/locales/uk.ts b/src/i18n/locales/uk.ts index 86808d45..59d8d2b2 100644 --- a/src/i18n/locales/uk.ts +++ b/src/i18n/locales/uk.ts @@ -265,6 +265,8 @@ export default { }, activity: { title: 'Активність', + browserAllowlistMiss: + 'Для цього періоду не знайдено відповідного вікна браузера. Якщо ви користувалися форком Chromium/Firefox, назва його застосунку може ще не розпізнаватися.', for: 'для', host: 'Хост:', timeActive: 'Активний час:', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 87a27510..5336274b 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -260,6 +260,8 @@ export default { }, activity: { title: '活动', + browserAllowlistMiss: + '此时间段内未找到匹配的浏览器窗口。如果您使用的是 Chromium/Firefox 衍生浏览器,其应用名称可能尚未被识别。', for: '查看', host: '主机:', timeActive: '活跃时间:', diff --git a/src/queries.ts b/src/queries.ts index 10b51e6d..dfc742c3 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -255,8 +255,16 @@ export function appQuery( // variants (upper/lowercase, spacing, .exe suffix) are handled by // browser_appname_regex using (?i) flag. See test/unit/queries.test.node.ts for // the complete list of known app names these patterns cover. -const browser_appnames: Record = { - chrome: ['com.google.Chrome', 'com.google.ChromeDev', 'org.chromium.Chromium'], +export const browser_appnames: Record = { + // Chromium forks (Dia, Arc) run the chrome extension by default, so their + // web events land in the chrome bucket. Reverse-domain identifiers don't + // match the process-name regex below and have to live here (#927). + chrome: [ + 'com.google.Chrome', + 'com.google.ChromeDev', + 'org.chromium.Chromium', + 'company.thebrowser.dia', + ], firefox: ['org.mozilla.firefox', 'io.gitlab.librewolf-community', 'net.waterfox.waterfox'], opera: ['com.opera.Opera'], brave: ['com.brave.Browser'], @@ -294,6 +302,7 @@ export const browser_appname_regex: Record = { // default their events land in the chrome bucket and their app names have to be matched // here (#927, ActivityWatch/activitywatch#1094). The standalone arc key below only covers // setups where Arc was picked explicitly in the settings, which changes the bucket name. + // Fork alternatives are $-anchored so names like "archive" / "Dialog" don't match. chrome: '(?i)^(google[-_ ]?chrome|chrome|chromium|arc(\\.exe)?$|dia(\\.exe)?$)', firefox: '(?i)(firefox|librewolf|waterfox|nightly)', opera: '(?i)(opera)', @@ -310,16 +319,25 @@ export const browser_appname_regex: Record = { // Returns a list of active browser events (where the browser was the active window) from all browser buckets function browserEvents(params: DesktopQueryParams): string { + const browsers = browsersWithBuckets(params.bid_browsers); + // Chrome regex also matches Arc, and a settings-override Arc bucket can + // coexist with the default chrome bucket. Those two streams can duplicate + // the same Arc activity; union_no_overlap is only for that pair. Distinct + // browsers (Chrome + Firefox, etc.) may overlap in time and must concat. + const mixChromeArc = + browsers.some(([browserName]) => browserName === 'chrome') && + browsers.some(([browserName]) => browserName === 'arc'); + let code = ` browser_events = []; `; - _.each(browsersWithBuckets(params.bid_browsers), ([browserName, bucketId]) => { + _.each(browsers, ([browserName, bucketId]) => { const browser_appnames_str = JSON.stringify(browser_appnames[browserName]); code += `events_${browserName} = flood(query_bucket("${bucketId}")); window_${browserName} = filter_keyvals(events, "app", ${browser_appnames_str});`; - // Add regex-based matching to cover case/spacing/versioning variants (e.g., Firefox.exe, firefox-esr-esr140) + // Add regex-based matching to cover case/spacing/versioning variants (e.g., Firefox.exe, firefox-esr-esr140). const pattern = browser_appname_regex[browserName]; if (pattern) { code += ` @@ -327,12 +345,24 @@ function browserEvents(params: DesktopQueryParams): string { window_${browserName} = sort_by_timestamp(concat(window_${browserName}, window_${browserName}_re));`; } + const combineChromeArcDup = mixChromeArc && (browserName === 'chrome' || browserName === 'arc'); code += ` events_${browserName} = filter_period_intersect(events_${browserName}, window_${browserName}); - events_${browserName} = split_url_events(events_${browserName}); + events_${browserName} = split_url_events(events_${browserName});`; + if (!combineChromeArcDup) { + code += ` browser_events = concat(browser_events, events_${browserName}); browser_events = sort_by_timestamp(browser_events);`; + } }); + + if (mixChromeArc) { + // Chrome first so current chrome-bucket events win over a stale Arc bucket. + code += ` + chrome_arc_events = union_no_overlap(events_chrome, events_arc); + browser_events = concat(browser_events, chrome_arc_events); + browser_events = sort_by_timestamp(browser_events);`; + } return code; } diff --git a/src/util/browserAllowlist.ts b/src/util/browserAllowlist.ts new file mode 100644 index 00000000..2dba09cd --- /dev/null +++ b/src/util/browserAllowlist.ts @@ -0,0 +1,21 @@ +/** + * Detect the silent-empty Browser view: a browser watcher bucket exists, the + * query finished, and the window-event intersection came back empty. + * + * This is the Chromium-fork failure mode (aw-webui#927): events land in + * `aw-watcher-web-chrome_*` but `app` is "Dia"/"Arc"/… and matches nothing. + * It is also the honest "didn't browse this period" case, so the UI copy + * must cover both. + * + * `top_domains === null` means the query is still in flight (see + * `start_loading` in the activity store) and must not fire the hint. + */ +export function isBrowserAllowlistMiss(browser: { + available: boolean; + duration: number; + top_domains: unknown[] | null; +}): boolean { + if (!browser.available) return false; + if (browser.top_domains === null) return false; + return browser.duration === 0 && browser.top_domains.length === 0; +} diff --git a/test/unit/browserAllowlist.test.node.ts b/test/unit/browserAllowlist.test.node.ts new file mode 100644 index 00000000..6570f13d --- /dev/null +++ b/test/unit/browserAllowlist.test.node.ts @@ -0,0 +1,23 @@ +import { isBrowserAllowlistMiss } from '~/util/browserAllowlist'; + +describe('isBrowserAllowlistMiss', () => { + const empty = { available: true, duration: 0, top_domains: [] as unknown[] }; + + test('true when a browser bucket exists but the window intersection is empty', () => { + expect(isBrowserAllowlistMiss(empty)).toBe(true); + }); + + test('false while the query is still in flight (null fields)', () => { + expect(isBrowserAllowlistMiss({ ...empty, top_domains: null })).toBe(false); + }); + + test('false when no browser watcher bucket is present', () => { + expect(isBrowserAllowlistMiss({ ...empty, available: false })).toBe(false); + }); + + test('false when matched browser events exist', () => { + expect(isBrowserAllowlistMiss({ available: true, duration: 12, top_domains: [{}] })).toBe( + false + ); + }); +}); diff --git a/test/unit/queries.test.node.ts b/test/unit/queries.test.node.ts index 9b15c23e..45065e4a 100644 --- a/test/unit/queries.test.node.ts +++ b/test/unit/queries.test.node.ts @@ -14,6 +14,9 @@ * 'Google-chrome-beta', 'Google-chrome-unstable' * (Flatpak app IDs retained as exact: 'com.google.Chrome', 'com.google.ChromeDev', * 'org.chromium.Chromium') + * Chromium forks that report through the chrome extension bucket (#927): + * 'Arc', 'arc.exe', 'Arc.exe', 'Dia', 'Dia.exe' + * (macOS bundle ID retained as exact: 'company.thebrowser.dia') * * Firefox: 'Firefox', 'Firefox.exe', 'firefox', 'firefox.exe', * 'Firefox Developer Edition', 'firefoxdeveloperedition', @@ -55,7 +58,12 @@ * (Flatpak app ID retained: 'one.ablaze.floorp') */ -import { browser_appname_regex, querystr_to_array } from '~/queries'; +import { + browser_appname_regex, + browser_appnames, + fullDesktopQuery, + querystr_to_array, +} from '~/queries'; // Convert ActivityWatch (?i) patterns to JS RegExp with i flag for testing. // AW server uses Python-style (?i) inline flag; JS uses RegExp 'i' flag instead. @@ -97,8 +105,9 @@ describe('browser_appname_regex', () => { test('chrome pattern does not false-positive', () => { const re = toRegex(browser_appname_regex.chrome); - // Flatpak app IDs are in the exact list, not matched by regex + // Flatpak / bundle IDs are in the exact list, not matched by regex expect(re.test('com.google.Chrome')).toBe(false); + expect(re.test('company.thebrowser.dia')).toBe(false); expect(re.test('Slack')).toBe(false); expect(re.test('Electron')).toBe(false); // The fork alternatives are anchored, so names merely starting with them don't match @@ -107,6 +116,10 @@ describe('browser_appname_regex', () => { expect(re.test('Dialog')).toBe(false); }); + test('chrome exact list includes the Dia macOS bundle id', () => { + expect(browser_appnames.chrome).toContain('company.thebrowser.dia'); + }); + test('firefox pattern matches all known Firefox/LibreWolf/Waterfox app names', () => { const re = toRegex(browser_appname_regex.firefox); // Every entry from the old exact-match list @@ -244,6 +257,76 @@ describe('browser_appname_regex', () => { }); }); +describe('chrome fork matching in generated query', () => { + const params = { + bid_window: 'aw-watcher-window_testhost', + bid_afk: 'aw-watcher-afk_testhost', + filter_afk: true, + include_audible: false, + categories: [], + filter_categories: [], + }; + + test('chrome bucket query includes Dia bundle id and process-name regex', () => { + const query = fullDesktopQuery({ + ...params, + bid_browsers: ['aw-watcher-web-chrome_testhost'], + }).join('\n'); + expect(query).toContain('company.thebrowser.dia'); + // JSON.stringify doubles the regex backslash, so the query text has \\. + expect(query).toContain('dia(\\\\.exe)?$'); + }); + + test('mixed chrome and Arc buckets preserve both matching paths without overlap', () => { + const query = fullDesktopQuery({ + ...params, + bid_browsers: ['aw-watcher-web-chrome_testhost', 'aw-watcher-web-arc_testhost'], + }).join('\n'); + const chromeWindowFilter = query.slice( + query.indexOf('window_chrome_re ='), + query.indexOf('events_chrome = filter_period_intersect') + ); + expect(chromeWindowFilter).toContain('arc(\\\\.exe)?$'); + expect(query).toContain('window_arc_re ='); + expect(query).toContain('arc(\\\\.exe)?$'); + // Duplicate chrome/Arc streams are unioned with each other, not with every browser. + expect(query).toContain('chrome_arc_events = union_no_overlap(events_chrome, events_arc);'); + expect(query).toContain('browser_events = concat(browser_events, chrome_arc_events);'); + expect(query).not.toContain( + 'browser_events = union_no_overlap(browser_events, events_chrome);' + ); + expect(query).not.toContain('browser_events = union_no_overlap(browser_events, events_arc);'); + }); + + test('unrelated browser buckets concat instead of dropping overlaps', () => { + const query = fullDesktopQuery({ + ...params, + bid_browsers: ['aw-watcher-web-chrome_testhost', 'aw-watcher-web-firefox_testhost'], + }).join('\n'); + expect(query).toContain('browser_events = concat(browser_events, events_chrome);'); + expect(query).toContain('browser_events = concat(browser_events, events_firefox);'); + expect(query).not.toContain('union_no_overlap(browser_events, events_'); + expect(query).not.toContain('union_no_overlap(events_chrome, events_firefox)'); + expect(query).not.toContain('chrome_arc_events'); + }); + + test('chrome+arc union does not swallow a third browser', () => { + const query = fullDesktopQuery({ + ...params, + bid_browsers: [ + 'aw-watcher-web-chrome_testhost', + 'aw-watcher-web-arc_testhost', + 'aw-watcher-web-firefox_testhost', + ], + }).join('\n'); + expect(query).toContain('chrome_arc_events = union_no_overlap(events_chrome, events_arc);'); + expect(query).toContain('browser_events = concat(browser_events, events_firefox);'); + expect(query).toContain('browser_events = concat(browser_events, chrome_arc_events);'); + expect(query).not.toContain('union_no_overlap(browser_events, events_firefox)'); + expect(query).not.toContain('union_no_overlap(browser_events, events_chrome)'); + }); +}); + describe('querystr_to_array', () => { test('splits simple multi-statement query correctly', () => { const query = 'events = query_bucket("aw-watcher-window_host"); RETURN = {"events": events};';