"))
178 | (req-payload (mapcar
179 | (lambda (x)
180 | (pcase x
181 | ;; because parsing JSON string to plist convert JS true/false
182 | ;; to keywords :true/:false sometimes. Seems like a bug in Emacs's
183 | ;; JSON parsing
184 | (:false nil)
185 | (:true t)
186 | ("null" nil)
187 | (val val)))
188 | (plist-get request :payload)))
189 | (res-payload (if handler (funcall handler req-payload)
190 | `((status . error)
191 | (message . ,(format "Unknown request: %s" request-name))))))
192 | (spookfox--send-msg (json-encode `((requestId . ,request-id)
193 | (payload . ,res-payload)))
194 | client-ws)))
195 |
196 | (defun spookfox--register-req-handler (name handler)
197 | "Run HANDLER every time request with NAME is received from browser.
198 | Return value of HANDLER is sent back to browser as response."
199 | (let* ((name (concat spookfox--msg-prefix name))
200 | (cell (assoc name spookfox--req-handlers-alist #'string=)))
201 | (when cell (warn "Handler already registered. Overwriting previously registered handler."))
202 | (push (cons name handler) spookfox--req-handlers-alist)))
203 |
204 | ;;;###autoload
205 | (defun spookfox-init ()
206 | "Initialize spookfox.
207 | This function is obsolete. Please use spookfox-start-server."
208 | (spookfox-start-server))
209 | (make-obsolete #'spookfox-init #'spookfox-start-server 'v0.6.0)
210 |
211 | (defun spookfox-shutdown ()
212 | "Stop spookfox."
213 | (interactive)
214 | (spookfox-stop-server))
215 |
216 | (provide 'spookfox)
217 | ;;; spookfox.el ends here
218 |
--------------------------------------------------------------------------------
/manifest.scm:
--------------------------------------------------------------------------------
1 | (use-modules (guix packages)
2 | (gnu packages node)
3 | (gnu packages base))
4 |
5 | (packages->manifest (list gnu-make node))
6 |
--------------------------------------------------------------------------------
/readme.org:
--------------------------------------------------------------------------------
1 | * Spookfox
2 |
3 | Communicate between Firefox/Chrome and Emacs. Because [[https://nyxt.atlas.engineer/][Nyxt]] is just not there yet.
4 |
5 | #+begin_quote
6 | ⚠️ Caution: Spookfox is in early development and I am making breaking changes all
7 | over the place.
8 | #+end_quote
9 |
10 | #+begin_quote
11 | ⚠️ Caution: Although Chromium is supported and a .crx file is generated in build,
12 | Chrome won't let you install and use it. Stop using Chrome. PS You'll have to
13 | drag-n-drop the downloaded crx file to Chrome to even install it, because
14 | Google.
15 | #+end_quote
16 |
17 | For installation, usage, and more; please visit the [[https://bitspook.in/projects/spookfox][project page]].
18 |
--------------------------------------------------------------------------------
/spookfox-addon/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true, // Allows for the use of predefined global variables for browsers (document, window, etc.)
4 | },
5 | extends: [
6 | 'eslint:recommended', // Use the recommened rules from eslint
7 | 'plugin:@typescript-eslint/recommended', // Use the recommended rules from @typescript-eslint/eslint-plugin
8 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier to display Prettier errors as ESLint errors
9 | ],
10 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser
11 | parserOptions: {
12 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
13 | ecmaFeatures: {
14 | jsx: true, // Allows for the parsing of JSX
15 | },
16 | sourceType: 'module', // Allows for the use of imports
17 | },
18 | plugins: [
19 | '@typescript-eslint', // Allows for manually setting @typescript-eslint/* rules
20 | 'prettier', // Allows for manually setting prettier/* rules
21 | ],
22 | settings: {
23 | react: {
24 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use
25 | },
26 | },
27 | rules: { },
28 | };
29 |
--------------------------------------------------------------------------------
/spookfox-addon/.parcelrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@parcel/config-webextension"
3 | }
--------------------------------------------------------------------------------
/spookfox-addon/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | singleQuote: true,
4 | trailingComma: "es5",
5 | };
6 |
--------------------------------------------------------------------------------
/spookfox-addon/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spookfox",
3 | "version": "0.7.1",
4 | "license": "GPL-3.0",
5 | "scripts": {
6 | "clean": "rm -rf dist && rm -rf ./addons-dist",
7 | "start": "npm run clean && parcel serve src/manifest.json 'src/apps/*.ts'",
8 | "lint": "eslint src/ && tsc --noEmit",
9 | "build:ff": "web-ext -s ./dist -a ./addons-dist build",
10 | "build:chrome": "crx pack ./dist -p ./key.pem -o ./addons-dist/spookfox.crx",
11 | "build:addon": "mkdir -p ./addons-dist && npm run build:chrome && npm run build:ff",
12 | "build:unpackaged": "parcel build src/manifest.json 'src/apps/**/*.ts'",
13 | "build": "npm run clean && npm run build:unpackaged && npm run build:addon",
14 | "publish:ff-unlisted": "web-ext -s ./dist -a ./addons-dist --api-key $FIREFOX_ADDON_KEY --api-secret $FIREFOX_ADDON_SECRET sign --channel=unlisted"
15 | },
16 | "devDependencies": {
17 | "@parcel/config-webextension": "2.12.0",
18 | "@parcel/transformer-sass": "2.12.0",
19 | "@types/webextension-polyfill": "0.12.1",
20 | "@typescript-eslint/eslint-plugin": "8.4.0",
21 | "@typescript-eslint/parser": "8.4.0",
22 | "crx": "5.0.1",
23 | "eslint": "8.57.0",
24 | "eslint-config-prettier": "9.1.0",
25 | "eslint-plugin-prettier": "5.2.1",
26 | "parcel": "2.12.0",
27 | "prettier": "3.3.3",
28 | "typescript": "5.5.4",
29 | "typescript-language-server": "4.3.3",
30 | "web-ext": "8.2.0",
31 | "webextension-polyfill": "0.12.0"
32 | },
33 | "dependencies": {
34 | "immer": "10.1.1",
35 | "uuid": "10.0.0"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/spookfox-addon/src/Spookfox.ts:
--------------------------------------------------------------------------------
1 | import { produce, Immutable } from 'immer';
2 | import { v4 as uuid } from 'uuid';
3 |
4 | interface ErrorResPayload {
5 | status: 'error';
6 | message: string;
7 | }
8 |
9 | interface Response {
10 | type: 'response';
11 | requestId: string;
12 | payload: ErrorResPayload | any;
13 | }
14 |
15 | interface Request {
16 | id: string;
17 | name: string;
18 | payload: any;
19 | type: 'request';
20 | }
21 |
22 | export interface SFApp {
23 | name: string;
24 | initialState: Immutable;
25 | reducer: (action: { name: string; payload: any }, state: S) => S;
26 | init?: () => void;
27 | }
28 |
29 | export interface SFAppConstructor {
30 | new (name: string, sf: Spookfox): SFApp;
31 | }
32 |
33 | /**
34 | * Events known to Spookfox.
35 | * For documentation and ease of refactoring.
36 | */
37 | export enum SFEvents {
38 | CONNECTED = 'CONNECTED',
39 | CONNECTING = 'CONNECTING',
40 | DISCONNECTED = 'DISCONNECTED',
41 | // A request Emacs sent to do something or to provide some information
42 | REQUEST = 'REQUEST',
43 | // Response Emacs sent for a request we made
44 | RESPONSE = 'RESPONSE',
45 | // Spookfox has had a state change, and new state is available
46 | NEW_STATE = 'NEW_STATE',
47 | }
48 |
49 | /**
50 | * A custom event which has an optional payload attached.
51 | */
52 | export class SFEvent extends Event {
53 | constructor(
54 | public name: string,
55 | public payload?: P
56 | ) {
57 | super(name);
58 | }
59 | }
60 |
61 | enum LogLevel {
62 | Error = 0,
63 | Info = 1,
64 | Debug = 2,
65 | }
66 |
67 | /**
68 | * `Spookfox` is the heart of this addon.
69 | *
70 | * # Usage
71 | *
72 | * ```js
73 | * const sf = new Spookfox();
74 | * ```
75 | *
76 | * # Events
77 | *
78 | * It emits `SFEvents`. `SFEvents.REQUEST` and `SFEvents.RESPONSE` don't
79 | * need to be handled manually. `Spookfox.request` and `Spookfox.registerReqHandler`
80 | * should be sufficient for most cases.
81 | */
82 | // Extends `EventTarget` so we can have the ability to emit and listen to custom
83 | // events. We rely on custom events to build independent modules, while
84 | // providing a unified interface.
85 | export class Spookfox extends EventTarget {
86 | state = {};
87 | reqHandlers = {};
88 | // This is needed only for re-init hack
89 | eventListeners = [];
90 | debugLevel: LogLevel;
91 | apps: { [name: string]: SFApp } = {};
92 | wsUrl = 'ws://localhost:59001';
93 | ws: WebSocket;
94 | reConnecting = false;
95 |
96 | constructor() {
97 | super();
98 | this.ws = this.reConnect();
99 | this.setupEventListeners();
100 | }
101 |
102 | get isConnected() {
103 | return this.ws && this.ws.readyState === this.ws.OPEN;
104 | }
105 |
106 | get logLevel(): LogLevel {
107 | return (
108 | parseInt(localStorage.getItem('SPOOKFOX_DEBUG'), 10) || LogLevel.Error
109 | );
110 | }
111 |
112 | addEventListener(
113 | type: string,
114 | callback: EventListenerOrEventListenerObject
115 | ): void {
116 | this.eventListeners.push({ type, callback });
117 | super.addEventListener(type, callback);
118 | }
119 |
120 | removeEventListener(
121 | type: string,
122 | callback: EventListenerOrEventListenerObject
123 | ): void {
124 | this.eventListeners = this.eventListeners.filter(
125 | (el) => !(el.type === type && el.callback === callback)
126 | );
127 | super.removeEventListener(type, callback);
128 | }
129 |
130 | private setupEventListeners() {
131 | this.addEventListener(SFEvents.REQUEST, this.handleRequest);
132 | this.addEventListener(SFEvents.RESPONSE, this.handleResponse);
133 | }
134 |
135 | handleServerMsg = async (event: MessageEvent) => {
136 | try {
137 | const msg = JSON.parse(event.data);
138 |
139 | if (msg.name) {
140 | return this.emit(SFEvents.REQUEST, msg);
141 | }
142 |
143 | return this.emit(SFEvents.RESPONSE, msg);
144 | } catch (err) {
145 | console.error(`Bad ws message [err=${err}, msg=${event.data}]`);
146 | }
147 | };
148 |
149 | reConnect() {
150 | if (this.ws) {
151 | this.ws.close();
152 | this.reConnecting = true;
153 | }
154 |
155 | if (!this.reConnecting) this.emit(SFEvents.CONNECTING);
156 | this.ws = new WebSocket(this.wsUrl);
157 |
158 | const handleWsOpen = () => {
159 | this.emit(SFEvents.CONNECTED);
160 | };
161 |
162 | const handleWsClose = () => {
163 | this.ws.removeEventListener('open', handleWsOpen);
164 | this.ws.removeEventListener('close', handleWsClose);
165 | this.ws.removeEventListener('message', this.handleServerMsg);
166 |
167 | this.emit(SFEvents.DISCONNECTED);
168 | if (this.reConnecting) this.emit(SFEvents.CONNECTING);
169 |
170 | this.ws = null;
171 | this.reConnecting = false;
172 | };
173 |
174 | this.ws.addEventListener('open', handleWsOpen);
175 | this.ws.addEventListener('close', handleWsClose);
176 | this.ws.addEventListener('message', this.handleServerMsg);
177 |
178 | return this.ws;
179 | }
180 |
181 | /**
182 | * Send a request with NAME and PAYLOAD to Emacs.
183 | * Returns a promise of response returned by Emacs.
184 | * # Example
185 | * ```
186 | * const savedTabs = sf.request('GET_SAVED_TABS');
187 | * ```
188 | */
189 | async request(name: string, payload?: object) {
190 | if (!this.ws) {
191 | console.error(
192 | `Not connected to Spookfox Server. Dropping request ${name}.`
193 | );
194 | return;
195 | }
196 |
197 | const request = {
198 | id: uuid(),
199 | name,
200 | payload,
201 | };
202 |
203 | this.ws.send(JSON.stringify(request));
204 |
205 | const res = await this.getResponse(request.id);
206 | if (res.payload.status?.toLowerCase() === 'error') {
207 | throw new Error(res.payload.message);
208 | }
209 |
210 | return res.payload;
211 | }
212 |
213 | /**
214 | * A convenience function for emitting new events to `Spookfox`.
215 | */
216 | emit = (name: SFEvents, payload?: object) => {
217 | const event = new SFEvent(name, payload);
218 |
219 | if (this.logLevel >= LogLevel.Debug) {
220 | console.log('Emitting', event);
221 | }
222 |
223 | this.dispatchEvent(event);
224 | };
225 |
226 | /**
227 | * Run a function when Emacs makes a request.
228 | * # Example
229 | * ```
230 | * sf.registerReqHandler('OPEN_TAB', ({ url }) => {
231 | * // somehow open a new tab with `url` provided by Emacs.
232 | * })
233 | * ```
234 | */
235 | registerReqHandler(
236 | name: string,
237 | handler: (payload: any, sf: Spookfox) => object
238 | ) {
239 | if (this.reqHandlers[name] && this.logLevel) {
240 | console.warn(`Overwriting handler for: ${name}`);
241 | }
242 |
243 | this.reqHandlers[name.toUpperCase()] = handler;
244 | }
245 |
246 | registerApp(name: string, App: SFAppConstructor) {
247 | if (this.apps[name]) return;
248 | this.apps[name] = new App(name, this);
249 |
250 | if (typeof this.apps[name].init === 'function') this.apps[name].init();
251 |
252 | this.state = produce(this.state, (state) => {
253 | state[name] = this.apps[name].initialState;
254 | });
255 | }
256 |
257 | /**
258 | * Change Spookfox state. Calling this will set the state to new given state,
259 | * and emit `SFEvents.NEW_STATE` event.
260 | * Spookfox.state should be treated as immutable and shouldn't be modified in-place.
261 | * # Example
262 | * ```
263 | * const newState = { ... };
264 | * sf.newState(newState, 'X kind of change.');
265 | * ```
266 | */
267 | private replaceState(s: any) {
268 | this.state = s;
269 | this.emit(SFEvents.NEW_STATE, s);
270 | }
271 |
272 | /**
273 | * Handle `SFEvents.REQUEST` events.
274 | */
275 | private handleRequest = async (e: SFEvent) => {
276 | const request = e.payload;
277 |
278 | const executioner = this.reqHandlers[request.name.toUpperCase()];
279 |
280 | if (!executioner) {
281 | console.warn('No handler for request', { request });
282 | return;
283 | }
284 | const response = await executioner(request.payload, this);
285 |
286 | return this.ws?.send(
287 | JSON.stringify({
288 | requestId: request.id,
289 | payload: response,
290 | })
291 | );
292 | };
293 |
294 | /**
295 | * Handle `SFEvents.RESPONSE` events.
296 | */
297 | private handleResponse = async (e: SFEvent) => {
298 | const res = e.payload;
299 |
300 | if (!res.requestId) {
301 | throw new Error(`Invalid response: [res=${JSON.stringify(res)}]`);
302 | }
303 |
304 | // Emit a unique event per `requestId`. Shenanigans I opted for doing
305 | // to build a promise based interface on request/response dance needed
306 | // for communication with Emacs. Check `Spookfox.getResponse`
307 | this.emit(res.requestId as SFEvents, res);
308 | };
309 |
310 | private getResponse = (requestId: string): Promise => {
311 | const maxWait = 5000;
312 |
313 | return new Promise((resolve, reject) => {
314 | const listener = (event: SFEvent) => {
315 | clearTimeout(killTimer);
316 | this.removeEventListener(requestId, listener);
317 |
318 | resolve(event.payload);
319 | };
320 | this.addEventListener(requestId, listener);
321 |
322 | // If it's taking too long to for Emacs to respond, something bad has
323 | // probably happened and we aren't getting any response. Time to abort the
324 | // response.
325 | const killTimer = setTimeout(() => {
326 | this.removeEventListener(requestId, listener);
327 | reject(new Error('Spookfox response timeout.'));
328 | }, maxWait);
329 | });
330 | };
331 |
332 | private rootReducer({ name, payload }: Action): any {
333 | const [appName, actionName] = name.split('/');
334 |
335 | if (!appName || !actionName) {
336 | throw new Error(
337 | 'Invalid Action "`${name}`". Action should be in format "/"'
338 | );
339 | }
340 |
341 | if (this.logLevel) {
342 | console.groupCollapsed(name);
343 | console.log('Payload', payload);
344 | }
345 |
346 | const app = this.apps[appName];
347 |
348 | if (!app) {
349 | console.log('APPS', app, appName, this.apps[appName]);
350 | console.groupEnd();
351 | throw new Error(
352 | `Could not find Spookfox app "${appName}". Was it registered?`
353 | );
354 | }
355 |
356 | const nextState = produce(this.state, (draft: typeof app.initialState) => {
357 | draft[appName] = app.reducer(
358 | { name: actionName, payload },
359 | draft[appName]
360 | );
361 | });
362 |
363 | if (this.logLevel) {
364 | console.log('Next state', nextState);
365 | console.groupEnd();
366 | }
367 |
368 | return nextState;
369 | }
370 |
371 | dispatch(name: string, payload: any) {
372 | // Need to manually do the error handling here because Firefox is eating
373 | // these errors up and not showing them in addon's console
374 | try {
375 | const newState = this.rootReducer({ name, payload });
376 | this.replaceState(newState);
377 | } catch (err) {
378 | console.error('Error during dispatching action, [err=', err, ']');
379 | }
380 | }
381 | }
382 |
383 | interface Action {
384 | name: string;
385 | payload?: any;
386 | }
387 |
--------------------------------------------------------------------------------
/spookfox-addon/src/apps/JsInject.ts:
--------------------------------------------------------------------------------
1 | import { Draft, Immutable } from 'immer';
2 | import browser from 'webextension-polyfill';
3 | import { SFApp, Spookfox } from '~src/Spookfox';
4 |
5 | export type JsInjectState = Immutable;
6 |
7 | export default class JsInject implements SFApp {
8 | initialState: Immutable = null;
9 |
10 | get state(): JsInjectState {
11 | return this.sf.state[this.name];
12 | }
13 |
14 | dispatch(name: Actions, payload: unknown) {
15 | return this.sf.dispatch(`${this.name}/${name}`, payload);
16 | }
17 |
18 | constructor(public name: string, public sf: Spookfox) {
19 | sf.registerReqHandler(
20 | EmacsRequests.EVAL_IN_ACTIVE_TAB,
21 | this.evalJsInActiveTab
22 | );
23 | sf.registerReqHandler(
24 | EmacsRequests.EVAL_IN_BACKGROUND_SCRIPT,
25 | this.evalJsInBackgroundScript
26 | );
27 | sf.registerReqHandler(
28 | EmacsRequests.EVAL_IN_TAB,
29 | this.evalJsInTab
30 | )
31 | }
32 |
33 | /**
34 | * Inject Javascript sent by Emacs into active tab and send whatever it
35 | * returns as response.
36 | */
37 | evalJsInActiveTab = async (script: browser.ExtensionTypes.InjectDetails) => {
38 | const currentWindow = await browser.windows.getCurrent()
39 | const activeTabs = await browser.tabs.query({ active: true, windowId: currentWindow.id });
40 |
41 | if (!activeTabs.length) {
42 | throw new Error(
43 | 'No active tab to execute script in. [script=${JSON.stringify(script)}]'
44 | );
45 | }
46 |
47 | return Promise.all(
48 | activeTabs.map((tab) => browser.tabs.executeScript(tab.id, script))
49 | );
50 | };
51 |
52 | evalJsInTab = async ({ code, 'tab-id': tabId }: { code: string, 'tab-Id': string }) => {
53 | return browser.tabs.executeScript(tabId, {code})
54 | }
55 |
56 | evalJsInBackgroundScript = async ({ code }: { code: string }) => {
57 | const result = window.eval(code);
58 |
59 | return result;
60 | };
61 |
62 | reducer(_action: any, _state: Draft) {
63 | return this.initialState;
64 | }
65 | }
66 |
67 | export enum Actions {}
68 |
69 | export enum EmacsRequests {
70 | EVAL_IN_ACTIVE_TAB = 'JS_INJECT_EVAL_IN_ACTIVE_TAB',
71 | EVAL_IN_BACKGROUND_SCRIPT = 'JS_INJECT_EVAL_IN_BACKGROUND_SCRIPT',
72 | EVAL_IN_TAB = 'JS_INJECT_EVAL_IN_TAB'
73 | }
74 |
--------------------------------------------------------------------------------
/spookfox-addon/src/apps/Tabs.ts:
--------------------------------------------------------------------------------
1 | import { Draft, Immutable } from 'immer';
2 | import browser from 'webextension-polyfill';
3 | import { SFApp, Spookfox } from '~src/Spookfox';
4 |
5 | export type TabsState = Immutable;
6 |
7 | export interface SFTab {
8 | id: number;
9 | url: string;
10 | title: string;
11 | windowId: number;
12 | }
13 |
14 | export default class Tabs implements SFApp {
15 | initialState: Immutable = null;
16 |
17 | get state(): TabsState {
18 | return this.sf.state[this.name];
19 | }
20 |
21 | dispatch(name: Actions, payload: unknown) {
22 | return this.sf.dispatch(`${this.name}/${name}`, payload);
23 | }
24 |
25 | constructor(public name: string, public sf: Spookfox) {
26 | sf.registerReqHandler(EmacsRequests.GET_ACTIVE_TAB, this.getActiveTab);
27 | sf.registerReqHandler(EmacsRequests.GET_ALL_TABS, this.getAllTabs);
28 | sf.registerReqHandler(EmacsRequests.OPEN_TAB, this.openTab);
29 | sf.registerReqHandler(EmacsRequests.SEARCH_FOR, this.openSearchTab);
30 | }
31 |
32 | serializeTab(tab: browser.Tabs.Tab): SFTab {
33 | return {
34 | id: tab.id,
35 | url: tab.url,
36 | title: tab.title,
37 | windowId: tab.windowId,
38 | };
39 | }
40 |
41 | async currentWindowId() {
42 | const currentWindow = await browser.windows.getCurrent();
43 |
44 | return currentWindow.id;
45 | }
46 |
47 | /**
48 | * Get the active tab of given browser-window, or if none provided, of current
49 | * browser window. Current-browser window is decided by browser.
50 | */
51 | getActiveTab = async (msg: { windowId?: number }): Promise => {
52 | const windowId = (msg || {}).windowId || (await this.currentWindowId());
53 | const tabs = await browser.tabs.query({ windowId, active: true });
54 |
55 | if (!tabs.length) {
56 | // Probably shouldn't be doing this, but just throwing an error and calling it
57 | // a day simplifies the types a lot. Besides I am not sure if there will ever
58 | // be a case when a window don't have an active tab. This check is here because
59 | // doing `tabs[0]` give me heebie-jeebies
60 | throw new Error('No active tab found');
61 | }
62 |
63 | return tabs[0];
64 | };
65 |
66 | openSearchTab = async (p: string) => {
67 | (browser as any).search.search({ query: p });
68 |
69 | return {};
70 | };
71 |
72 | /**
73 | * Get all tabs which are open in given or current browser window. Follows
74 | * same semantics as `getActiveTab`
75 | */
76 | getAllTabs = async (msg: { windowId?: number } = {}): Promise => {
77 | const windowId = (msg || {}).windowId;
78 | let tabs: browser.Tabs.Tab[] = [];
79 | if (windowId) {
80 | tabs = await browser.tabs.query({ windowId });
81 | } else {
82 | const allWindows = await browser.windows.getAll();
83 | tabs = (
84 | await Promise.all(
85 | allWindows.map(({ id }) => {
86 | return browser.tabs.query({ windowId: id });
87 | })
88 | )
89 | ).flat();
90 | }
91 |
92 | return tabs.map(this.serializeTab);
93 | };
94 |
95 | openTab = async (p: { url: string }): Promise => {
96 | const tab = await browser.tabs.create({ url: p.url });
97 |
98 | return tab;
99 | };
100 |
101 | openTabs = async (
102 | tabs: {
103 | id: string;
104 | url: string;
105 | }[]
106 | ): Promise => {
107 | const openedTabs = await Promise.all(tabs.map(this.openTab));
108 |
109 | return openedTabs;
110 | };
111 |
112 | /**
113 | * Initialize the state.
114 | */
115 | init = async () => {
116 | this.dispatch(Actions.INIT, null);
117 | };
118 |
119 | reducer(_, state: Draft) {
120 | return state;
121 | }
122 | }
123 |
124 | export enum Actions {
125 | INIT = 'INIT',
126 | }
127 |
128 | export enum EmacsRequests {
129 | GET_ACTIVE_TAB = 'T_GET_ACTIVE_TAB',
130 | GET_ALL_TABS = 'T_GET_ALL_TABS',
131 | OPEN_TAB = 'T_OPEN_TAB',
132 | SEARCH_FOR = 'T_SEARCH_FOR',
133 | }
134 |
--------------------------------------------------------------------------------
/spookfox-addon/src/apps/Windows.ts:
--------------------------------------------------------------------------------
1 | import { Draft } from 'immer';
2 | import browser from 'webextension-polyfill';
3 | import { SFApp, Spookfox } from '~src/Spookfox';
4 |
5 | type State = undefined;
6 |
7 | interface SFWindow {
8 | id: number;
9 | title: string;
10 | isIcognito: boolean;
11 | }
12 |
13 | /**
14 | * App to work with multiple browser windows.
15 | */
16 | export default class Windows implements SFApp {
17 | initialState = undefined;
18 |
19 | constructor(public name: string, public sf: Spookfox) {
20 | sf.registerReqHandler(EmacsRequests.GET_ALL_WINDOWS, this.getAllWindows);
21 | }
22 |
23 | dispatch(name: Actions, payload: unknown) {
24 | return this.sf.dispatch(`${this.name}/${name}`, payload);
25 | }
26 |
27 | async currentWindowId() {
28 | const currentWindow = await browser.windows.getCurrent();
29 |
30 | return currentWindow.id;
31 | }
32 |
33 | getAllWindows = async (): Promise => {
34 | const windows = await browser.windows.getAll();
35 |
36 | return windows.map((w) => ({
37 | id: w.id,
38 | title: w.title,
39 | isIcognito: w.incognito,
40 | }));
41 | };
42 |
43 | /**
44 | * Initialize the state.
45 | */
46 | init = async () => {
47 | this.dispatch(Actions.INIT, null);
48 | };
49 |
50 | reducer(_, state: Draft) {
51 | return state;
52 | }
53 | }
54 |
55 | export enum Actions {
56 | INIT = 'INIT',
57 | }
58 |
59 | export enum EmacsRequests {
60 | GET_ALL_WINDOWS = 'WINDOWS_GET_ALL',
61 | }
62 |
--------------------------------------------------------------------------------
/spookfox-addon/src/background.ts:
--------------------------------------------------------------------------------
1 | import { SFEvents, Spookfox } from './Spookfox';
2 | // eslint-disable-next-line
3 | import iconEmacsMono from './icons/emacs-mono.svg';
4 | import iconEmacsColor from './icons/emacs-color.svg';
5 | import Tabs from './apps/Tabs';
6 | import OrgTabs from './apps/OrgTabs';
7 | import JsInject from './apps/JsInject';
8 | import browser from 'webextension-polyfill';
9 | import Windows from './apps/Windows';
10 |
11 | let autoConnectInterval = null;
12 | let connectedPorts: browser.Runtime.Port[] = [];
13 |
14 | // Messages from content script
15 | browser.runtime.onMessage.addListener(
16 | (msg: { type: string; action: { name: string; payload?: any } }) => {
17 | const sf = window.spookfox;
18 | switch (msg.type) {
19 | case 'SPOOKFOX_RELAY_TO_EMACS': {
20 | sf.request(msg.action.name, msg.action.payload);
21 | }
22 | }
23 | }
24 | );
25 |
26 | // Messages from popup
27 | browser.runtime.onConnect.addListener((port) => {
28 | connectedPorts.push(port);
29 | port.postMessage({
30 | type: window.spookfox.isConnected ? 'CONNECTED' : 'DISCONNECTED',
31 | });
32 |
33 | port.onDisconnect.addListener(
34 | () => (connectedPorts = connectedPorts.filter((p) => p !== port))
35 | );
36 |
37 | port.onMessage.addListener((msg: { type: string }) => {
38 | const sf = window.spookfox;
39 |
40 | switch (msg.type) {
41 | case 'RECONNECT':
42 | return sf.reConnect();
43 | }
44 | });
45 | });
46 |
47 | const startAutoconnectTimer = (sf: Spookfox) => {
48 | sf.addEventListener(SFEvents.CONNECTED, () => {
49 | browser.browserAction.setIcon({ path: iconEmacsColor });
50 |
51 | if (autoConnectInterval) clearInterval(autoConnectInterval);
52 |
53 | connectedPorts.forEach((port) => {
54 | port.postMessage({ type: 'CONNECTED' });
55 | });
56 | });
57 |
58 | sf.addEventListener(SFEvents.CONNECTING, () => {
59 | connectedPorts.forEach((port) => {
60 | port.postMessage({ type: 'CONNECTING' });
61 | });
62 | });
63 |
64 | sf.addEventListener(SFEvents.DISCONNECTED, () => {
65 | connectedPorts.forEach((port) => {
66 | port.postMessage({ type: 'DISCONNECTED' });
67 | });
68 |
69 | browser.browserAction.setIcon({ path: iconEmacsMono });
70 | if (!autoConnectInterval) {
71 | autoConnectInterval = setInterval(() => {
72 | sf.reConnect();
73 | }, 5000);
74 | }
75 | });
76 | };
77 |
78 | const run = async () => {
79 | const sf = ((window as any).spookfox = new Spookfox());
80 |
81 | // register all available apps
82 | sf.registerApp('js-injection', JsInject);
83 | sf.registerApp('tabs', Tabs);
84 | sf.registerApp('spookfox-windows', Windows);
85 |
86 | startAutoconnectTimer(sf);
87 | };
88 |
89 | run().catch((err) => {
90 | console.error('An error occurred in run()', err);
91 | });
92 |
--------------------------------------------------------------------------------
/spookfox-addon/src/content.ts:
--------------------------------------------------------------------------------
1 | import browser from 'webextension-polyfill';
2 |
3 | window.addEventListener('message', (event) => {
4 | if (
5 | event.data &&
6 | event.data.type === 'SPOOKFOX_RELAY_TO_EMACS' &&
7 | event.data.action &&
8 | typeof event.data.action.name === 'string'
9 | ) {
10 | const { type, action } = event.data;
11 |
12 | browser.runtime.sendMessage({ type, action });
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/chained-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/chained-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-color.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-color.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-color@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-color@2x.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-color@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-color@4x.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-mono.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-mono.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-mono.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-mono@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-mono@2x.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/emacs-mono@4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bitspook/spookfox/923bd75e3011a55a6c56c79a3f797b075fcba485/spookfox-addon/src/icons/emacs-mono@4x.png
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/refresh-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
95 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/refresh-light--active.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
99 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/refresh-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
99 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/unchained-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
--------------------------------------------------------------------------------
/spookfox-addon/src/icons/unchained-light.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
--------------------------------------------------------------------------------
/spookfox-addon/src/lib.ts:
--------------------------------------------------------------------------------
1 | export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
2 |
3 | export const gobbleErrorsOf =
4 | (fn: (...args: any[]) => Promise) =>
5 | async (...args) => {
6 | try {
7 | return await fn(...args);
8 | } catch (error) {
9 | console.error('Error while performing', { fn, error });
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/spookfox-addon/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Make web-browsers speak to Emacs",
3 | "manifest_version": 2,
4 | "name": "Spookfox",
5 | "version": "0.7.1",
6 | "icons": {
7 | "48": "icons/emacs-color.png",
8 | "96": "icons/emacs-color@2x.png",
9 | "192": "icons/emacs-color@4x.png"
10 | },
11 | "browser_specific_settings": {
12 | "chrome": {
13 | "id": "kkmebdchecbibghcdoohhffiogjcfcno"
14 | },
15 | "gecko": {
16 | "id": "spookfox@bitspook.in",
17 | "strict_min_version": "70.0"
18 | }
19 | },
20 | "background": {
21 | "scripts": [
22 | "../node_modules/webextension-polyfill/dist/browser-polyfill.js",
23 | "./background.ts"
24 | ]
25 | },
26 | "content_scripts": [
27 | {
28 | "matches": [""],
29 | "js": [
30 | "../node_modules/webextension-polyfill/dist/browser-polyfill.js",
31 | "content.ts"
32 | ]
33 | }
34 | ],
35 | "browser_action": {
36 | "default_icon": {
37 | "48": "icons/emacs-mono.png",
38 | "96": "icons/emacs-color@4x.png",
39 | "192": "icons/emacs-mono@2x.png"
40 | },
41 | "default_title": "Spookfox",
42 | "default_popup": "popup/global.html"
43 | },
44 | "permissions": ["", "tabs", "search", "scripting"],
45 | "web_accessible_resources": [],
46 | "content_security_policy": "script-src 'self' blob: filesystem: 'unsafe-eval';object-src 'self' blob: filesystem:;"
47 | }
48 |
--------------------------------------------------------------------------------
/spookfox-addon/src/popup/global.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Spookfox
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | Not Connected
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Hack spookfox on
27 |
Github!
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/spookfox-addon/src/popup/popup.ts:
--------------------------------------------------------------------------------
1 | const button = document.querySelector('.reconnect');
2 | const statusDot = document.querySelector('.status > #dot');
3 | const statusMsg = document.querySelector('.status > #msg');
4 |
5 | const reconnect = async (port: browser.Runtime.Port) => {
6 | port.postMessage({ type: 'RECONNECT' });
7 | };
8 |
9 | const handleConnected = () => {
10 | statusDot.classList.value = 'connected';
11 | statusMsg.innerHTML = 'Connected';
12 | button.classList.remove('rotating');
13 | };
14 |
15 | const handleDisconnected = () => {
16 | statusDot.classList.value = 'disconnected';
17 | statusMsg.innerHTML = 'Not connected';
18 | button.classList.remove('rotating');
19 | };
20 |
21 | const handleConnecting = () => {
22 | button.classList.add('rotating');
23 |
24 | statusDot.classList.value = 'connecting';
25 | statusMsg.innerHTML = 'Connecting...';
26 | };
27 |
28 | const init = async () => {
29 | const port = browser.runtime.connect();
30 |
31 | if (!port) {
32 | console.warn('No Spookfox port');
33 | return;
34 | }
35 |
36 | port.onMessage.addListener((msg: { type: string }) => {
37 | switch (msg.type) {
38 | case 'CONNECTED':
39 | return handleConnected();
40 | case 'CONNECTING':
41 | return handleConnecting();
42 | case 'DISCONNECTED':
43 | return handleDisconnected();
44 | }
45 | });
46 |
47 | button.addEventListener('click', () => reconnect(port));
48 | };
49 |
50 | init();
51 |
--------------------------------------------------------------------------------
/spookfox-addon/src/popup/styles.scss:
--------------------------------------------------------------------------------
1 | :root {
2 | --bg: #323232;
3 | --bg-contrasted: #535353;
4 | --connected: #129912;
5 | --disconnected: #991212;
6 | --connecting: #a09000;
7 | --seperator: #646464;
8 | --dim-text: #c2c2c2;
9 | --text: #ddd;
10 | --link: #a36004;
11 | --popup-height: 20em;
12 | }
13 |
14 | * {
15 | padding: 0;
16 | margin: 0;
17 | box-sizing: border-box;
18 | }
19 |
20 | body {
21 | background: var(--bg);
22 | color: var(--text);
23 | font-size: 10px;
24 | min-width: 20em;
25 | min-height: var(--popup-height);
26 | font-family: monospace;
27 | }
28 |
29 | a {
30 | color: var(--link);
31 | font-weight: bold;
32 | }
33 |
34 | .conn-status {
35 | background: var(--bg-contrasted);
36 | font-family: monospace;
37 | line-height: 1;
38 | color: var(--dim-text);
39 | width: 100%;
40 | padding: 1em;
41 | display: flex;
42 | border-bottom: 1px solid var(--seperator);
43 | align-items: center;
44 |
45 | .status {
46 | flex-grow: 1;
47 | display: flex;
48 | }
49 | }
50 |
51 | .conn-dot {
52 | width: 1em;
53 | height: 1em;
54 | border-radius: 50%;
55 | margin-right: 0.5em;
56 | }
57 |
58 | .connected {
59 | @extend .conn-dot;
60 | background-color: var(--connected);
61 | }
62 |
63 | .connecting {
64 | @extend .conn-dot;
65 | background-color: var(--connecting);
66 | }
67 |
68 | .disconnected {
69 | @extend .conn-dot;
70 | background-color: var(--disconnected);
71 | }
72 |
73 | .buttons {
74 | align-self: flex-end;
75 |
76 | button {
77 | border: none;
78 | }
79 |
80 | .reconnect {
81 | width: 1em;
82 | height: 1em;
83 | background: url('../icons/refresh-light.svg');
84 | cursor: pointer;
85 |
86 | &:hover {
87 | background: url('../icons/refresh-light--active.svg');
88 | }
89 |
90 | &:active {
91 | background: url('../icons/refresh-light.svg');
92 | }
93 | }
94 | }
95 |
96 | .rotating {
97 | animation: rotate 2s infinite linear;
98 | }
99 |
100 | @keyframes rotate {
101 | from { transform: rotate(0deg); }
102 | to { transform: rotate(360deg); }
103 | }
104 |
105 | .main {
106 | display: flex;
107 | align-items: center;
108 | justify-content: center;
109 | height: 15em;
110 | width: 100%;
111 | padding: 1em;
112 | }
113 |
--------------------------------------------------------------------------------
/spookfox-addon/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: any;
3 | export default content;
4 | }
5 |
6 | declare module 'uuid' {
7 | export function v4(): string;
8 | }
9 |
--------------------------------------------------------------------------------
/spookfox-addon/src/window.d.ts:
--------------------------------------------------------------------------------
1 | import { Spookfox } from './Spookfox';
2 |
3 | declare global {
4 | interface Window {
5 | spookfox: Spookfox;
6 | }
7 | }
8 |
9 | export {};
10 |
--------------------------------------------------------------------------------
/spookfox-addon/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react-jsx",
4 | "jsxImportSource": "preact/compat",
5 | "target": "ES2020",
6 | "moduleResolution": "node",
7 | "experimentalDecorators": true,
8 | "esModuleInterop": true,
9 | "baseUrl": ".",
10 | "typeRoots": ["node_modules/@types", "node_modules/web-ext-types"],
11 | "paths": {
12 | "~*": ["./*"]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/test/.envrc:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | eval "$(guix shell -m manifest.scm --search-paths)"
4 |
--------------------------------------------------------------------------------
/test/.gitignore:
--------------------------------------------------------------------------------
1 | /with-elpaca/elpaca
2 |
--------------------------------------------------------------------------------
/test/manifest.scm:
--------------------------------------------------------------------------------
1 | (use-modules (guix packages)
2 | (gnu packages emacs))
3 |
4 | (packages->manifest (list emacs))
5 |
--------------------------------------------------------------------------------
/test/readme.org:
--------------------------------------------------------------------------------
1 | * Testing Spookfox
2 |
3 | There are no automated tests so far. This setup is for manually testing Spookfox. This directory has
4 | code to set a clean testing environment on Guix.
5 |
6 | ** Testing with Elpaca
7 |
8 | As prompted by #39, here's how I tested the issue:
9 |
10 | 1. Load latest stable emacs: =cd test; direnv allow=
11 | 2. Start Emacs with just spookfox loaded; =emacs --init-dir ./with-elpaca=
12 |
13 | This starts Emacs, installs Elpaca and Spookfox as setup defined in =./with-elpaca/init.el=
14 | 3. Change Spookfox branch/repo: Edit =./with-elpaca/init.el= and run =rm -rf
15 | ./with-elpaca/elpaca/builds/spookfox && rm -rf ./with-elpaca/elpaca/repos/spookfox=, and
16 | repeat step 2.
17 |
--------------------------------------------------------------------------------
/test/with-elpaca/init.el:
--------------------------------------------------------------------------------
1 | ;; -*- lexical-binding: t -*-
2 |
3 | ;; Elpaca
4 | (defvar elpaca-installer-version 0.8)
5 | (defvar elpaca-directory (expand-file-name "elpaca/" user-emacs-directory))
6 | (defvar elpaca-builds-directory (expand-file-name "builds/" elpaca-directory))
7 | (defvar elpaca-repos-directory (expand-file-name "repos/" elpaca-directory))
8 | (defvar elpaca-order '(elpaca :repo "https://github.com/progfolio/elpaca.git"
9 | :ref nil
10 | :files (:defaults (:exclude "extensions"))
11 | :build (:not elpaca--activate-package)))
12 | (let* ((repo (expand-file-name "elpaca/" elpaca-repos-directory))
13 | (build (expand-file-name "elpaca/" elpaca-builds-directory))
14 | (order (cdr elpaca-order))
15 | (default-directory repo))
16 | (add-to-list 'load-path (if (file-exists-p build) build repo))
17 | (unless (file-exists-p repo)
18 | (make-directory repo t)
19 | (when (< emacs-major-version 28) (require 'subr-x))
20 | (condition-case-unless-debug err
21 | (if-let ((buffer (pop-to-buffer-same-window "*elpaca-bootstrap*"))
22 | ((zerop (call-process "git" nil buffer t "clone"
23 | (plist-get order :repo) repo)))
24 | ((zerop (call-process "git" nil buffer t "checkout"
25 | (or (plist-get order :ref) "--"))))
26 | (emacs (concat invocation-directory invocation-name))
27 | ((zerop (call-process emacs nil buffer nil "-Q" "-L" "." "--batch"
28 | "--eval" "(byte-recompile-directory \".\" 0 'force)")))
29 | ((require 'elpaca))
30 | ((elpaca-generate-autoloads "elpaca" repo)))
31 | (progn (message "%s" (buffer-string)) (kill-buffer buffer))
32 | (error "%s" (with-current-buffer buffer (buffer-string))))
33 | ((error) (warn "%s" err) (delete-directory repo 'recursive))))
34 | (unless (require 'elpaca-autoloads nil t)
35 | (require 'elpaca)
36 | (elpaca-generate-autoloads "elpaca" repo)
37 | (load "./elpaca-autoloads")))
38 | (add-hook 'after-init-hook #'elpaca-process-queues)
39 | (elpaca `(,@elpaca-order))
40 |
41 | ;; Install use-package support
42 | (elpaca elpaca-use-package
43 | ;; Enable :elpaca use-package keyword.
44 | (elpaca-use-package-mode)
45 | ;; Assume :elpaca t unless otherwise specified.
46 | (setq elpaca-use-package-by-default t))
47 |
48 | (elpaca-wait)
49 | ;; END Elpaca
50 |
51 | ;; Spookfox
52 | (use-package spookfox
53 | ;; :ensure (spookfox :host github :repo "edgar-vincent/spookfox" :ref "fix-issue-39" :files ("lisp/*.el" "lisp/apps/*.el"))
54 | :ensure (spookfox :host github :repo "bitspook/spookfox" :files ("lisp/*.el" "lisp/apps/*.el"))
55 | :config
56 | (require 'spookfox-tabs)
57 | (spookfox-start-server))
58 | ;; END Spookfox
59 |
--------------------------------------------------------------------------------