Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/components/SelectableVisualization.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export default {
},
activity: {
title: 'Активность',
browserAllowlistMiss:
'Для этого периода не найдено подходящее окно браузера. Если вы использовали форк Chromium/Firefox, имя его приложения может пока не распознаваться.',
for: 'для',
host: 'Хост:',
timeActive: 'Активное время:',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/uk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export default {
},
activity: {
title: 'Активність',
browserAllowlistMiss:
'Для цього періоду не знайдено відповідного вікна браузера. Якщо ви користувалися форком Chromium/Firefox, назва його застосунку може ще не розпізнаватися.',
for: 'для',
host: 'Хост:',
timeActive: 'Активний час:',
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ export default {
},
activity: {
title: '活动',
browserAllowlistMiss:
'此时间段内未找到匹配的浏览器窗口。如果您使用的是 Chromium/Firefox 衍生浏览器,其应用名称可能尚未被识别。',
for: '查看',
host: '主机:',
timeActive: '活跃时间:',
Expand Down
40 changes: 35 additions & 5 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
chrome: ['com.google.Chrome', 'com.google.ChromeDev', 'org.chromium.Chromium'],
export const browser_appnames: Record<string, string[]> = {
// 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'],
Expand Down Expand Up @@ -294,6 +302,7 @@ export const browser_appname_regex: Record<string, string> = {
// 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)?$)',
Comment thread
TimeToBuildBob marked this conversation as resolved.
firefox: '(?i)(firefox|librewolf|waterfox|nightly)',
opera: '(?i)(opera)',
Expand All @@ -310,29 +319,50 @@ export const browser_appname_regex: Record<string, string> = {

// 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 += `
window_${browserName}_re = filter_keyvals_regex(events, "app", ${JSON.stringify(pattern)});
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;
}

Expand Down
21 changes: 21 additions & 0 deletions src/util/browserAllowlist.ts
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 23 additions & 0 deletions test/unit/browserAllowlist.test.node.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
87 changes: 85 additions & 2 deletions test/unit/queries.test.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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};';
Expand Down
Loading