diff --git a/global.d.ts b/global.d.ts index 0f1bd942..5560d4f0 100644 --- a/global.d.ts +++ b/global.d.ts @@ -110,10 +110,6 @@ declare namespace timer { } type StatisticsOption = { - /** - * Count when idle - */ - countWhenIdle: boolean /** * Whether to collect the site name * @@ -230,6 +226,12 @@ declare namespace timer { | 'mn' namespace stat { + type Event = { + start: number + end: number + url: string + ignoreTabCheck: boolean + } /** * The dimension to statistics */ @@ -530,6 +532,7 @@ declare namespace timer { | "cs.getTodayInfo" | "cs.moreMinutes" | "cs.getLimitedRules" + | "cs.trackTime" type ResCode = "success" | "fail" | "ignore" /** @@ -550,7 +553,7 @@ declare namespace timer { /** * @since 1.3.0 */ - type Handler = (data: Req, sender: chrome.runtime.MessageSender) => Promise + type Handler = (data: Req, sender?: chrome.runtime.MessageSender) => Promise /** * @since 0.8.4 */ @@ -575,6 +578,4 @@ declare type ChromeAlarm = chrome.alarms.Alarm // chrome.runtime declare type ChromeOnInstalledReason = chrome.runtime.OnInstalledReason declare type ChromeMessageSender = chrome.runtime.MessageSender -declare type ChromeMessageHandler = (req: timer.mq.Request, sender: ChromeMessageSender) => Promise> -// chrome.idle -declare type ChromeIdleState = chrome.idle.IdleState \ No newline at end of file +declare type ChromeMessageHandler = (req: timer.mq.Request, sender: ChromeMessageSender) => Promise> \ No newline at end of file diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 1f5dc232..31edeba1 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -214,13 +214,3 @@ export async function checkMainBranch(client: CrowdinClient) { } return branch } - -// function main() { -// const file = fs.readFileSync(path.join(MSG_BASE, 'app', 'habit.ts'), { encoding: 'utf-8' }) -// const result = /(const|let|var) _default(.*)=\s*\{\s*(\n?.*\n)+\}/.exec(file) -// const origin = result[0] -// console.log(origin) -// console.log(file.indexOf(origin)) -// } - -// main() diff --git a/src/api/chrome/context-menu.ts b/src/api/chrome/context-menu.ts index e9c5596e..ade501e5 100644 --- a/src/api/chrome/context-menu.ts +++ b/src/api/chrome/context-menu.ts @@ -1,8 +1,19 @@ +import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" +function onClick(id: string, handler: Function) { + chrome.contextMenus.onClicked.addListener(({ menuItemId }) => menuItemId === id && handler?.()) +} + export function createContextMenu(props: ChromeContextMenuCreateProps): Promise { + let clickHandler: Function = undefined + if (IS_MV3) { + clickHandler = props.onclick + delete props.onclick + } return new Promise(resolve => chrome.contextMenus.create(props, () => { handleError('createContextMenu') + clickHandler && onClick(props.id, clickHandler) resolve() })) } diff --git a/src/api/chrome/idle.ts b/src/api/chrome/idle.ts deleted file mode 100644 index 3b02970b..00000000 --- a/src/api/chrome/idle.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function onIdleStateChanged(handler: (idleState: ChromeIdleState) => void) { - chrome.idle.onStateChanged.addListener(handler) -} \ No newline at end of file diff --git a/src/app/components/option/components/statistics.ts b/src/app/components/option/components/statistics.ts index 741f59dc..6ed11768 100644 --- a/src/app/components/option/components/statistics.ts +++ b/src/app/components/option/components/statistics.ts @@ -13,17 +13,12 @@ import { defaultStatistics } from "@util/constant/option" import { defineComponent, h, reactive, unref } from "vue" import { t } from "@app/locale" import { renderOptionItem, tagText, tooltip } from "../common" -import { IS_SAFARI } from "@util/constant/environment" function updateOptionVal(key: keyof timer.option.StatisticsOption, newVal: boolean, option: UnwrapRef) { option[key] = newVal optionService.setStatisticsOption(unref(option)) } -const countWhenIdle = (option: UnwrapRef) => h(ElSwitch, { - modelValue: option.countWhenIdle, - onChange: (newVal: boolean) => updateOptionVal('countWhenIdle', newVal, option) -}) const countLocalFiles = (option: UnwrapRef) => h(ElSwitch, { modelValue: option.countLocalFiles, @@ -36,40 +31,10 @@ const collectSiteName = (option: UnwrapRef) => h( }) function copy(target: timer.option.StatisticsOption, source: timer.option.StatisticsOption) { - target.countWhenIdle = source.countWhenIdle target.collectSiteName = source.collectSiteName target.countLocalFiles = source.countLocalFiles } -function renderOptionItems(option: timer.option.StatisticsOption) { - const result = [] - if (!IS_SAFARI) { - // chrome.idle does not work in Safari, so not to display this option - result.push( - renderOptionItem({ - input: countWhenIdle(option), - idleTime: tagText(msg => msg.option.statistics.idleTime), - info: tooltip(msg => msg.option.statistics.idleTimeInfo) - }, msg => msg.statistics.countWhenIdle, t(msg => msg.option.yes)), - h(ElDivider) - ) - } - result.push( - renderOptionItem({ - input: countLocalFiles(option), - localFileTime: tagText(msg => msg.option.statistics.localFileTime), - info: tooltip(msg => msg.option.statistics.localFilesInfo) - }, msg => msg.statistics.countLocalFiles, t(msg => msg.option.no)), - h(ElDivider), - renderOptionItem({ - input: collectSiteName(option), - siteName: tagText(msg => msg.option.statistics.siteName), - siteNameUsage: tooltip(msg => msg.option.statistics.siteNameUsage) - }, msg => msg.statistics.collectSiteName, t(msg => msg.option.yes)) - ) - return result -} - const _default = defineComponent({ name: "StatisticsOptionContainer", setup(_props, ctx) { @@ -81,7 +46,19 @@ const _default = defineComponent({ await optionService.setStatisticsOption(unref(option)) } }) - return () => h('div', renderOptionItems(option)) + return () => h('div', [ + renderOptionItem({ + input: countLocalFiles(option), + localFileTime: tagText(msg => msg.option.statistics.localFileTime), + info: tooltip(msg => msg.option.statistics.localFilesInfo) + }, msg => msg.statistics.countLocalFiles, t(msg => msg.option.no)), + h(ElDivider), + renderOptionItem({ + input: collectSiteName(option), + siteName: tagText(msg => msg.option.statistics.siteName), + siteNameUsage: tooltip(msg => msg.option.statistics.siteNameUsage) + }, msg => msg.statistics.collectSiteName, t(msg => msg.option.yes)) + ]) } }) diff --git a/src/background/active-tab-listener.ts b/src/background/active-tab-listener.ts index 4bcccee1..4e579b51 100644 --- a/src/background/active-tab-listener.ts +++ b/src/background/active-tab-listener.ts @@ -34,7 +34,7 @@ export default class ActiveTabListener { listen() { onTabActivated(async tabId => { const tab = await getTab(tabId) - this.processWithTabInfo(tab) + tab && this.processWithTabInfo(tab) }) } } diff --git a/src/background/badge-text-manager.ts b/src/background/badge-text-manager.ts index 101e1006..a05ca95a 100644 --- a/src/background/badge-text-manager.ts +++ b/src/background/badge-text-manager.ts @@ -12,7 +12,6 @@ import TimerDatabase from "@db/timer-database" import whitelistHolder from "@service/components/whitelist-holder" import optionService from "@service/option-service" import { extractHostname, isBrowserUrl } from "@util/pattern" -import alarmManager from "./alarm-manager" const storage = chrome.storage.local const timerDb: TimerDatabase = new TimerDatabase(storage) @@ -26,6 +25,7 @@ export type BadgeLocation = { * The url of tab */ url: string + focus?: number } function mill2Str(milliseconds: number) { @@ -67,7 +67,7 @@ async function updateFocus(badgeLocation?: BadgeLocation, lastLocation?: BadgeLo if (!badgeLocation) { return badgeLocation } - const { url, tabId } = badgeLocation + const { url, tabId, focus } = badgeLocation if (!url || isBrowserUrl(url)) { return badgeLocation } @@ -76,7 +76,7 @@ async function updateFocus(badgeLocation?: BadgeLocation, lastLocation?: BadgeLo setBadgeText('W', tabId) return badgeLocation } - const milliseconds = host ? (await timerDb.get(host, new Date())).focus : undefined + const milliseconds = focus || (host ? (await timerDb.get(host, new Date())).focus : undefined) setBadgeTextOfMills(milliseconds, tabId) return badgeLocation } @@ -90,14 +90,12 @@ class BadgeTextManager { this.pauseOrResumeAccordingToOption(!!option.displayBadgeText) optionService.addOptionChangeListener(({ displayBadgeText }) => this.pauseOrResumeAccordingToOption(displayBadgeText)) whitelistHolder.addPostHandler(updateFocus) - - alarmManager.setInterval('badage-text-manager', 1000, () => !this.isPaused && updateFocus()) } /** * Hide the badge text */ - async pause() { + private async pause() { this.isPaused = true const tab = await findActiveTab() setBadgeText('', tab?.tabId) @@ -106,7 +104,7 @@ class BadgeTextManager { /** * Show the badge text */ - resume() { + private resume() { this.isPaused = false // Update badge text immediately this.forceUpdate() diff --git a/src/background/browser-action-menu-manager.ts b/src/background/browser-action-menu-manager.ts index 28f3f34d..bf612585 100644 --- a/src/background/browser-action-menu-manager.ts +++ b/src/background/browser-action-menu-manager.ts @@ -8,18 +8,15 @@ import { OPTION_ROUTE } from "../app/router/constants" import { getAppPageUrl, getGuidePageUrl, SOURCE_CODE_PAGE, TU_CAO_PAGE } from "@util/constant/url" import { t2Chrome } from "@i18n/chrome/t" -import { IS_MV3, IS_SAFARI } from "@util/constant/environment" +import { IS_SAFARI } from "@util/constant/environment" import { createTab } from "@api/chrome/tab" -import { createContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" +import { createContextMenu } from "@api/chrome/context-menu" const APP_PAGE_URL = getAppPageUrl(true) -const baseProps: Partial = { - // Cast unknown to fix the error with manifestV2 - // Because 'browser_action' will be replaced with 'action' in union type chrome.contextMenus.ContextType since V3 - // But 'action' does not work in V2 - contexts: [IS_MV3 ? 'action' : 'browser_action'], +const baseProps: Partial = { + contexts: ['action'], visible: true } diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 0590627b..585ceeef 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -20,21 +20,13 @@ import MessageDispatcher from "./message-dispatcher" export default function init(dispatcher: MessageDispatcher) { dispatcher // Increase the visit time - .register('cs.incVisitCount', async host => { - timerService.addOneTime(host) - }) + .register('cs.incVisitCount', host => timerService.addOneTime(host)) // Judge is in whitelist .register('cs.isInWhitelist', host => whitelistService.include(host)) // Need to print the information of today - .register('cs.printTodayInfo', async () => { - const option = await optionService.getAllOption() - return !!option.printInConsole - }) + .register('cs.printTodayInfo', async () => !!(await optionService.getAllOption())?.printInConsole) // Get today info - .register('cs.getTodayInfo', host => { - const now = new Date() - return timerService.getResult(host, now) - }) + .register('cs.getTodayInfo', host => timerService.getResult(host, new Date())) // More minutes .register('cs.moreMinutes', url => limitService.moreMinutes(url)) // cs.getLimitedRules diff --git a/src/background/icon-and-alias-collector.ts b/src/background/icon-and-alias-collector.ts index e0e0a27b..df9430c5 100644 --- a/src/background/icon-and-alias-collector.ts +++ b/src/background/icon-and-alias-collector.ts @@ -9,7 +9,6 @@ import HostAliasDatabase from "@db/host-alias-database" import IconUrlDatabase from "@db/icon-url-database" import OptionDatabase from "@db/option-database" import { IS_CHROME, IS_SAFARI } from "@util/constant/environment" -import { iconUrlOfBrowser } from "@util/constant/url" import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" import { defaultStatistics } from "@util/constant/option" import { extractSiteName } from "@util/site" @@ -46,13 +45,11 @@ async function processTabInfo(tab: ChromeTab): Promise { if (isBrowserUrl(url)) return const hostInfo = extractHostname(url) const host = hostInfo.host - const protocol = hostInfo.protocol if (!host) return let favIconUrl = tab.favIconUrl // localhost hosts with Chrome use cache, so keep the favIcon url undefined IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const iconUrl = favIconUrl || iconUrlOfBrowser(protocol, host) - iconUrl && iconUrlDatabase.put(host, iconUrl) + favIconUrl && iconUrlDatabase.put(host, favIconUrl) collectAliasEnabled && !isBrowserUrl(url) && isHomepage(url) && collectAlias(host, tab.title) } diff --git a/src/background/index.ts b/src/background/index.ts index bc23afe3..91370725 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -9,21 +9,18 @@ import { openLog } from "../common/logger" import WhitelistMenuManager from "./whitelist-menu-manager" import BrowserActionMenuManager from "./browser-action-menu-manager" import IconAndAliasCollector from "./icon-and-alias-collector" -import Timer from "./timer" import VersionManager from "./version-manager" import ActiveTabListener from "./active-tab-listener" import badgeTextManager from "./badge-text-manager" -import metaService from "@service/meta-service" -import UninstallListener from "./uninstall-listener" -import { getGuidePageUrl } from "@util/constant/url" import MessageDispatcher from "./message-dispatcher" import initLimitProcesser from "./limit-processor" import initCsHandler from "./content-script-handler" import { isBrowserUrl } from "@util/pattern" import BackupScheduler from "./backup-scheduler" -import { createTab, listTabs } from "@api/chrome/tab" +import { listTabs } from "@api/chrome/tab" import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window" -import { onInstalled } from "@api/chrome/runtime" +import initServer from "./timer/server" +import handleInstall from "./install-handler" // Open the log of console openLog() @@ -37,7 +34,8 @@ initLimitProcesser(messageDispatcher) initCsHandler(messageDispatcher) // Start the timer -new Timer().start() +// new Timer().start() +initServer(messageDispatcher) // Collect the icon url and title new IconAndAliasCollector().listen() @@ -70,15 +68,7 @@ onNormalWindowFocusChanged(async windowId => { .forEach(({ url, id }) => badgeTextManager.forceUpdate({ url, tabId: id })) }) -// Collect the install time -onInstalled(async reason => { - if (reason === "install") { - createTab(getGuidePageUrl(true)) - await metaService.updateInstallTime(new Date()) - } - // Questionnaire for uninstall - new UninstallListener().listen() -}) +handleInstall() // Start message dispatcher -messageDispatcher.start() \ No newline at end of file +messageDispatcher.start() diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts new file mode 100644 index 00000000..c4521951 --- /dev/null +++ b/src/background/install-handler/index.ts @@ -0,0 +1,35 @@ +import { onInstalled } from "@api/chrome/runtime" +import { createTab, listTabs } from "@api/chrome/tab" +import metaService from "@service/meta-service" +import { getGuidePageUrl } from "@util/constant/url" +import { isBrowserUrl } from "@util/pattern" +import UninstallListener from './uninstall-listener' + +async function onFirstInstall() { + createTab(getGuidePageUrl(true)) + metaService.updateInstallTime(new Date()) +} + +function executeScript(tabId: number, files: string[]) { + chrome.scripting.executeScript({ target: { tabId }, files }).catch(err => console.log(err)) +} + +async function reloadContentScript() { + const files = chrome.runtime.getManifest().content_scripts?.[0]?.js + if (!files?.length) { + return + } + const tabs = await listTabs() + tabs.filter(({ url }) => url && !isBrowserUrl(url)) + .forEach(tab => executeScript(tab.id, files)) +} + +export default function handleInstall() { + onInstalled(async reason => { + reason === "install" && await onFirstInstall() + // Questionnaire for uninstall + new UninstallListener().listen() + // Reload content-script + await reloadContentScript() + }) +} \ No newline at end of file diff --git a/src/background/uninstall-listener.ts b/src/background/install-handler/uninstall-listener.ts similarity index 100% rename from src/background/uninstall-listener.ts rename to src/background/install-handler/uninstall-listener.ts diff --git a/src/background/timer/client.ts b/src/background/timer/client.ts new file mode 100644 index 00000000..a4ab67d6 --- /dev/null +++ b/src/background/timer/client.ts @@ -0,0 +1,48 @@ +import { sendMsg2Runtime } from "@api/chrome/runtime" + +/** + * Tracker client, used in the content-script + */ +export default class TrackerClient { + docVisible: boolean = false + start: number = Date.now() + + init() { + this.docVisible = document?.visibilityState === 'visible' + document?.addEventListener('visibilitychange', () => this.changeState(document?.visibilityState === 'visible')) + setInterval(() => this.collect(), 1000) + } + + private changeState(docVisible: boolean) { + this.docVisible && !docVisible && this.pause() + !this.docVisible && docVisible && this.resume() + + this.docVisible = docVisible + } + + private async collect(ignoreTabCheck?: boolean) { + if (!this.docVisible) return + + const end = Date.now() + if (end <= this.start) return + + const data: timer.stat.Event = { + start: this.start, + end, + url: location?.href, + ignoreTabCheck + } + try { + await sendMsg2Runtime('cs.trackTime', data) + this.start = end + } catch (_) { } + } + + private pause() { + this.collect(true) + } + + private resume() { + this.start = Date.now() + } +} diff --git a/src/background/timer/collection-context.ts b/src/background/timer/collection-context.ts deleted file mode 100644 index d802b379..00000000 --- a/src/background/timer/collection-context.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import TimerContext from "./context" - -export default class CollectionContext { - realInterval: number - timerContext: TimerContext - - - init() { - const now = Date.now() - this.realInterval = now - this.timerContext.lastCollectTime - this.timerContext.lastCollectTime = now - } - - constructor() { - this.timerContext = new TimerContext() - this.init() - } - - accumulate(focusHost: string, focusUrl: string) { - this.timerContext.accumulate(focusHost, focusUrl, this.realInterval) - } -} \ No newline at end of file diff --git a/src/background/timer/collector.ts b/src/background/timer/collector.ts deleted file mode 100644 index 13033718..00000000 --- a/src/background/timer/collector.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { isBrowserUrl, extractHostname, extractFileHost } from "@util/pattern" -import CollectionContext from "./collection-context" -import optionService from "@service/option-service" -import { listTabs } from "@api/chrome/tab" -import { listAllWindows } from "@api/chrome/window" - -let countLocalFiles: boolean -optionService.getAllOption().then(option => countLocalFiles = !!option.countLocalFiles) -optionService.addOptionChangeListener((newVal => countLocalFiles = !!newVal.countLocalFiles)) - -function handleTab(tab: ChromeTab, window: ChromeWindow, context: CollectionContext) { - if (!tab.active || !window.focused) { - return - } - const url = tab.url - if (!url) return - if (isBrowserUrl(url)) return - let host = extractHostname(url).host - if (!host && countLocalFiles) { - // Not host, try to detect the local files - host = extractFileHost(url) - } - if (host) { - context.accumulate(host, url) - } else { - console.log('Detect blank host:', url) - } -} - -async function doCollect(context: CollectionContext) { - const windows = await listAllWindows() - for (const window of windows) { - const tabs = await listTabs({ windowId: window.id }) - // tabs maybe undefined - if (!tabs) { - continue - } - tabs.forEach(tab => handleTab(tab, window, context)) - } -} - -export default class TimeCollector { - context: CollectionContext - - constructor(context: CollectionContext) { - this.context = context - } - - collect() { - this.context.init() - if (this.context.timerContext.isPaused()) return - doCollect(this.context) - } -} \ No newline at end of file diff --git a/src/background/timer/context.ts b/src/background/timer/context.ts deleted file mode 100644 index 1b32981d..00000000 --- a/src/background/timer/context.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import optionService from "@service/option-service" - -let countWhenIdle: boolean = false - -const setCountWhenIdle = (op: timer.option.AllOption) => countWhenIdle = op.countWhenIdle -optionService.getAllOption().then(setCountWhenIdle) -optionService.addOptionChangeListener(setCountWhenIdle) - -/** - * Context of timer - */ -export default class TimerContext { - /** - * The result of time collection - */ - timeMap: { [host: string]: TimeInfo } - /** - * The last collect time - */ - lastCollectTime: number - - private idleState: ChromeIdleState - - constructor() { - this.idleState = 'active' - this.lastCollectTime = new Date().getTime() - this.resetTimeMap() - } - - accumulate(host: string, url: string, focusTime: number) { - let data: TimeInfo = this.timeMap[host] - !data && (this.timeMap[host] = data = {}) - let existFocusTime = data[url] || 0 - data[url] = existFocusTime + focusTime - } - - /** - * Reset the time map - */ - resetTimeMap(): void { this.timeMap = {} } - - setIdle(idleNow: ChromeIdleState) { this.idleState = idleNow } - - isPaused(): boolean { - if (this.idleState === 'active') { - return false - } else if (this.idleState === 'locked') { - return true - } else if (this.idleState === 'idle') { - return !countWhenIdle - } - // Never happen - return false - } -} \ No newline at end of file diff --git a/src/background/timer/idle-listener.ts b/src/background/timer/idle-listener.ts deleted file mode 100644 index a9e1c5a1..00000000 --- a/src/background/timer/idle-listener.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { onIdleStateChanged } from "@api/chrome/idle" -import { IS_SAFARI } from "@util/constant/environment" -import { formatTime } from "@util/time" -import TimerContext from "./context" - -function listen(context: TimerContext, newState: ChromeIdleState) { - console.log(`Idle state changed:${newState}`, formatTime(new Date())) - context.setIdle(newState) -} - -/** - * @since 0.2.2 - */ -export default class IdleListener { - private context: TimerContext - - constructor(context: TimerContext) { - this.context = context - } - - listen() { - // Idle does not work in Safari - !IS_SAFARI && onIdleStateChanged(newState => listen(this.context, newState)) - } -} \ No newline at end of file diff --git a/src/background/timer/index.ts b/src/background/timer/index.ts deleted file mode 100644 index 14b63c80..00000000 --- a/src/background/timer/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import TimeCollector from "./collector" -import IdleListener from "./idle-listener" -import save from "./save" -import TimerContext from "./context" -import CollectionContext from "./collection-context" -import alarmManager from "../alarm-manager" - -/** - * Timer - */ -class Timer { - start() { - const collectionContext = new CollectionContext() - const timerContext: TimerContext = collectionContext.timerContext - new IdleListener(timerContext).listen() - const collector = new TimeCollector(collectionContext) - - alarmManager.setInterval('collect', 1000, () => collector.collect()) - alarmManager.setInterval('save', 500, () => save(collectionContext)) - } -} - -export default Timer diff --git a/src/background/timer/save.ts b/src/background/timer/save.ts deleted file mode 100644 index c5d40820..00000000 --- a/src/background/timer/save.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import limitService from "@service/limit-service" -import periodService from "@service/period-service" -import timerService from "@service/timer-service" -import { sum } from "@util/array" -import CollectionContext from "./collection-context" - -async function sendLimitedMessage(item: timer.limit.Item[]) { - const tabs = await listTabs({ status: 'complete' }) - tabs.forEach(tab => sendMsg2Tab(tab.id, 'limitTimeMeet', item) - .then(() => console.log(`Processed limit rules: rule=${JSON.stringify(item)}`)) - .catch(err => console.error(`Failed to execute limit rule: rule=${JSON.stringify(item)}, msg=${err.msg}`)) - ) -} - -export default async function save(collectionContext: CollectionContext) { - const context = collectionContext.timerContext - if (context.isPaused()) return - const timeMap = context.timeMap - timerService.addFocusAndTotal(timeMap) - const totalFocusTime = sum(Object.values(timeMap).map(timeInfo => sum(Object.values(timeInfo)))) - // Add period time - await periodService.add(context.lastCollectTime, totalFocusTime) - for (const [host, timeInfo] of Object.entries(timeMap)) { - for (const [url, focusTime] of Object.entries(timeInfo)) { - // Add limit time - const limitedRules = await limitService.addFocusTime(host, url, focusTime) - // If time limited after this operation, send messages - limitedRules && limitedRules.length && sendLimitedMessage(limitedRules) - } - } - - context.resetTimeMap() -} diff --git a/src/background/timer/server.ts b/src/background/timer/server.ts new file mode 100644 index 00000000..e88ea4d1 --- /dev/null +++ b/src/background/timer/server.ts @@ -0,0 +1,49 @@ +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getWindow } from "@api/chrome/window" +import limitService from "@service/limit-service" +import periodService from "@service/period-service" +import timerService from "@service/timer-service" +import { extractHostname, HostInfo } from "@util/pattern" +import badgeTextManager from "../badge-text-manager" +import MessageDispatcher from "../message-dispatcher" + +async function handleTime(hostInfo: HostInfo, url: string, dateRange: [number, number]): Promise { + const host = hostInfo.host + const [start, end] = dateRange + const focusTime = end - start + // 1. Saveasync + const totalFocus = await timerService.addFocus(host, new Date(end), focusTime) + // 2. Process limit + const meedLimits = await limitService.addFocusTime(host, url, focusTime) + // If time limited after this operation, send messages + meedLimits && meedLimits.length && sendLimitedMessage(meedLimits) + // 3. Add period time + await periodService.add(start, focusTime) + return totalFocus +} + +async function handleEvent(event: timer.stat.Event, sender: ChromeMessageSender): Promise { + const { url, start, end, ignoreTabCheck } = event + const windowId = sender?.tab?.windowId + if (!ignoreTabCheck) { + if (!windowId) return + const window = await getWindow(windowId) + if (!window?.focused) return + } + const hostInfo = extractHostname(url) + const focus = await handleTime(hostInfo, url, [start, end]) + const tabId = sender?.tab?.id + tabId && badgeTextManager.forceUpdate({ tabId, url, focus }) +} + +async function sendLimitedMessage(item: timer.limit.Item[]) { + const tabs = await listTabs({ status: 'complete' }) + tabs.forEach(tab => sendMsg2Tab(tab.id, 'limitTimeMeet', item) + .then(() => console.log(`Processed limit rules: rule=${JSON.stringify(item)}`)) + .catch(err => console.error(`Failed to execute limit rule: rule=${JSON.stringify(item)}, msg=${err.msg}`)) + ) +} + +export default function initServer(messageDispatcher: MessageDispatcher) { + messageDispatcher.register('cs.trackTime', handleEvent) +} diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index 9eca2622..0e631fdb 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -12,10 +12,11 @@ import { ContextMenusMessage } from "@i18n/message/common/context-menus" import { extractHostname, isBrowserUrl } from "@util/pattern" import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" import { createContextMenu, updateContextMenu } from "@api/chrome/context-menu" +import { getRuntimeId } from "@api/chrome/runtime" const db = new WhitelistDatabase(chrome.storage.local) -const menuId = '_timer_menu_item_' + Date.now() +const menuId = '_timer_menu_item_' + getRuntimeId() let currentActiveId: number let whitelist: string[] = [] @@ -81,4 +82,4 @@ async function init() { optionService.addOptionChangeListener(option => visible = option.displayWhitelistMenu) } -export default init \ No newline at end of file +export default init diff --git a/src/content-script/index.ts b/src/content-script/index.ts index d155a648..cf80e62f 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -7,13 +7,31 @@ import { sendMsg2Runtime } from "@api/chrome/runtime" import { initLocale } from "@i18n" +import TrackerClient from "@src/background/timer/client" import processLimit from "./limit" import printInfo from "./printer" const host = document?.location?.host const url = document?.location?.href +function getOrSetFlag(): boolean { + const flagId = '__TIMER_INJECTION_FLAG__' + const pre = document?.getElementById(flagId) + if (!pre) { + const flag = document?.createElement('a') + flag.style.visibility = 'hidden' + flag && (flag.id = flagId) + document?.body?.appendChild(flag) + } + return !!pre +} + async function main() { + // Execute in every injections + new TrackerClient().init() + + // Execute only one time + if (getOrSetFlag()) return if (!host) return const isWhitelist = await sendMsg2Runtime('cs.isInWhitelist', host) diff --git a/src/database/icon-url-database.ts b/src/database/icon-url-database.ts index 928ff131..eedd3bd1 100644 --- a/src/database/icon-url-database.ts +++ b/src/database/icon-url-database.ts @@ -5,7 +5,6 @@ * https://opensource.org/licenses/MIT */ -import { IS_FIREFOX } from "@util/constant/environment" import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" @@ -15,6 +14,8 @@ const generateKey = (host: string) => DB_KEY_PREFIX + host const urlOf = (key: string) => key.substring(DB_KEY_PREFIX.length) +const CHROME_FAVICON_PATTERN = /^(chrome|edge):\/\/favicon/ + /** * The icon url of hosts * @@ -41,17 +42,28 @@ class IconUrlDatabase extends BaseDatabase { const keys = hosts.map(generateKey) const items = await this.storage.get(keys) const result = {} - Object.entries(items).forEach(([key, iconUrl]) => result[urlOf(key)] = iconUrl) + const keys2Remove = [] + Object.entries(items).forEach(([key, iconUrl]) => { + if (CHROME_FAVICON_PATTERN.test(iconUrl)) { + // Remove the icon url starting with chrome://favicon + // Because this protocol is invalid since mv3 + // And this will be removed in some version + keys2Remove.push(key) + } else { + result[urlOf(key)] = iconUrl + } + }) + // Remove asynchronously + keys2Remove.length && this.storage.remove(keys2Remove) return Promise.resolve(result) } async importData(data: any): Promise { const items = await this.storage.get() const toSave = {} - const chromeEdgeIconUrlReg = /^(chrome|edge):\/\/favicon/ Object.entries(data) .filter(([key, value]) => key.startsWith(DB_KEY_PREFIX) && !!value && !items[key]) - .filter(([_key, value]) => !chromeEdgeIconUrlReg.test(value as string)) + .filter(([_key, value]) => !CHROME_FAVICON_PATTERN.test(value as string)) .forEach(([key, value]) => toSave[key] = value) await this.storage.set(toSave) } diff --git a/src/database/timer-database.ts b/src/database/timer-database.ts index bead4cfd..c9a74fab 100644 --- a/src/database/timer-database.ts +++ b/src/database/timer-database.ts @@ -142,14 +142,14 @@ class TimerDatabase extends BaseDatabase { * @param host host * @since 0.1.3 */ - async accumulate(host: string, date: Date | string, item: timer.stat.Result): Promise { + async accumulate(host: string, date: Date | string, item: timer.stat.Result): Promise { const key = generateKey(host, date) const items = await this.storage.get(key) const exist: timer.stat.Result = mergeResult(items[key] as timer.stat.Result || createZeroResult(), item) const toUpdate = {} toUpdate[key] = exist - log('toUpdate', toUpdate) - return this.storage.set(toUpdate) + await this.storage.set(toUpdate) + return exist } /** diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 9035cef4..f8aa3aaf 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -49,9 +49,6 @@ export type OptionMessage = { } statistics: { title: string - countWhenIdle: string - idleTime: string - idleTimeInfo: string countLocalFiles: string localFileTime: string localFilesInfo: string @@ -140,9 +137,6 @@ const _default: Messages = { }, statistics: { title: '统计', - countWhenIdle: '{input} 是否统计 {idleTime} {info}', - idleTime: '休眠时间', - idleTimeInfo: '长时间不操作(比如全屏观看视频),浏览器会自动进入休眠状态', countLocalFiles: '{input} 是否统计使用浏览器 {localFileTime} {info}', localFileTime: '阅读本地文件的时间', localFilesInfo: '支持 PDF、图片、txt 以及 json 等格式', @@ -226,9 +220,6 @@ const _default: Messages = { }, statistics: { title: '統計', - countWhenIdle: '{input} 是否統計 {idleTime} {info}', - idleTime: '休眠時間', - idleTimeInfo: '長時間不操作(比如全屏觀看視頻),瀏覽器會自動進入休眠狀態', countLocalFiles: '{input} 是否統計使用瀏覽器 {localFileTime} {info}', localFileTime: '閱讀本地文件的時間', localFilesInfo: '支持 PDF、圖片、txt 以及 json 等格式', @@ -311,9 +302,6 @@ const _default: Messages = { }, statistics: { title: 'Statistics', - countWhenIdle: '{input} Whether to count {idleTime} {info}', - idleTime: 'idle time', - idleTimeInfo: 'If you do not operate for a long time (such as watching a video in full screen), the browser will automatically enter the idle state', countLocalFiles: '{input} Whether to count the time to {localFileTime} {info} in the browser', localFileTime: ' read a local file ', localFilesInfo: 'Supports files of types such as PDF, image, txt and json', @@ -396,9 +384,6 @@ const _default: Messages = { }, statistics: { title: '統計', - countWhenIdle: '{input} {idleTime} をカウントするかどうか {info}', - idleTime: 'アイドルタイム', - idleTimeInfo: '長時間操作しない場合(フルスクリーンでビデオを見るなど)、ブラウザは自動的にアイドル状態になります', countLocalFiles: '{input} ブラウザで {localFileTime} {info} に費やされた時間をカウントするかどうか', localFileTime: ' ローカルファイルの読み取り ', localFilesInfo: 'PDF、画像、txt、jsonを含む', diff --git a/src/i18n/message/common/meta.ts b/src/i18n/message/common/meta.ts index 43b9b2b0..cae576f0 100644 --- a/src/i18n/message/common/meta.ts +++ b/src/i18n/message/common/meta.ts @@ -23,8 +23,8 @@ const _default: Messages = { }, en: { name: 'Timer', - marketName: 'Timer - Browsing Time & Visit count', - description: 'To be the BEST web timer.', + marketName: 'Timer - Track Your Webtime', + description: 'To be the BEST webtime tracker.', }, } diff --git a/src/manifest.ts b/src/manifest.ts index 5a47abfd..d4bc79b6 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,22 +15,22 @@ import packageInfo from "./package" import { OPTION_ROUTE } from "./app/router/constants" const { version, author, homepage } = packageInfo -const _default: chrome.runtime.ManifestV2 = { + +const _default: chrome.runtime.ManifestV3 = { name: '__MSG_meta_marketName__', description: "__MSG_meta_description__", version, author, default_locale: 'en', homepage_url: homepage, - manifest_version: 2, + manifest_version: 3, icons: { 16: "static/images/icon.png", 48: "static/images/icon.png", 128: "static/images/icon.png" }, background: { - scripts: ['background.js'], - persistent: true + service_worker: 'background.js' }, content_scripts: [ { @@ -48,12 +48,11 @@ const _default: chrome.runtime.ManifestV2 = { 'tabs', 'webNavigation', 'contextMenus', - 'chrome://favicon/**', - /** - * @since 0.2.2 - **/ - 'idle', 'alarms', + 'scripting', + ], + host_permissions: [ + "", ], /** * @since 0.3.4 @@ -61,7 +60,7 @@ const _default: chrome.runtime.ManifestV2 = { optional_permissions: [ 'clipboardRead' ], - browser_action: { + action: { default_popup: "static/popup.html", default_icon: "static/images/icon.png" }, diff --git a/src/service/limit-service.ts b/src/service/limit-service.ts index f4d980e5..5a0d76e0 100644 --- a/src/service/limit-service.ts +++ b/src/service/limit-service.ts @@ -47,7 +47,7 @@ async function handleLimitChanged() { const tabs = await listTabs() tabs.forEach(tab => { const limitedItems = allItems.filter(item => item.matches(tab.url) && item.enabled && item.hasLimited()) - limitedItems?.length && sendMsg2Tab(tab?.id, 'limitChanged', limitedItems) + sendMsg2Tab(tab?.id, 'limitChanged', limitedItems) .catch(err => console.log(err.message)) }) } @@ -83,7 +83,7 @@ async function getLimited(url: string): Promise { * @param focusTime time, milliseconds * @returns the rules is limit cause of this operation */ -async function addFocusTime(url: string, focusTime: number) { +async function addFocusTime(url: string, focusTime: number): Promise { const allEnabled: TimeLimitItem[] = await select({ filterDisabled: true, url }) const toUpdate: { [cond: string]: number } = {} const result: TimeLimitItem[] = [] @@ -134,4 +134,4 @@ class LimitService { } } -export default new LimitService() \ No newline at end of file +export default new LimitService() diff --git a/src/service/timer-service/index.ts b/src/service/timer-service/index.ts index abc01016..221f67ea 100644 --- a/src/service/timer-service/index.ts +++ b/src/service/timer-service/index.ts @@ -12,7 +12,6 @@ import MergeRuleDatabase from "@db/merge-rule-database" import IconUrlDatabase from "@db/icon-url-database" import HostAliasDatabase from "@db/host-alias-database" import { slicePageResult } from "../components/page-info" -import whitelistHolder from '../components/whitelist-holder' import { resultOf } from "@util/stat" import OptionDatabase from "@db/option-database" import processor from "@src/common/backup/processor" @@ -75,10 +74,6 @@ export type HostSet = { merged: Set } -function calcFocusInfo(timeInfo: TimeInfo): number { - return Object.values(timeInfo).reduce((a, b) => a + b, 0) -} - const keyOf = (row: timer.stat.RowKey) => `${row.date}${row.host}` async function processRemote(param: TimerCondition, origin: timer.stat.Row[]): Promise { @@ -158,12 +153,9 @@ async function canReadRemote0(backupType: timer.backup.Type, auth: string): Prom */ class TimerService { - async addFocusAndTotal(data: { [host: string]: TimeInfo }): Promise { - const toUpdate = {} - Object.entries(data) - .filter(([host]) => whitelistHolder.notContains(host)) - .forEach(([host, timeInfo]) => toUpdate[host] = resultOf(calcFocusInfo(timeInfo), 0)) - return timerDatabase.accumulateBatch(toUpdate, new Date()) + async addFocus(host: string, date: Date, focus: number): Promise { + const result: timer.stat.Result = await timerDatabase.accumulate(host, date, { focus, time: 0 }) + return result?.focus || 0 } async addOneTime(host: string) { @@ -206,9 +198,7 @@ class TimerService { * @since 1.0.2 */ async count(condition: TimerCondition): Promise { - log("service: count: {condition}", condition) const count = await timerDatabase.count(condition) - log("service: count: {result}", count) return count } diff --git a/src/util/constant/environment.ts b/src/util/constant/environment.ts index 6d82b562..91d85a84 100644 --- a/src/util/constant/environment.ts +++ b/src/util/constant/environment.ts @@ -58,4 +58,7 @@ export const IS_SAFARI: boolean = isSafari */ export const BROWSER_MAJOR_VERSION = browserMajorVersion +/** + * @since 1.4.4 + */ export const IS_MV3 = chrome.runtime.getManifest().manifest_version === 3 \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index ba4d7b17..dfa12755 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -40,7 +40,6 @@ export function defaultAppearance(): timer.option.AppearanceOption { export function defaultStatistics(): timer.option.StatisticsOption { return { - countWhenIdle: true, collectSiteName: true, countLocalFiles: false } diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 20dcc93a..84c9903e 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -107,6 +107,7 @@ export function getGuidePageUrl(isInBackground: boolean): string { /** * @since 0.2.2 + * @deprecated mv3 * @returns icon url in the browser */ export function iconUrlOfBrowser(protocol: string, host: string): string { diff --git a/test/database/icon-url-database.test.ts b/test/database/icon-url-database.test.ts index 21ffa17c..c8b74488 100644 --- a/test/database/icon-url-database.test.ts +++ b/test/database/icon-url-database.test.ts @@ -22,6 +22,14 @@ describe('icon-url-database', () => { expect((await db.get(foo))[foo]).toBeUndefined() }) + // Invalid url starting with chrome:// or edge:// + test('2', async () => { + await db.put(baidu, 'chrome://favicon/https://baidu.com') + expect(await db.get(baidu)[baidu]).toBeUndefined() + await db.put(baidu, 'edge://favicon/https://baidu.com') + expect(await db.get(baidu)[baidu]).toBeUndefined() + }) + test("import data", async () => { await db.put(baidu, "test1") const data2Import = { diff --git a/webpack/webpack.common.ts b/webpack/webpack.common.ts index cc8af3f9..b48c0bb9 100644 --- a/webpack/webpack.common.ts +++ b/webpack/webpack.common.ts @@ -94,7 +94,7 @@ const staticOptions: webpack.Configuration = { } } -const optionGenerator = (outputPath: string, manifestHooker?: (manifest: chrome.runtime.ManifestV2) => void) => { +const optionGenerator = (outputPath: string, manifestHooker?: (manifest: chrome.runtime.ManifestV3) => void) => { manifestHooker?.(manifest) const plugins = [ ...generateJsonPlugins, diff --git a/webpack/webpack.dev.safari.ts b/webpack/webpack.dev.safari.ts index 6a6d43a0..21f0c06f 100644 --- a/webpack/webpack.dev.safari.ts +++ b/webpack/webpack.dev.safari.ts @@ -3,21 +3,10 @@ import optionGenerator from "./webpack.common" const outputDir = path.join(__dirname, '..', 'dist_dev_safari') -function removeUnsupportedProperties(manifest: Partial) { - // 1. permissions. 'idle' is not supported - const originPermissions = manifest.permissions || [] - const unsupported = ['idle'] - const supported = [] - originPermissions.forEach(perm => !unsupported.includes(perm) && supported.push(perm)) - manifest.permissions = supported -} - const options = optionGenerator( outputDir, baseManifest => { baseManifest.name = 'Timer_Safari_DEV' - // Remove unsupported properties in Safari - removeUnsupportedProperties(baseManifest) } ) diff --git a/webpack/webpack.dev.ts b/webpack/webpack.dev.ts index d5f2cc9b..01674e20 100644 --- a/webpack/webpack.dev.ts +++ b/webpack/webpack.dev.ts @@ -4,13 +4,13 @@ import FileManagerWebpackPlugin from "filemanager-webpack-plugin" import optionGenerator from "./webpack.common" import webpack from "webpack" -const outputDir = path.join(__dirname, '..', 'dist_dev') -let manifest: chrome.runtime.ManifestV2 +const outputDir = path.join(__dirname, '..', 'dist_dev_mv3') +let manifest: chrome.runtime.ManifestV3 const options = optionGenerator( outputDir, baseManifest => { - baseManifest.name = 'IS DEV' + baseManifest.name = 'DEV_MV3' // Fix the crx id for development mode baseManifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb" manifest = baseManifest @@ -28,7 +28,7 @@ const firefoxManifestGeneratePlugin = new GenerateJsonPlugin( } ) as unknown as webpack.WebpackPluginInstance options.plugins.push(firefoxManifestGeneratePlugin) -const firefoxDevDir = path.join(__dirname, '..', 'firefox_dev') +const firefoxDevDir = path.join(__dirname, '..', 'firefox_dev_mv3') // Generate FireFox dev files options.plugins.push( new FileManagerWebpackPlugin({ diff --git a/webpack/webpack.prod.safari.ts b/webpack/webpack.prod.safari.ts index b3ab5cd5..72cb45a6 100644 --- a/webpack/webpack.prod.safari.ts +++ b/webpack/webpack.prod.safari.ts @@ -8,21 +8,10 @@ const { name, version } = require(path.join(__dirname, '..', 'package.json')) const outputDir = path.join(__dirname, '..', 'dist_prod_safari') const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-safari.zip`) -function removeUnsupportedProperties(manifest: Partial) { - // 1. permissions. 'idle' is not supported - const originPermissions = manifest.permissions || [] - const unsupported = ['idle'] - const supported = [] - originPermissions.forEach(perm => !unsupported.includes(perm) && supported.push(perm)) - manifest.permissions = supported -} - const options = optionGenerator( outputDir, baseManifest => { baseManifest.name = 'Timer' - // Remove unsupported properties in Safari - removeUnsupportedProperties(baseManifest) } ) diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index baa398ba..a3be94eb 100644 --- a/webpack/webpack.prod.ts +++ b/webpack/webpack.prod.ts @@ -9,8 +9,8 @@ const outputDir = path.resolve(__dirname, '..', 'dist_prod') const option = optionGenerator(outputDir) option.mode = 'production' -const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}.zip`) -const sourceCodeForFireFox = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`) +const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}.mv3.zip`) +const sourceCodeForFireFox = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.mv3.zip`) // Temporary directory for source code to archive on Firefox const sourceTempDir = path.resolve(__dirname, '..', 'firefox')