diff --git a/README.md b/README.md index d706274..21c93aa 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Ulysse is a simple CLI tool for blocking your distracting apps and websites. Prevent distractions by blocking your most distracting apps and websites, even if you are the administrator of your computer. > [!WARNING] -> The shield mode block root access to your computer and can block you from disabling Ulysse. +> The shield mode block root access to your computer and can prevent you from disabling Ulysse. > > Make sure to remember your password. > diff --git a/__tests__/block.spec.js b/__tests__/block.spec.js index e6cb392..6afabd8 100644 --- a/__tests__/block.spec.js +++ b/__tests__/block.spec.js @@ -1,6 +1,4 @@ -import { config, editConfig, readConfig } from '../src/config'; -import { DEFAULT_CONFIG } from '../src/constants'; -import { disableShieldMode } from '../src/shield'; +import { config, readConfig, resetConfig } from '../src/config'; import { getBlockedApps, blockDistraction, @@ -11,13 +9,11 @@ import { } from '../src/block'; beforeEach(async () => { - await disableShieldMode('ulysse'); - await editConfig(DEFAULT_CONFIG); - Object.assign(config, DEFAULT_CONFIG); + await resetConfig(); jest.spyOn(console, 'log').mockImplementation(() => {}); }); -test('Should check a distraction', async () => { +test('Should check if a distraction is valid', () => { expect(isValidDistraction({ name: '' })).toBe(false); expect(isValidDistraction({ name: '*' })).toBe(true); expect(isValidDistraction({ name: '*.*' })).toBe(true); @@ -33,13 +29,14 @@ test('Should block a distraction', async () => { await blockDistraction({ name: 'example.com' }); expect(isDistractionBlocked('example.com')).toEqual(true); + expect(readConfig().blocklist).toContainEqual({ name: 'example.com', profile: 'default' }); }); test('Should block a distraction with a duration', async () => { await blockDistraction({ name: 'twitter.com', time: '2m' }); expect(isDistractionBlocked('twitter.com')).toBe(true); - expect(config.blocklist).toEqual([{ name: 'twitter.com', time: '2m', timeout: expect.any(Number) }]); + expect(readConfig().blocklist).toContainEqual({ name: 'twitter.com', time: '2m', profile: 'default', timeout: expect.any(Number) }); }); test('Should block a distraction with a time-based interval', async () => { @@ -49,6 +46,7 @@ test('Should block a distraction with a time-based interval', async () => { await blockDistraction({ name: 'example.com', time: '0h-23h' }); expect(isDistractionBlocked('example.com')).toBe(true); + expect(readConfig().blocklist).toContainEqual({ name: 'example.com', profile: 'default', time: '0h-23h' }); }); test('Should block a specific subdomain', async () => { @@ -56,6 +54,7 @@ test('Should block a specific subdomain', async () => { expect(isDistractionBlocked('www.example.com')).toBe(true); expect(isDistractionBlocked('example.com')).toBe(false); + expect(readConfig().blocklist).toContainEqual({ name: 'www.example.com', profile: 'default' }); }); test('Should block all subdomains of a domain with a wildcard', async () => { @@ -111,7 +110,7 @@ test('Should unblock a distraction', async () => { expect(isDistractionBlocked('example.com')).toBe(false); }); -test('Should run isDistractionBlocked in less than 150ms with a large blocklist', async () => { +test.skip('Should run isDistractionBlocked in less than 150ms with a large blocklist', async () => { config.blocklist = Array.from({ length: 500000 }, (_, i) => ({ name: `${i + 1}.com` })); isDistractionBlocked('example.com'); @@ -147,10 +146,10 @@ test('Should get all blocked apps', async () => { expect(blockedApps).toEqual(['node']); }); -test('Should get running blocked apps', () => { +test('Should get running blocked apps', async () => { config.blocklist = [{ name: 'node' }, { name: 'firefox' }]; - const runningBlockedApps = getRunningBlockedApps(); + const runningBlockedApps = await getRunningBlockedApps(); expect(runningBlockedApps).toContainEqual({ name: 'node', @@ -160,27 +159,21 @@ test('Should get running blocked apps', () => { }); }); -test('Should block all apps and websites', async () => { +test.skip('Should block all apps and websites', async () => { await blockDistraction({ name: '*' }); + const runningBlockedApps = await getRunningBlockedApps(); + expect(isDistractionBlocked('example.com')).toEqual(true); - expect(isDistractionBlocked('node')).toEqual(true); - expect(getRunningBlockedApps()).toContainEqual({ - name: 'node', + expect(isDistractionBlocked('chromium')).toEqual(true); + expect(runningBlockedApps).toContainEqual({ + name: 'chromium', pid: expect.any(Number), cmd: expect.any(String), bin: expect.any(String), }); }); -test('Should not block system process', async () => { - blockDistraction({ name: '*' }); - - const runningBlockedApps = JSON.stringify(getRunningBlockedApps()); - - expect(runningBlockedApps).not.toContain('/sbin/init'); -}); - test('Should not block all websites outside of a time range', async () => { const currentDate = new Date('2021-01-01T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); @@ -189,3 +182,11 @@ test('Should not block all websites outside of a time range', async () => { expect(isDistractionBlocked('example.com')).toEqual(false); }); + +test('Should not block a distraction from a profile disabled', async () => { + config.profiles = [{ name: 'default', enabled: false }]; + await blockDistraction({ name: 'signal-desktop', profile: 'default' }); + + expect(isDistractionBlocked('signal-desktop')).toEqual(false); + expect(getBlockedApps()).not.toContain('signal-desktop'); +}); diff --git a/__tests__/commands.spec.js b/__tests__/commands.spec.js index 4249b31..00ec096 100644 --- a/__tests__/commands.spec.js +++ b/__tests__/commands.spec.js @@ -1,13 +1,11 @@ -import { config, editConfig } from '../src/config'; -import { DEFAULT_CONFIG } from '../src/constants'; +import { config, resetConfig } from '../src/config'; import { disableShieldMode } from '../src/shield'; import { helpCmd, versionCmd, blockCmd, whitelistCmd, unblockCmd, shieldCmd } from '../src/commands'; beforeEach(async () => { process.argv = []; await disableShieldMode('ulysse'); - await editConfig(DEFAULT_CONFIG); - Object.assign(config, DEFAULT_CONFIG); + await resetConfig(); jest.spyOn(console, 'log').mockImplementation(() => {}); }); diff --git a/__tests__/daemon.spec.js b/__tests__/daemon.spec.js index 784221a..963d925 100644 --- a/__tests__/daemon.spec.js +++ b/__tests__/daemon.spec.js @@ -1,8 +1,7 @@ import fs from 'fs'; -import { config, editConfig, readConfig } from '../src/config'; +import { config, resetConfig, readConfig } from '../src/config'; import { getRunningApps } from '../src/utils'; import { blockDistraction } from '../src/block'; -import { DEFAULT_CONFIG } from '../src/constants'; import { disableShieldMode } from '../src/shield'; import { handleAppBlocking, handleTimeout, updateResolvConf } from '../src/daemon'; @@ -18,15 +17,14 @@ jest.mock('child_process', () => ({ beforeEach(async () => { await disableShieldMode('ulysse'); - await editConfig(DEFAULT_CONFIG); - Object.assign(config, DEFAULT_CONFIG); + await resetConfig(); jest.spyOn(console, 'log').mockImplementation(() => {}); }); test('Should block a running app', async () => { - blockDistraction({ name: 'node' }); + await blockDistraction({ name: 'node' }); - handleAppBlocking(); + await handleAppBlocking(); expect(console.log).toHaveBeenCalledWith('Blocking node'); }); diff --git a/__tests__/profile.spec.js b/__tests__/profile.spec.js new file mode 100644 index 0000000..2b3abc1 --- /dev/null +++ b/__tests__/profile.spec.js @@ -0,0 +1,15 @@ +import { config, resetConfig } from '../src/config'; +import { disableShieldMode } from '../src/shield'; + +beforeEach(async () => { + process.argv = []; + await disableShieldMode('ulysse'); + await resetConfig(); + jest.spyOn(console, 'log').mockImplementation(() => {}); +}); + +test.skip('Should create a new profile', () => { + process.argv = ['ulysse', '-p', 'work']; + + expect(config.profiles).toEqual([{ name: 'work', enabled: true }]); +}); diff --git a/__tests__/shield.spec.js b/__tests__/shield.spec.js index 7074cb0..55df31b 100644 --- a/__tests__/shield.spec.js +++ b/__tests__/shield.spec.js @@ -1,13 +1,10 @@ -import { config, readConfig, editConfig } from '../src/config'; -import { DEFAULT_CONFIG } from '../src/constants'; +import { readConfig, resetConfig } from '../src/config'; import { whitelistDistraction } from '../src/whitelist'; import { enableShieldMode, disableShieldMode } from '../src/shield'; import { blockDistraction, unblockDistraction } from '../src/block'; beforeEach(async () => { - await disableShieldMode('ulysse'); - await editConfig(DEFAULT_CONFIG); - Object.assign(config, DEFAULT_CONFIG); + await resetConfig(); jest.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -42,7 +39,8 @@ test('Should not unblock a distraction if shield mode is enabled', async () => { await unblockDistraction({ name: 'example.com' }); - expect(readConfig().blocklist).toContainEqual({ name: 'example.com' }); + const { blocklist } = readConfig(); + expect(blocklist).toContainEqual({ name: 'example.com', profile: 'default' }); }); test('Should not whitelist a distraction if shield mode is enabled', async () => { @@ -50,5 +48,6 @@ test('Should not whitelist a distraction if shield mode is enabled', async () => await whitelistDistraction({ name: 'example.com' }); - expect(readConfig().whitelist).not.toContainEqual({ name: 'example.com' }); + const { whitelist } = readConfig(); + expect(whitelist).not.toContainEqual({ name: 'example.com' }); }); diff --git a/__tests__/whitelist.spec.js b/__tests__/whitelist.spec.js index b1d384d..2e9dae0 100644 --- a/__tests__/whitelist.spec.js +++ b/__tests__/whitelist.spec.js @@ -1,8 +1,7 @@ -import { config, readConfig, editConfig } from '../src/config'; -import { DEFAULT_CONFIG } from '../src/constants'; +import { config, resetConfig } from '../src/config'; import { disableShieldMode } from '../src/shield'; import { blockDistraction, isDistractionBlocked, getRunningBlockedApps } from '../src/block'; -import { isDistractionWhitelisted, whitelistDistraction } from '../src/whitelist'; +import { whitelistDistraction } from '../src/whitelist'; jest.mock('../src/utils', () => ({ ...jest.requireActual('../src/utils'), @@ -15,8 +14,7 @@ jest.mock('../src/utils', () => ({ beforeEach(async () => { await disableShieldMode('ulysse'); - await editConfig(DEFAULT_CONFIG); - Object.assign(config, DEFAULT_CONFIG); + await resetConfig(); jest.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -25,7 +23,7 @@ test('Should whitelist a distraction', async () => { await whitelistDistraction(distraction); - expect(readConfig().whitelist).toEqual([distraction]); + expect(config.whitelist).toEqual([distraction]); }); test('Should not block a domain if it is in the whitelist', async () => { @@ -42,12 +40,6 @@ test('Should not block a domain if it is in the whitelist with a wildcard', asyn expect(isDistractionBlocked('www.example.com')).toBe(false); }); -test('Should not block a process from the system whitelist', async () => { - await blockDistraction({ name: '*' }); - - expect(isDistractionWhitelisted('systemd')).toBe(true); -}); - test('Should not whitelist a blocked process outside of a time range', async () => { const currentDate = new Date('2021-01-01T12:00:00Z'); jest.spyOn(global, 'Date').mockImplementation(() => currentDate); diff --git a/__tests__/x11.spec.js b/__tests__/x11.spec.js new file mode 100644 index 0000000..95b2d46 --- /dev/null +++ b/__tests__/x11.spec.js @@ -0,0 +1,9 @@ +import { listActiveWindows } from '../src/x11'; + +test.skip('Should list active windows', async () => { + const windows = await listActiveWindows(); + + expect(windows[0]).toEqual( + { windowId: expect.any(Number), name: expect.any(String), pid: expect.any(Number) }, + ); +}); diff --git a/customSequencer.js b/customSequencer.js new file mode 100644 index 0000000..95d7924 --- /dev/null +++ b/customSequencer.js @@ -0,0 +1,14 @@ +/* eslint class-methods-use-this: 0 */ +const TestSequencer = require('@jest/test-sequencer').default; + +class AlphabeticalSequencer extends TestSequencer { + sort(tests) { + return tests.sort((testA, testB) => { + const nameA = testA.path.toUpperCase(); + const nameB = testB.path.toUpperCase(); + return nameA.localeCompare(nameB); + }); + } +} + +module.exports = AlphabeticalSequencer; diff --git a/package.json b/package.json index 1f3cb21..54f8a59 100644 --- a/package.json +++ b/package.json @@ -33,45 +33,40 @@ ], "dependencies": { "dns-packet": "^5.6.1", - "gun": "^0.2020.1240" + "enquirer": "^2.4.1", + "gun": "^0.2020.1240", + "x11": "^2.3.0" }, "devDependencies": { - "@babel/cli": "^7.23.9", - "@babel/core": "^7.23.9", - "@babel/node": "^7.23.9", - "@babel/preset-env": "^7.23.9", + "@babel/cli": "^7.24.8", + "@babel/core": "^7.24.9", + "@babel/node": "^7.24.8", + "@babel/preset-env": "^7.24.8", "@rollup/plugin-babel": "^6.0.4", - "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-terser": "^0.4.4", + "babel-plugin-transform-remove-strict-mode": "^0.0.2", "dotenv": "^16.4.5", "eslint": "^8.57.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-import": "^2.29.1", - "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-jest": "^28.6.0", "jest": "^29.7.0", - "rollup": "^4.12.0" + "rollup": "^4.18.1" }, "scripts": { "lint": "eslint src", "prepublishOnly": "npm run build", "prebuild": "rm -rf dist", - "build": "rollup --bundleConfigAsCjs -c", + "build": "rollup --bundleConfigAsCjs --no-strict -c", "start": "babel-node src/index.js", "test": "DOTENV_CONFIG_PATH=.env.test jest -i --setupFiles dotenv/config --forceExit" }, "jest": { + "testSequencer": "./customSequencer.js", "globalSetup": "./jest.setup.js", - "restoreMocks": true, - "transformIgnorePatterns": [], - "transform": { - "\\.js$": [ - "babel-jest", - { - "configFile": "./babel.config.js" - } - ] - } + "restoreMocks": true } } diff --git a/rollup.config.js b/rollup.config.js index 6655658..f6c2953 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -12,7 +12,11 @@ export default { plugins: [ json(), terser(), - commonjs(), + commonjs({ + dynamicRequireTargets: [ + 'node_modules/x11/lib/ext/big-requests.js', + ], + }), nodeResolve(), babel({ babelHelpers: 'bundled' }), ], diff --git a/src/block.js b/src/block.js index 447c010..273ee21 100644 --- a/src/block.js +++ b/src/block.js @@ -2,10 +2,11 @@ import fs from 'fs'; import { config, editConfig } from './config'; import { isDistractionWhitelisted } from './whitelist'; import { DOMAIN_REGEX } from './constants'; +import { listActiveWindows } from './x11'; import { removeDuplicates, getRootDomain, getRunningApps, getTimeType, createTimeout, isWithinTimeRange } from './utils'; -export const blockDistraction = async (distraction) => { - config.blocklist = removeDuplicates([...config.blocklist, distraction]); +export const blockDistraction = async ({ name, time, profile = 'default' }) => { + config.blocklist = removeDuplicates([...config.blocklist, { name, time, profile }]); config.blocklist = config.blocklist.map((d) => { if (getTimeType(d.time) === 'duration') { return { ...d, timeout: createTimeout(d.time) }; @@ -36,7 +37,7 @@ export const isDistractionBlocked = (distraction) => { if (isDistractionWhitelisted(distraction)) return false; const rootDomain = getRootDomain(distraction); - const { blocklist } = config; + const blocklist = config.blocklist.filter(({ profile }) => config.profiles.find(({ name }) => name === profile)?.enabled !== false); if (blocklist.some(({ name, time }) => name === '*' && isWithinTimeRange(time))) return true; @@ -71,7 +72,7 @@ export const isValidDistraction = (distraction) => { }; export const getBlockedApps = () => { - const { blocklist } = config; + const { blocklist, profiles } = config; const isApp = (name) => !isValidDomain(name); @@ -79,15 +80,17 @@ export const getBlockedApps = () => { .filter(({ name }) => isApp(name)) .filter(({ time }) => isWithinTimeRange(time)) .filter(({ name }) => !isDistractionWhitelisted(name)) + .filter(({ profile }) => profiles.find(({ name }) => name === profile)?.enabled !== false) .map(({ name }) => name); }; -export const getRunningBlockedApps = () => { - const runningApps = getRunningApps(); - const blockedApps = getBlockedApps(); +export const getRunningBlockedApps = async () => { + const runningApps = await getRunningApps(); + const blockedApps = await getBlockedApps(); + const activeWindows = await listActiveWindows(); if (blockedApps.includes('*')) { - return runningApps.filter(({ name }) => !isDistractionWhitelisted(name)); + return runningApps.filter(({ name, pid }) => !isDistractionWhitelisted(name) && activeWindows.some((a) => pid === a.pid)); } const isBlockedApp = (app) => blockedApps.includes(app.name) || blockedApps.includes(app.bin) || blockedApps.includes(app.cmd); diff --git a/src/commands.js b/src/commands.js index 0d28f8a..bcaaa45 100644 --- a/src/commands.js +++ b/src/commands.js @@ -1,4 +1,4 @@ -import { config } from './config'; +import { editConfig, config } from './config'; import { getParam } from './utils'; import { version } from '../package.json'; import { HELP_MESSAGE } from './constants'; @@ -7,6 +7,8 @@ import { isValidPassword, enableShieldMode, disableShieldMode } from './shield'; import { isValidDistraction, blockDistraction, unblockDistraction } from './block'; import { daemon } from './daemon'; +export { default as interactiveCmd } from './interactive'; + export const helpCmd = () => { console.log(HELP_MESSAGE); }; @@ -22,6 +24,7 @@ export const daemonCmd = () => { export const blockCmd = (name) => { const time = getParam('--time') || getParam('-t'); const force = getParam('--force') || getParam('-f'); + const profile = getParam('--profile') || getParam('-p'); const distraction = { name, time }; if (!force && !isValidDistraction(distraction)) { @@ -29,7 +32,7 @@ export const blockCmd = (name) => { return; } - blockDistraction(distraction); + blockDistraction(distraction, profile); console.log(`Blocking ${name}`); }; @@ -116,3 +119,16 @@ export const shieldCmd = (value = 'on') => { console.log('You must provide a valid password to disable the shield mode.'); } }; + +export const profileCmd = async (profile) => { + const time = getParam('--time') || getParam('-t'); + + if (!profile) { + console.log('You must provide a valid profile name.'); + return; + } + + config.profiles = profile.split(',').map((name) => ({ name, time })); + + await editConfig(config); +}; diff --git a/src/config.js b/src/config.js index d7a0c4d..a701d6c 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ import fs from 'fs'; import net from 'net'; import { dirname } from 'path'; -import { isSudo, tryCatch } from './utils'; +import { tryCatch } from './utils'; import { CONFIG_PATH, DEFAULT_CONFIG, SOCKET_PATH } from './constants'; export const sendDataToSocket = (data) => new Promise((resolve, reject) => { @@ -40,3 +40,11 @@ export const config = (tryCatch(() => { createConfig(); return readConfig(); }, DEFAULT_CONFIG))(); + +export const resetConfig = async () => { + config.shield = false; + config.profiles = []; + config.blocklist = []; + config.whitelist = []; + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); +}; diff --git a/src/constants.js b/src/constants.js index 29769a1..9b6ac08 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,4 +1,10 @@ -export const DEFAULT_CONFIG = process.env.DEFAULT_CONFIG || { shield: false, blocklist: [], whitelist: [], date: new Date('1970').toISOString() }; +export const DEFAULT_CONFIG = process.env.DEFAULT_CONFIG || { + shield: false, + profiles: [], + blocklist: [], + whitelist: [], + date: new Date('1970').toISOString(), +}; export const RESOLV_CONF_PATH = process.env.RESOLV_CONF_PATH || '/etc/resolv.conf'; @@ -14,64 +20,6 @@ export const DNS_PORT = process.env.DNS_PORT || 53; export const DOMAIN_REGEX = /^([\w-]+\.)+[\w-]+$/; -export const SYSTEM_WHITELIST = [ - new URL(GUN_SERVER).hostname, - 'agetty', - 'at-spi2-registr', - 'at-spi-bus-laun', - 'bash', - 'bluetoothd', - 'chattr', - 'containerd', - 'containerd-shim', - 'crond', - 'dbus-broker', - 'dbus-broker-lau', - 'dconf-service', - 'dockerd', - 'docker-proxy', - 'dunst', - 'gnome-keyring-d', - 'gpg-agent', - 'greetd', - 'grep', - 'gvfsd', - 'gvfsd-fuse', - 'journalctl', - 'less', - 'lightdm', - 'login', - 'NetworkManager', - 'nm-dispatcher', - 'pipewire', - 'pipewire-pulse', - 'polkitd', - 'pulseaudio', - 'rtkit-daemon', - 'scdaemon', - '(sd-pam)', - 'sh', - 'sort', - 'sshd', - 'startx', - 'sudo', - 'systemd', - 'systemd-journal', - 'systemd-logind', - 'systemd-timesyn', - 'systemd-udevd', - 'systemd-userdbd', - 'systemd-userwor', - '(udev-worker)', - 'ulysse', - 'wireplumber', - 'wpa_supplicant', - 'xcompmgr', - 'xinit', - 'Xorg', - 'zsh', -]; - export const HELP_MESSAGE = `Usage: ulysse [OPTIONS] Ulysse: A simple and powerful tool to block your distracting apps and websites. diff --git a/src/daemon.js b/src/daemon.js index f1f5a6f..d97c52b 100644 --- a/src/daemon.js +++ b/src/daemon.js @@ -14,8 +14,8 @@ export const updateResolvConf = (dnsServer = DNS_SERVER) => { execSync(`chattr +i ${RESOLV_CONF_PATH}`); }; -export const handleAppBlocking = () => { - const runningBlockedApps = getRunningBlockedApps(); +export const handleAppBlocking = async () => { + const runningBlockedApps = await getRunningBlockedApps(); for (const app of runningBlockedApps) { try { @@ -29,8 +29,10 @@ export const handleAppBlocking = () => { }; export const handleTimeout = async () => { - const blocklist = config.blocklist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); - const whitelist = config.whitelist.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); + const removeTimeouts = (list) => list.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); + + const blocklist = removeTimeouts(config.blocklist); + const whitelist = removeTimeouts(config.whitelist); if (blocklist.length !== config.blocklist.length || whitelist.length !== config.whitelist.length) { await editConfig(config); @@ -48,8 +50,8 @@ export const daemon = () => { process.exit(1); } - setInterval(() => { - handleAppBlocking(); + setInterval(async () => { + await handleAppBlocking(); }, 1000); setInterval(() => { diff --git a/src/edit.js b/src/edit.js new file mode 100644 index 0000000..3828bd5 --- /dev/null +++ b/src/edit.js @@ -0,0 +1,32 @@ +import fs from 'fs'; +import { config } from './config'; +import { CONFIG_PATH } from './constants'; +import { isValidPassword, blockRoot, unblockRoot } from './shield'; +import { removeDuplicates } from './utils'; + +const removeTimeouts = (list) => list.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); + +export const editConfig = (newConfig) => { + const { blocklist = [], whitelist = [], profiles = [], date, shield, password, passwordHash } = newConfig; + + config.date = date; + config.profiles = profiles; + config.whitelist = removeTimeouts(removeDuplicates(config.shield ? config.whitelist : whitelist)); + config.blocklist = removeTimeouts(removeDuplicates(config.shield ? [...config.blocklist, ...blocklist] : blocklist)); + + if (isValidPassword(password)) { + unblockRoot(); + config.shield = false; + delete config.passwordHash; + } + + if (shield && passwordHash) { + blockRoot(); + config.shield = true; + config.passwordHash = passwordHash; + } + + delete config.password; + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); +}; diff --git a/src/index.js b/src/index.js index 32bd8f5..511c797 100755 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ const commands = { '--unblock': cmd.unblockCmd, '--shield': cmd.shieldCmd, '--whitelist': cmd.whitelistCmd, + '--interactive': cmd.interactiveCmd, }; const processCommand = () => { @@ -18,7 +19,7 @@ const processCommand = () => { const alias = getAlias(command); const value = getParam(command) || getParam(alias); - if (!['--help', '--version', '--daemon', undefined].includes(command) && !isDaemonRunning()) { + if (!['--help', '--version', '--daemon'].includes(command) && !isDaemonRunning()) { console.log('You must start the daemon first.'); return; } @@ -28,7 +29,7 @@ const processCommand = () => { return; } - cmd.helpCmd(); + cmd.interactiveCmd(); }; processCommand(); diff --git a/src/interactive.js b/src/interactive.js new file mode 100644 index 0000000..ab57cd2 --- /dev/null +++ b/src/interactive.js @@ -0,0 +1,330 @@ +/* eslint-disable no-use-before-define */ +import { Select, Input, AutoComplete } from 'enquirer'; +import { config, editConfig } from './config'; +import { getAllApps } from './utils'; +import { unblockDistraction, blockDistraction } from './block'; +import { unwhitelistDistraction, whitelistDistraction } from './whitelist'; + +let profile; + +const blockDistractionMenu = async () => { + const blockDistractionPrompt = new Select({ + name: 'blockDistraction', + message: 'Block a distraction', + choices: ['Block a website', 'Block an application', 'Back'], + }); + + const blockDistractionChoice = await blockDistractionPrompt.run(); + + if (blockDistractionChoice === 'Block a website') { + const websitePrompt = new Input({ message: 'Enter the website to block' }); + const website = await websitePrompt.run(); + + blockDistraction({ name: website, profile }); + } + + if (blockDistractionChoice === 'Block an application') { + const appPrompt = new AutoComplete({ message: 'Enter the application to block', choices: getAllApps(), limit: 20 }); + const app = await appPrompt.run(); + + blockDistraction({ name: app, profile }); + } + + return updateProfileMenu(); +}; + +const unblockDistractionMenu = async () => { + const blocklist = config.blocklist.filter((d) => d.profile === profile).map((d) => d.name); + const prompt = new Select({ + name: 'unblockDistraction', + message: 'Unblock a distraction', + choices: [...blocklist, 'Back'], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') return updateProfileMenu(); + + unblockDistraction({ name: choice }); + + return updateProfileMenu(); +}; + +const whitelistDistractionMenu = async () => { + const whitelistDistractionPrompt = new Select({ + name: 'whitelistDistraction', + message: 'Whitelist a distraction', + choices: ['Whitelist a website', 'Whitelist an application', 'Back'], + }); + + const whitelistDistractionChoice = await whitelistDistractionPrompt.run(); + + if (whitelistDistractionChoice === 'Whitelist a website') { + const websitePrompt = new Input({ message: 'Enter the website to whitelist' }); + const website = await websitePrompt.run(); + + whitelistDistraction({ name: website, profile }); + } + + if (whitelistDistractionChoice === 'Whitelist an application') { + const appPrompt = new AutoComplete({ message: 'Enter the application to whitelist', choices: getAllApps(), limit: 20 }); + const app = await appPrompt.run(); + + whitelistDistraction({ name: app, profile }); + } + + return updateProfileMenu(); +}; + +const unwhitelistDistractionMenu = async () => { + const prompt = new Select({ + name: 'unwhitelistDistraction', + message: 'Unwhitelist a distraction', + choices: [...config.whitelist.filter((d) => d.profile === profile).map((d) => d.name), 'Back'], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') return updateProfileMenu(); + + unwhitelistDistraction({ name: choice }); + + return updateProfileMenu(); +}; + +const enableProfileMenu = async () => { + config.profiles = config.profiles.map((p) => { + if (p.name === profile) { + return { ...p, enabled: true }; + } + + return p; + }); + + await editConfig(config); + + return updateProfileMenu(); +}; + +const disableProfileMenu = async () => { + config.profiles = config.profiles.map((p) => { + if (p.name === profile) { + return { ...p, enabled: false }; + } + + return p; + }); + + await editConfig(config); + + return updateProfileMenu(); +}; + +const selectProfile = async () => { + const profiles = config.profiles.map((p) => p.name).sort((a, b) => a.localeCompare(b)); + + const prompt = new Select({ + name: 'selectProfile', + message: 'Select a profile', + choices: [...profiles, 'Back'], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') return manageProfilesMenu(); + + profile = choice; + + return choice; +}; + +const setSchedule = async () => { + console.log('Setting a schedule...'); + + return updateProfileMenu(); +}; + +const setBreakTime = async () => { + console.log('Setting a break time...'); + + return updateProfileMenu(); +}; + +const renameProfile = async () => { + const prompt = new Input({ message: 'Enter the new profile name', initial: profile }); + const newProfile = await prompt.run(); + + config.profiles = config.profiles.map((p) => { + if (p.name === profile) { + return { ...p, name: newProfile }; + } + + return p; + }); + + config.blocklist = config.blocklist.map((b) => { + if (b.profile === profile) { + return { ...b, profile: newProfile }; + } + + return b; + }); + + config.whitelist = config.whitelist.map((w) => { + if (w.profile === profile) { + return { ...w, profile: newProfile }; + } + + return w; + }); + + await editConfig(config); + + profile = newProfile; + + return updateProfileMenu(); +}; + +// eslint-disable-next-line complexity +const updateProfileMenu = async () => { + const blocklist = config.blocklist.filter((d) => d.profile === profile); + const whitelist = config.whitelist.filter((d) => d.profile === profile); + const currentProfile = config.profiles.find((p) => p.name === profile); + const enabled = currentProfile?.enabled || false; + + const prompt = new Select({ + name: 'updateProfileAction', + message: `Update profile: ${profile}`, + choices: [ + 'Block a distraction', + ...(blocklist.length > 0 ? ['Unblock a distraction'] : []), + 'Whitelist a distraction', + ...(whitelist.length > 0 ? ['Unwhitelist a distraction'] : []), + 'Set a schedule', + 'Set a break time', + 'Rename profile', + enabled ? 'Disable profile' : 'Enable profile', + 'Back', + ], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') await manageProfilesMenu(); + if (choice === 'Rename profile') await renameProfile(); + if (choice === 'Enable profile') await enableProfileMenu(); + if (choice === 'Disable profile') await disableProfileMenu(); + if (choice === 'Block a distraction') await blockDistractionMenu(); + if (choice === 'Unblock a distraction') await unblockDistractionMenu(); + if (choice === 'Whitelist a distraction') await whitelistDistractionMenu(); + if (choice === 'Unwhitelist a distraction') await unwhitelistDistractionMenu(); + if (choice === 'Set a schedule') await setSchedule(); + if (choice === 'Set a break time') await setBreakTime(); + + return true; +}; + +const createProfile = async () => { + const profiles = config.profiles.map((p) => p.name); + + const prompt = new Input({ message: 'Enter the profile name', initial: 'Default' }); + profile = await prompt.run(); + + if (profiles.includes(profile)) { + console.log('Profile already exists'); + return mainMenu(); + } + + config.profiles.push({ name: profile, enabled: true }); + await editConfig(config); + + return updateProfileMenu(); +}; + +const deleteProfile = async () => { + const profiles = config.profiles.map((p) => p.name); + + const prompt = new Select({ + name: 'deleteProfile', + message: 'Delete a profile', + choices: [...profiles, 'Back'], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') return manageProfilesMenu(); + + config.profiles = config.profiles.filter((p) => p.name !== choice); + config.blocklist = config.blocklist.filter((d) => d.profile !== choice); + config.whitelist = config.whitelist.filter((d) => d.profile !== choice); + await editConfig(config); + + return manageProfilesMenu(); +}; + +const manageProfilesMenu = async () => { + const profiles = config.profiles.map((p) => p.name); + + const prompt = new Select({ + name: 'manageProfiles', + message: 'Manage profiles', + choices: [ + ...(profiles.length > 0 ? ['Update a profile', 'Create a profile', 'Delete a profile'] : ['Create a profile']), + 'Back', + ], + }); + + const choice = await prompt.run(); + + if (choice === 'Back') return mainMenu(); + if (choice === 'Create a profile') return createProfile(); + if (choice === 'Delete a profile') return deleteProfile(); + if (choice === 'Update a profile') { + profile = await selectProfile(); + return updateProfileMenu(); + } + + return true; +}; + +const enableShieldMode = async () => { + config.shield = true; + + return mainMenu(); +}; + +const disableShieldMode = async () => { + config.shield = false; + + return mainMenu(); +}; + +const takeBreak = async () => { + console.log('Taking a break...'); + + return mainMenu(); +}; + +const mainMenu = async () => { + const prompt = new Select({ + name: 'mainMenu', + message: 'Choose an option', + choices: [ + 'Manage profiles', + config.shield ? 'Disable shield mode' : 'Enable shield mode', + 'Take a break', + 'Quit', + ], + }); + + const choice = await prompt.run(); + + if (choice === 'Manage profiles') return manageProfilesMenu(); + if (choice === 'Enable shield mode') return enableShieldMode(); + if (choice === 'Disable shield mode') return disableShieldMode(); + if (choice === 'Take a break') return takeBreak(); + + return true; +}; + +export default mainMenu; diff --git a/src/shield.js b/src/shield.js index c978e2d..d2dcdf6 100644 --- a/src/shield.js +++ b/src/shield.js @@ -1,5 +1,4 @@ import fs from 'fs'; -import { isAbsolute } from 'path'; import { execSync } from 'child_process'; import { isValidApp } from './block'; import { config, editConfig } from './config'; @@ -29,7 +28,7 @@ export const blockRoot = () => { fs.writeFileSync('/etc/sudoers.d/ulysse', `${process.env.SUDO_USER} ALL=(ALL) !ALL`, 'utf8'); for (const w of config.whitelist) { - if (isValidApp(w.name) && isAbsolute(w.name)) { + if (isValidApp(w.name)) { fs.appendFileSync('/etc/sudoers.d/ulysse', `\n${process.env.SUDO_USER} ALL=(ALL) ${w.name}`, 'utf8'); } } diff --git a/src/socket.js b/src/socket.js index 60a8903..3a31538 100644 --- a/src/socket.js +++ b/src/socket.js @@ -2,36 +2,8 @@ import 'dotenv/config'; import fs from 'fs'; import net from 'net'; import Gun from 'gun'; -import { config } from './config'; -import { removeDuplicates } from './utils'; -import { SOCKET_PATH, CONFIG_PATH, GUN_SERVER } from './constants'; -import { blockRoot, unblockRoot, isValidPassword } from './shield'; - -const removeTimeouts = (list) => list.filter(({ timeout }) => !timeout || timeout >= Math.floor(Date.now() / 1000)); - -const editConfig = (newConfig) => { - const { blocklist = [], whitelist = [], date, shield, password, passwordHash } = newConfig; - - config.date = date; - config.whitelist = removeTimeouts(removeDuplicates(config.shield ? config.whitelist : whitelist)); - config.blocklist = removeTimeouts(removeDuplicates(config.shield ? [...config.blocklist, ...blocklist] : blocklist)); - - if (isValidPassword(password)) { - unblockRoot(); - config.shield = false; - delete config.passwordHash; - } - - if (shield && passwordHash) { - blockRoot(); - config.shield = true; - config.passwordHash = passwordHash; - } - - delete config.password; - - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 4), 'utf8'); -}; +import { editConfig } from './edit'; +import { SOCKET_PATH, GUN_SERVER } from './constants'; const server = net.createServer((connection) => { let buffer = ''; diff --git a/src/utils.js b/src/utils.js index b5f82af..a52d232 100644 --- a/src/utils.js +++ b/src/utils.js @@ -129,3 +129,17 @@ export const isDaemonRunning = () => { export const getAlias = (key) => key?.replace('--', '-').slice(0, 2); export const getRootDomain = (domain) => domain.split('.').slice(-2).join('.'); + +export const getAllApps = () => { + const paths = process.env.PATH.split(':'); + + const apps = paths.reduce((acc, path) => { + try { + return [...acc, ...fs.readdirSync(path)]; + } catch { + return acc; + } + }, []); + + return apps; +}; diff --git a/src/whitelist.js b/src/whitelist.js index 54893a3..8d4aab0 100644 --- a/src/whitelist.js +++ b/src/whitelist.js @@ -1,5 +1,5 @@ +import { GUN_SERVER } from './constants'; import { config, editConfig } from './config'; -import { SYSTEM_WHITELIST } from './constants'; import { getRootDomain, removeDuplicates, getTimeType, createTimeout, isWithinTimeRange } from './utils'; export const whitelistDistraction = async (distraction) => { @@ -17,8 +17,16 @@ export const whitelistDistraction = async (distraction) => { await editConfig(config); }; +export const unwhitelistDistraction = async (distraction) => { + if (config.shield) return; + + config.whitelist = config.whitelist.filter(({ name, time }) => JSON.stringify({ name, time }) !== JSON.stringify(distraction)); + + await editConfig(config); +}; + export const isDistractionWhitelisted = (distraction) => { - if (SYSTEM_WHITELIST.some((d) => d === distraction && isWithinTimeRange(d.time))) return true; + if (distraction.name === new URL(GUN_SERVER).hostname) return true; if (config.whitelist.some((d) => d.name.slice(0, 15) === distraction && isWithinTimeRange(d.time))) return true; if (config.whitelist.some((d) => d.name === distraction && isWithinTimeRange(d.time))) return true; if (config.whitelist.some((d) => d.name === '*' && isWithinTimeRange(d.time))) return true; diff --git a/src/x11.js b/src/x11.js new file mode 100644 index 0000000..9540972 --- /dev/null +++ b/src/x11.js @@ -0,0 +1,79 @@ +import x11 from 'x11'; + +const connectToX11 = () => new Promise((resolve, reject) => { + const client = x11.createClient((err, display) => { + if (err) { + reject(err); + } else { + resolve(display); + } + }); + + client.on('error', (err) => { + reject(err); + }); +}); + +const getProperty = (display, windowId, atom, type) => new Promise((resolve, reject) => { + display.client.GetProperty(0, windowId, atom, type, 0, 1000000, (err, prop) => { + if (err) { + reject(err); + } else { + resolve(prop); + } + }); +}); + +const internAtom = (display, atomName) => new Promise((resolve, reject) => { + display.client.InternAtom(false, atomName, (err, atom) => { + if (err) { + reject(err); + } else { + resolve(atom); + } + }); +}); + +export const closeWindow = async (windowId) => { + let display; + + try { + display = await connectToX11(); + display.client.DestroyWindow(windowId); + } finally { + await display.client.terminate(); + } +}; + +export const listActiveWindows = async () => { + let display; + const windows = []; + + try { + display = await connectToX11(); + const { root } = display.screen[0]; + + const atom = await internAtom(display, '_NET_CLIENT_LIST'); + const type = await internAtom(display, 'WINDOW'); + const prop = await getProperty(display, root, atom, type); + + const windowIds = Array.from({ length: prop.data.length / 4 }, (_, i) => prop.data.readUInt32LE(i * 4)); + + for await (const windowId of windowIds) { + const nameProp = await getProperty(display, windowId, display.client.atoms.WM_CLASS, display.client.atoms.STRING); + const name = nameProp?.data?.toString().split('\0')[0] || 'N/A'; + + const pidAtom = await internAtom(display, '_NET_WM_PID'); + const pidProp = await getProperty(display, windowId, pidAtom, display.client.atoms.CARDINAL); + const pid = pidProp?.data?.length > 0 ? pidProp.data.readUInt32LE(0) : 'N/A'; + + windows.push({ windowId, name, pid }); + } + } catch { + return windows; + } finally { + await display.client.terminate(); + } + + return windows; +};