├── .envrc ├── .github └── workflows │ ├── build.yml │ └── check.yml ├── .gitignore ├── LICENSE ├── Makefile ├── architecture.org ├── changelog.org ├── contributing.org ├── flake.lock ├── flake.nix ├── lisp ├── apps │ ├── readme.org │ ├── spookfox-js-injection.el │ ├── spookfox-org-tabs.el │ ├── spookfox-tabs.el │ └── spookfox-windows.el └── spookfox.el ├── manifest.scm ├── readme.org ├── spookfox-addon ├── .eslintrc.js ├── .parcelrc ├── .prettierrc.js ├── package-lock.json ├── package.json ├── src │ ├── Spookfox.ts │ ├── apps │ │ ├── JsInject.ts │ │ ├── Tabs.ts │ │ └── Windows.ts │ ├── background.ts │ ├── content.ts │ ├── icons │ │ ├── chained-dark.svg │ │ ├── chained-light.svg │ │ ├── emacs-color.png │ │ ├── emacs-color.svg │ │ ├── emacs-color@2x.png │ │ ├── emacs-color@4x.png │ │ ├── emacs-mono.png │ │ ├── emacs-mono.svg │ │ ├── emacs-mono@2x.png │ │ ├── emacs-mono@4x.png │ │ ├── refresh-dark.svg │ │ ├── refresh-light--active.svg │ │ ├── refresh-light.svg │ │ ├── unchained-dark.svg │ │ └── unchained-light.svg │ ├── lib.ts │ ├── manifest.json │ ├── popup │ │ ├── global.html │ │ ├── popup.ts │ │ └── styles.scss │ ├── types.d.ts │ └── window.d.ts └── tsconfig.json └── test ├── .envrc ├── .gitignore ├── manifest.scm ├── readme.org └── with-elpaca └── init.el /.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | eval "$(guix shell -m manifest.scm --search-paths)" 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | jobs: 10 | build: 11 | environment: build 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | 17 | - uses: actions/setup-node@v2 18 | with: 19 | node-version: '18.x' 20 | 21 | - run: npm ci --include=optional 22 | working-directory: spookfox-addon 23 | 24 | - run: echo "${{secrets.CHROME_PRIVATE_KEY}} > key.pem" 25 | working-directory: spookfox-addon 26 | 27 | - run: npm run build 28 | working-directory: spookfox-addon 29 | 30 | - run: npm run publish:ff-unlisted 31 | working-directory: spookfox-addon 32 | env: 33 | FIREFOX_ADDON_KEY: "${{ secrets.FIREFOX_ADDON_KEY }}" 34 | FIREFOX_ADDON_SECRET: "${{ secrets.FIREFOX_ADDON_SECRET }}" 35 | 36 | - run: mv ./addons-dist/*.xpi ./addons-dist/spookfox-ff.xpi 37 | working-directory: spookfox-addon 38 | 39 | - run: mv ./addons-dist/*.crx ./addons-dist/spookfox-chrome.crx 40 | working-directory: spookfox-addon 41 | 42 | - name: Release 43 | uses: softprops/action-gh-release@v1 44 | with: 45 | files: | 46 | ./spookfox-addon/addons-dist/spookfox-ff.xpi 47 | ./spookfox-addon/addons-dist/spookfox-chrome.crx 48 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check if a PR can be merged to master 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: '16.x' 19 | 20 | - run: npm ci --include=optional 21 | working-directory: spookfox-addon 22 | 23 | - run: npm run lint 24 | working-directory: spookfox-addon 25 | 26 | version-check: 27 | name: Check if version is valid 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - uses: actions/checkout@v1 32 | 33 | - run: make version-check 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.log/ 3 | node_modules 4 | .parcel-cache 5 | spookfox-addon/dist/ 6 | /spookfox-addon/yarn-error.log 7 | /spookfox-addon/addons-dist/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | cd spookfox-addon; npm run build 3 | 4 | lint: 5 | cd spookfox-addon; npm run lint 6 | 7 | package_json_version := $(shell cat spookfox-addon/package.json | grep -o '"version": ".*"' | tr -d '[:blank:]"' | cut -d ':' -f 2) 8 | addon_version := $(shell cat spookfox-addon/src/manifest.json | grep -o '"version": ".*"' | tr -d '[:blank:]"' | cut -d ':' -f 2) 9 | el_pkg_version := $(shell cat lisp/spookfox.el | grep 'Version:.*' | grep -o '[0-9\.]*') 10 | el_version := $(shell cat lisp/spookfox.el | grep 'defvar.*version' | grep -o '[0-9\.]*') 11 | 12 | master_version := $(shell git show master:spookfox-addon/package.json | grep 'version' | grep -o '"[0-9\.]*"') 13 | 14 | version-check: 15 | ifneq ($(filter-out $(package_json_version), $(addon_version) $(el_version) $(el_pkg_version)),) 16 | $(error "Versions don't match. manifest.json, package.json and spookfox.el must have same version.") 17 | else ifeq ($(package_json_version),$(master_version)) 18 | $(error "Please bump the version. We will not be able to release same version again") 19 | else ifeq ($(shell git describe --tag --abbrev=0 2> /dev/null),) 20 | $(error "Please set a tag to release on HEAD.") 21 | else 22 | @echo "Versions look ok." 23 | endif 24 | 25 | version-set: 26 | ifeq ($(VERSION),) 27 | $(error "Please set VERSION argument. e.g make version-set VERSION=1.0.0") 28 | endif 29 | sed -i '/"version".*/s/"[0-9\.]*"/"$(VERSION)"/' spookfox-addon/package.json 30 | sed -i '/"version".*/s/"[0-9\.]*"/"$(VERSION)"/' spookfox-addon/src/manifest.json 31 | sed -i '/defvar.*version.*/s/"[0-9\.]*"/"$(VERSION)"/' lisp/spookfox.el 32 | sed -i '/Version/s/[0-9]\.[0-9]\.[0-9]/$(VERSION)/g' lisp/spookfox.el 33 | git add spookfox-addon/package.json spookfox-addon/src/manifest.json lisp/spookfox.el 34 | git commit -m 'Version bump to $(VERSION)' 35 | git tag -a v$(VERSION) -m "Version $(VERSION)" 36 | @echo "Version set to: ${VERSION}" 37 | 38 | clean: 39 | cd spookfox-addon && npm run clean 40 | 41 | publish-addon: 42 | cd spookfox-addon; npm run publish-addon 43 | -------------------------------------------------------------------------------- /architecture.org: -------------------------------------------------------------------------------- 1 | #+title: Spookfox Software Architecture 2 | 3 | Spookfox involves following major parts in terms of a tech stack. 4 | 5 | 1. *spookfox.el*: Emacs package written in Emacs Lisp 6 | 2. *spookfox-addon*: Firefox extension written in Javascript 7 | 8 | To make the browser extension talk to Emacs, Spookfox uses Websockets. A 9 | websockets server is started in Emacs, to which browser addon then connects. 10 | Emacs itself proved unusable as a native-messaging client. 11 | 12 | * Level 0 13 | 14 | Low level communication between browser and Emacs over websockets. 15 | 16 | ** Components 17 | 18 | Spookfox has 3 primary components: 19 | 20 | 1. <> : spookfox-addon : Firefox addon written in Javascript 21 | 2. <> : spookfox-el : Emacs package written in Emacs Lisp 22 | 23 | * Level 1 24 | 25 | Communication between browser and Emacs in terms of requests and responses. 26 | These aren't HTTP requests/responses, but a kinda-sorta protocol hand rolled 27 | atop of Level 0. 28 | 29 | ** Components 30 | 31 | 1. [[sa]] 32 | 2. [[se]] 33 | 34 | * Glossary 35 | 36 | - <> :: Analogous of a request in most protocols. This is a message one 37 | side sends to another, expecting it to do some operation and/or a response. 38 | Every request must have a unique ID. 39 | - <> :: Response to a request. Every response contains a =requestId= 40 | identifying which requests is being responded to. 41 | -------------------------------------------------------------------------------- /changelog.org: -------------------------------------------------------------------------------- 1 | ** v0.7.0 2 | 3 | - Removed jscl and org-tabs apps 4 | 5 | - *jscl*: [[https://github.com/jscl-project/jscl][jscl]] appear to be dead or almost dead; I don't want to build too much on top of it. The 6 | way it unconditionally add console logs on startup and all the hacks I had to do to include it 7 | in the addon don't give confidence. 8 | 9 | I have decided to remove jscl from Spookfox. I hope nobody was using it. 10 | 11 | I still want to make Spookfox extensible with a lisp. I'll probably add a scheme which has 12 | better support than jscl. Or compile ECL to wasm. Or guile-hoot is looking good too. But for 13 | now, no more jscl. 14 | 15 | - *org-tabs*: This was the original feature for which I created spookfox. I haven't used it since 16 | forever though because it is also most clunky. It probably didn't even work. Keeping tabs in 17 | sync with Emacs needs a lot of state-management. Which becomes an impossible task given that 18 | browser don't provide any way to uniquely identify a tab. Things like tab IDs change b/w browser 19 | restarts, browser emits tons of events per tab which can pretty much freeze Emacs etc. 20 | 21 | I want to pivot this to a simpler "archive all tabs" "restore tabs from archive" feature. 22 | org-tabs were kept around for this. But I think it's better to let them go and build from 23 | scratch when I get to implement the pivot. 24 | 25 | - Switch to tabs from all open browser windows 26 | 27 | =spookfox-switch-tab= now show tabs from all windows of browser, instead of just one. 28 | 29 | ** v0.6.0 30 | 31 | - No more apps on elisp side. 32 | 33 | They were meant to be more, but haven't grown. I deemed them a result of over-engineering, and 34 | have simplified installing/setting-up spookfox by removing them. 35 | 36 | ** v0.5.0 37 | 38 | - Extended [[https://github.com/bitspook/spookfox/blob/ed35f2d57a9021ad62871ed9eb3f8eedf0d3521e/lisp/apps/spookfox-js-injection.el#L65][spookfox-js-injection-eval]] to run JS in user selected tabs. Inspired by [[https://github.com/bitspook/spookfox/issues/38][#38]] 39 | 40 | spookfox-js-injection-eval now support ='tab= context, and additional =select-tab=p= argument. 41 | =select-tab-p= is a lambda which receives each open browser tab from all connected spookfox 42 | clients (i.e all browser windows) as a plist of shape =(:id :url :windowId :title)=. =js= is 43 | injected into each tab for which =select-tab-p= returns non-nil value, their results are 44 | collected, and returned to lisp in Emacs. 45 | 46 | E.g following code to run a script in all tabs which are visiting =bitspook.in=: 47 | 48 | #+begin_src elisp 49 | (spookfox-js-injection-eval 50 | "console.log('Hello from Emacs'); 5 + 5;" 51 | 'tab 52 | (lambda (tab) 53 | (s-contains-p "bitspook.in" (plist-get tab :url)))) 54 | ;; => ((:tab (:id 35 :url "https://bitspook.in/projects/spookfox/" :title "Spookfox" :windowId 1) :result [10])) 55 | #+end_src 56 | 57 | ** v0.4.1 58 | 59 | - Fix behavior of [[https://github.com/bitspook/spookfox/blob/ed35f2d57a9021ad62871ed9eb3f8eedf0d3521e/lisp/apps/spookfox-js-injection.el#L19][spookfox-js-injection-eval-in-active-tab]] in response to [[https://github.com/bitspook/spookfox/issues/37][#37]] 60 | 61 | This function is meant to run js in active tab in browser. Use case is "User switches from browser 62 | to Emacs, and want to quickly run some JS in browser. e.g when taking notes for a web page". 63 | However this function wasn't taking care of multiple browser-windows, and was running JS in active 64 | tab of all windows. 65 | 66 | This behavior is now changed. This function now runs js only in active tab of [[https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/getCurrent][current window]]. 67 | -------------------------------------------------------------------------------- /contributing.org: -------------------------------------------------------------------------------- 1 | #+begin_quote 2 | You might also wanna read [[./architecture.org]] 3 | #+end_quote 4 | 5 | If you are looking to contribute to spookfox's development, please follow this 6 | guide to setup a development environment. 7 | 8 | /Assumption/: you are on a Linux machine, and have cloned this repo. 9 | 10 | ** Setup 11 | 12 | *** Without Nix 13 | 14 | If you don't have [[https://nixos.org/][Nix]] installed, you will need to make sure that following 15 | software are available in your =$PATH=. 16 | 17 | 1. Node.js, for building the spookfox add-on 18 | 19 | *** With Nix 20 | 21 | If you have Nix installed, you can just do =nix-shell= to drop into an already 22 | setup development environment. Or if you are using [[https://direnv.net/][direnv]], just do a =direnv 23 | allow= to achieve the same. 24 | 25 | 26 | ** Install the addon 27 | 28 | 1. =cd spookfox-addon= 29 | 2. =yarn= 30 | 3. =yarn start= 31 | 4. Install in Firefox 32 | 1. Open Firefox 33 | 2. Go to [[about:addons][about:addons]] 34 | 3. Click on the gear icon and select "*Debug Add-Ons*" 35 | 4. Click on "*Load Temporary Add-on*" button 36 | 5. Navigate to the =spookfox-addon/dist= directory, and click =Open= 37 | 38 | Any changes you make to typescript/html/scss now will automatically rebuild. But 39 | you will still need to click on =Reload= button on *Debug Add-Ons* firefox page. 40 | 41 | ** Install spookfox.el 42 | 43 | *** Using straight.el 44 | 45 | You can use straight.el to install it directly from where you've clone this 46 | repo. 47 | 48 | #+begin_src elisp 49 | (use-package spookfox 50 | :straight (spookfox :type git 51 | :local-repo "~/Documents/work/spookfox" 52 | :files ("lisp/*.el" "lisp/apps/*.el")) 53 | :config 54 | (spookfox-init)) 55 | #+end_src 56 | 57 | This is how I install it in [[https://github.com/bitspook/spookmax.d/][my config]]. 58 | 59 | ** Tips 60 | 61 | *** Debugging the Firefox addon 62 | 63 | - Access addon's developer console 64 | 65 | In Firefox go to [[about:addons]], click /Gear Icon > Debug Addons > Inspect(in 66 | Spookfox box)/. This will open a developer console where you can see messages 67 | logged by the addon. There is also a global variable =spookfox= (instance of 68 | =Spookfox= class) available which you can play with. 69 | 70 | - Log state-changing events 71 | 72 | To see various levels of logged messages, please set =SPOOKFOX_DEBUG= to a 73 | number b/w 0 and 2 in =localStorage= i.e 74 | 75 | #+begin_src js 76 | localStorage.setItem('SPOOKFOX_DEBUG', 1) 77 | #+end_src 78 | 79 | 0 -> Log errors 80 | 1 -> Log information 81 | 2 -> Log for debugging 82 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1644229661, 6 | "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1646142557, 21 | "narHash": "sha256-IUj5bhLcEbEPtlxsPMUwlBvqOQVI7uIJ6z44XsCgwZc=", 22 | "owner": "nixos", 23 | "repo": "nixpkgs", 24 | "rev": "678464223fa046c68425fa23d9797322086c1bc1", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "nixos", 29 | "ref": "master", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/master"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils, ... }: 8 | flake-utils.lib.eachDefaultSystem (system: 9 | let 10 | overlays = []; 11 | pkgs = import nixpkgs { inherit system overlays; }; 12 | 13 | buildInputs = with pkgs; [ ]; 14 | 15 | nativeBuildInputs = with pkgs; [ 16 | nodejs 17 | yarn 18 | websocat 19 | ]; 20 | 21 | in 22 | { 23 | devShell = pkgs.mkShell 24 | ({ 25 | inherit buildInputs nativeBuildInputs; 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /lisp/apps/readme.org: -------------------------------------------------------------------------------- 1 | * Spookfox Apps 2 | 3 | A Spookfox app is a plist with following properties 4 | 5 | #+begin_src elisp 6 | `(:name symbol 7 | :on-init function 8 | :dependencies (other-app another-app)) 9 | #+end_src 10 | 11 | 1. =:name= is name of the app. This will be sent to Spookfox clients (i.e browser addon) to enable 12 | the app. 13 | 2. =:on-init= is a function which is executed when spookfox is initialized, *before* any client is 14 | connected. 15 | 3. =:dependencies= is list of spookfox-apps which should be initialized before this app 16 | -------------------------------------------------------------------------------- /lisp/apps/spookfox-js-injection.el: -------------------------------------------------------------------------------- 1 | ;;; spookfox-js-injection -- Spookfox app to inject Javascript into browser tabs -*- lexical-binding: t -*- 2 | 3 | ;;; Commentary: 4 | ;; Access, save and manipulate browser tabs 5 | 6 | ;;; Code: 7 | (require 'org) 8 | (require 'org-id) 9 | (require 'cl-lib) 10 | (require 'spookfox) 11 | (require 'spookfox-tabs) 12 | 13 | (defvar sfjsi--msg-prefix "JS_INJECT_") 14 | 15 | (defun sfjsi--request (&rest args) 16 | "Make spookfox-request with CLIENT and ARGS but with prefixed NAME." 17 | (let ((spookfox--msg-prefix sfjsi--msg-prefix)) 18 | (apply #'spookfox-request args))) 19 | 20 | (defun sfjsi-eval-in-active-tab (js &optional just-the-tip-p) 21 | "Evaluate JS in active firefox tab. 22 | Return value is a list of lists. Browser can have multiple active 23 | tabs (one per window). Every active tab can have multiple frames. 24 | If JUST-THE-TIP-P is non-nil, first tab's first frame's return 25 | value from the results is returned (instead of list of lists). 26 | 27 | JS is subjected to limitations of browser's ability to execute 28 | it. It is similar to executing js in browser's console. So for 29 | example running a script which declares a variable with `let` or 30 | `const` might cause the script to fail execution. 31 | 32 | Details about js execution: 33 | https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/executeScript" 34 | (let ((client (cl-first spookfox--connected-clients))) 35 | (when client 36 | (let ((result (plist-get 37 | (spookfox--poll-response 38 | (sfjsi--request 39 | client "EVAL_IN_ACTIVE_TAB" 40 | `((code . ,js)))) 41 | :payload))) 42 | (if just-the-tip-p (seq-first (seq-first result)) 43 | result))))) 44 | 45 | (defalias 'spookfox-eval-js-in-active-tab 'sfjsi-eval-in-active-tab) 46 | (make-obsolete 'spookfox-eval-js-in-active-tab 'sfjsi-eval-in-active-tab 'v0.3.0) 47 | 48 | (defun sfjsi--eval-in-tabs (client js select-tab-p) 49 | "Eval JS in all tabs for which SELECT-TAB-P return non nil. 50 | CLIENT is the websocket client. Return list of (:tab :result) 51 | with result of execution from each selected tab." 52 | (let* ((all-tabs (spookfox--request-all-tabs)) 53 | (selected-tabs (seq-filter 54 | (lambda (tab) (funcall select-tab-p tab)) 55 | all-tabs))) 56 | (mapcar 57 | (lambda (tab) 58 | (list :tab tab 59 | :result (plist-get 60 | (spookfox--poll-response 61 | (sfjsi--request client "EVAL_IN_TAB" 62 | `((code . ,js) (tab-id . ,(plist-get tab :id))))) 63 | :payload))) 64 | selected-tabs))) 65 | 66 | ;;;###autoload 67 | (cl-defun sfjsi-eval (js &optional (context 'background) (select-tab-p nil)) 68 | "Evaluate JS in CONTEXT. Return the result of evaluation. 69 | 70 | Supported contexts: 71 | 72 | 1. background 73 | Eval JS in addon's background page. 74 | 2. tab 75 | Eval JS in tab. When non-nil SELECT-TAB-P should be a function which 76 | receives each tab (a plist of at least :url, :id, :title, :windowId), 77 | and JS is evaluated in every tab for which it returns non-nil. 78 | " 79 | (let ((client (cl-first spookfox--connected-clients))) 80 | (when client 81 | (cl-case context 82 | (background (plist-get 83 | (spookfox--poll-response 84 | (sfjsi--request client "EVAL_IN_BACKGROUND_SCRIPT" `((code . ,js)))) 85 | :payload)) 86 | (tab (sfjsi--eval-in-tabs client js select-tab-p)) 87 | (t (error "Unsupported context: %s" context)))))) 88 | 89 | (provide 'spookfox-js-injection) 90 | ;;; spookfox-js-injection.el ends here 91 | ;; Local Variables: 92 | ;; read-symbol-shorthands: (("sfjsi-" . "spookfox-js-injection-")) 93 | ;; End: 94 | -------------------------------------------------------------------------------- /lisp/apps/spookfox-org-tabs.el: -------------------------------------------------------------------------------- 1 | ;;; spookfox-org-tabs -- Spookfox app to manage browser tabs as org-mode subtree -*- lexical-binding: t -*- 2 | 3 | ;;; Commentary: 4 | ;; ABANDONED. Please DO NOT USE THIS. I am keeping it around until I can change it to "archive tabs 5 | ;; to org file". 6 | ;; 7 | ;; Access, save and manipulate browser tabs 8 | 9 | ;;; Code: 10 | (require 'org) 11 | (require 'org-id) 12 | (require 'cl-lib) 13 | (require 'spookfox) 14 | (require 'spookfox-tabs) 15 | 16 | (defvar spookfox-saved-tabs-target `(file+headline ,(expand-file-name "spookfox.org" user-emacs-directory) "Tabs") 17 | "Target parse-able by org-capture-template where browser tabs will be saved.") 18 | 19 | (defvar spookfox--tab-history nil 20 | "History of accessing spookfox tabs.") 21 | 22 | (defvar spookfox--tab-group-history nil 23 | "History of accessing spookfox tab groups.") 24 | 25 | (defvar spookfox--known-tab-props '("id" "url" "chained" "id") 26 | "List of properties which are read when an org node is converted to a tab.") 27 | 28 | (defvar spookfox-org-tabs--msg-prefix "OT_") 29 | 30 | (defun spookfox-org-tabs--request (&rest args) 31 | "Make spookfox-request with CLIENT and ARGS but with prefixed NAME." 32 | (let ((spookfox--msg-prefix spookfox-org-tabs--msg-prefix)) 33 | (apply #'spookfox-request args))) 34 | 35 | (defun spookfox-org-tabs--request-active-tab () 36 | "Get details of active tab in browser." 37 | (let ((client (cl-first spookfox--connected-clients))) 38 | (when client 39 | (plist-get 40 | (spookfox--poll-response (spookfox-org-tabs--request client "GET_ACTIVE_TAB")) 41 | :payload)))) 42 | 43 | (defun spookfox-org-tabs--request-all-tabs () 44 | "Get all tabs currently present in browser." 45 | (spookfox--request-all-tabs)) 46 | 47 | (defun spookfox-org-tabs--insert-tab (tab) 48 | "Insert browser TAB as a new org-mode-subtree." 49 | (org-insert-heading) 50 | (let ((id (org-id-get-create))) 51 | (while tab 52 | (let ((prop (upcase (substring (format "%s" (pop tab)) 1))) 53 | (val (pop tab))) 54 | (cond 55 | ((string= "TITLE" prop) (org-edit-headline (if (spookfox--string-blank-p val) "" val))) 56 | ((string= "TAGS" prop) (org-set-tags val)) 57 | (t (org-entry-put (point) prop (format "%s" val)))))) 58 | id)) 59 | 60 | (defun spookfox-org-tabs--deserialize-tab () 61 | "Return spookfox tab for subtree at point. 62 | This function is useful for `org-map-entries`." 63 | (let ((props (org-entry-properties))) 64 | `(:title ,(alist-get "ITEM" props nil nil #'string=) 65 | :tags ,(mapcar #'substring-no-properties (org-get-tags)) 66 | ,@(seq-reduce (lambda (accum cell) 67 | (when (seq-contains-p spookfox--known-tab-props (downcase (car cell)) #'string=) ;; org-mode upcase all the property names 68 | (setq accum (plist-put accum 69 | (intern (downcase (format ":%s" (car cell)))) 70 | (pcase (cdr cell) 71 | ("t" t) 72 | ("nil" nil) 73 | (val val))))) 74 | accum) 75 | props nil)))) 76 | 77 | (defun spookfox-org-tabs--save-tabs (tabs &optional hide-prompt?) 78 | "Save spookfox TABS as an `org-mode` subtree. 79 | Tabs subtree is saved in `spokfox-saved-tabs-target`. Capture 80 | buffer is not shown if HIDE-PROMPT? is non-nil." 81 | (let* ((org-capture-templates 82 | `(("t" 83 | "Spookfox tabs" 84 | entry 85 | ,spookfox-saved-tabs-target 86 | "* %?" 87 | :unnarrowed t 88 | :immidiate-finish ,(not hide-prompt?))))) 89 | (org-capture nil "t") 90 | ;; Delete the "* " inserted by capture template; org-capture need us to 91 | ;; start with a valid org-entry, but `spookfox-org-tabs--insert-tab' adds its own entries 92 | ;; later. 93 | (delete-char -3) 94 | (let ((start-pos (point))) 95 | (seq-map (lambda (tab) 96 | (spookfox-org-tabs--insert-tab tab) 97 | (goto-char (point-max))) tabs) 98 | (goto-char start-pos)) 99 | (recenter-top-bottom))) 100 | 101 | (defmacro spookfox-org-tabs--with-tabs-subtree (&rest body) 102 | "Run BODY with current buffer set and narrowed to tabs org subtree. 103 | Content of the `current-buffer' will be the complete tabs 104 | subtree, not just the valid tabs. If you change `current-buffer', 105 | you need to save it." 106 | `(let (res) 107 | (org-capture-set-target-location spookfox-saved-tabs-target) 108 | (save-excursion 109 | (with-current-buffer (org-capture-get :buffer) 110 | (goto-char (org-capture-get :pos)) 111 | (org-narrow-to-subtree) 112 | (setq res (progn ,@body)) 113 | (widen))) 114 | res)) 115 | 116 | (defun spookfox-org-tabs--get-saved-tabs () 117 | "Get browser tabs saved with spookfox. 118 | Returns a list of tabs as plists. Any subtree which don't have a 119 | ID and URL is discarded." 120 | (seq-filter 121 | #'spookfox-org-tabs--tab-p 122 | (spookfox-org-tabs--with-tabs-subtree 123 | (org-map-entries #'spookfox-org-tabs--deserialize-tab)))) 124 | 125 | (defun spookfox-org-tabs--find-tab-with-id (tab-id) 126 | "Find tab with TAB-ID." 127 | (spookfox-org-tabs--with-tabs-subtree 128 | (let ((pos (org-id-find-id-in-file tab-id (buffer-file-name)))) 129 | (when pos 130 | (goto-char (cdr pos)) 131 | (spookfox-org-tabs--deserialize-tab))))) 132 | 133 | (defun spookfox-org-tabs--update-tab (tab-id patch) 134 | "Update a saved tab matching TAB-ID with PATCH. 135 | PATCH is a plist of properties to upsert." 136 | (spookfox-org-tabs--with-tabs-subtree 137 | (let ((pos (org-id-find-id-in-file tab-id (buffer-file-name)))) 138 | (when pos 139 | (goto-char (cdr pos)) 140 | (while patch 141 | (let ((prop (upcase (substring (symbol-name (pop patch)) 1))) 142 | (val (pop patch))) 143 | (pcase prop 144 | ;; Empty titles have been observed in the wild 145 | ("TITLE" (org-edit-headline (if (spookfox--string-blank-p val) "" val))) 146 | ("TAGS" (org-set-tags val)) 147 | (_ (org-entry-put (point) prop val))))) 148 | (save-buffer)))) 149 | (spookfox-org-tabs--find-tab-with-id tab-id)) 150 | 151 | (defun spookfox-org-tabs--remove-tab (tab-id) 152 | "Remove tab with TAB-ID." 153 | (let ((tab (spookfox-org-tabs--find-tab-with-id tab-id))) 154 | (spookfox-org-tabs--with-tabs-subtree 155 | (let ((pos (org-id-find-id-in-file tab-id (buffer-file-name)))) 156 | (when pos 157 | (goto-char (cdr pos)) 158 | (org-narrow-to-subtree) 159 | (delete-region (point-min) (point-max)) 160 | (widen) 161 | (delete-line) 162 | (save-buffer)))) 163 | tab)) 164 | 165 | (defun spookfox-org-tabs--tab-read () 166 | "Ask user to select a tab using Emacs' completion system." 167 | (let* ((tabs (mapcar 168 | (lambda (pl) 169 | (cons 170 | (concat (plist-get pl :title) "\t\t(" (plist-get pl :url) ")" "[" (plist-get pl :id) "]") 171 | pl)) 172 | (spookfox-org-tabs--get-saved-tabs))) 173 | (annotation-function nil) 174 | (tab (completing-read 175 | "Open tab in browser: " 176 | (lambda (string pred action) 177 | (if (eq action 'metadata) 178 | `(metadata 179 | (display-sort-function . identity) 180 | (cycle-sort-function . identity) 181 | (annotation-function . ,annotation-function) 182 | (category . spookfox-tab)) 183 | (complete-with-action action tabs string pred))) 184 | nil nil nil 'spookfox--tab-history))) 185 | (or (cdr (assoc tab tabs)) 186 | tab))) 187 | 188 | (defun spookfox-org-tabs--tab-p (tab) 189 | "Return t if TAB is a spookfox tab, nil otherwise." 190 | (when (and (plist-get tab :id) (plist-get tab :url)) t)) 191 | 192 | (defun spookfox-org-tabs--handle-get-saved-tabs (_payload) 193 | "Handler for GET_SAVED_TABS." 194 | ;; Need to do the JSON encode/decode/encode dance again. I think we need a 195 | ;; different data structure to represent a Tab; plist is proving problematic 196 | ;; when we have to deal with list of Tabs 197 | (json-parse-string (concat "[" (string-join (mapcar #'json-encode (spookfox-org-tabs--get-saved-tabs)) ",") "]"))) 198 | 199 | (defun spookfox-org-tabs--handle-remove-tab (tab) 200 | "Handler for REMOVE_TAB action." 201 | (spookfox-org-tabs--remove-tab (plist-get tab :id))) 202 | 203 | (defun spookfox-org-tabs--handle-update-tab (payload) 204 | "Handler for UPDATE_TAB action. 205 | PAYLOAD is a plist with :id and :patch" 206 | (spookfox-org-tabs--update-tab 207 | (plist-get payload :id) 208 | (mapcar 209 | (lambda (x) 210 | (cond 211 | ((eq x nil) "nil") 212 | ((eq x t) "t") 213 | (t x))) (plist-get payload :patch)))) 214 | 215 | (defun spookfox-org-tabs--handle-toggle-tab-chaining (tab) 216 | "Handler for TOGGLE_TAB_CHAINING REQUEST." 217 | (let* ((chained? (not (plist-get tab :chained))) 218 | (tab-id (plist-get tab :id))) 219 | (if tab-id 220 | (spookfox-org-tabs--handle-update-tab `(:id ,tab-id :patch (:chained ,chained?))) 221 | (setq tab-id (spookfox-org-tabs--with-tabs-subtree 222 | (goto-char (point-max)) 223 | (spookfox-org-tabs--insert-tab (plist-put tab :chained chained?))))) 224 | (spookfox-org-tabs--find-tab-with-id tab-id))) 225 | 226 | (defun spookfox-org-tabs-save-all-tabs () 227 | "Save all currently open browser tabs at `spookfox-saved-tabs-target`. 228 | It will open a capture buffer so user get a chance to preview and 229 | make changes." 230 | (interactive) 231 | (let ((tabs (spookfox-org-tabs--request-all-tabs))) 232 | (spookfox-org-tabs--save-tabs tabs))) 233 | 234 | (defun spookfox-org-tabs-save-active-tab () 235 | "Save active tab in browser." 236 | (interactive) 237 | (let ((tab (spookfox-org-tabs--request-active-tab))) 238 | (spookfox-org-tabs--save-tabs (list tab)))) 239 | 240 | (defun spookfox-org-tabs-open () 241 | "Prompt user to select a tab and open it in spookfox browser." 242 | (interactive) 243 | (let ((tab (spookfox-org-tabs--tab-read)) 244 | (client (cl-first spookfox--connected-clients))) 245 | (when client 246 | (cond 247 | ((spookfox-org-tabs--tab-p tab) 248 | (spookfox-org-tabs--request client "OPEN_TAB" tab)) 249 | ((string-match "https?:\/\/.*[\.].*" tab) 250 | (spookfox-org-tabs--request client "OPEN_TAB" `(:url ,tab))) 251 | (t 252 | (spookfox-org-tabs--request client "SEARCH_FOR" tab)))))) 253 | 254 | (defun spookfox-org-tabs-open-group () 255 | "Prompt for a tab group, and open all tabs belonging to that group." 256 | (interactive) 257 | (let* ((tabs (spookfox-org-tabs--get-saved-tabs)) 258 | (groups (seq-uniq (seq-mapcat (lambda (tab) (plist-get tab :tags)) tabs))) 259 | (selected-group (completing-read "Select tab group: " groups nil t nil spookfox--tab-group-history)) 260 | (group-tabs (seq-filter (lambda (tab) (seq-contains-p (plist-get tab :tags) selected-group #'string=)) tabs)) 261 | (client (cl-first spookfox--connected-clients))) 262 | (when client 263 | (spookfox-org-tabs--request 264 | client 265 | "OPEN_TABS" 266 | (json-parse-string ; json-encode kinda messes up converting list 267 | ; of plists; so we make proper 268 | ; json-string, parses it to hashmap so 269 | ; spookfox-org-tabs--request can parse it again 270 | ; into a proper JSON array 271 | (concat "[" (string-join (mapcar #'json-encode group-tabs) ",") "]")))))) 272 | 273 | ;; This should be added to spookfox-client-connected-hook 274 | (defun spookfox-org-tabs--on-init () 275 | "Initialize spookfox-org-tabs app." 276 | (let ((spookfox--msg-prefix spookfox-org-tabs--msg-prefix)) 277 | (spookfox--register-req-handler "TOGGLE_TAB_CHAINING" #'spookfox-org-tabs--handle-toggle-tab-chaining) 278 | (spookfox--register-req-handler "GET_SAVED_TABS" #'spookfox-org-tabs--handle-get-saved-tabs) 279 | (spookfox--register-req-handler "REMOVE_TAB" #'spookfox-org-tabs--handle-remove-tab) 280 | (spookfox--register-req-handler "UPDATE_TAB" #'spookfox-org-tabs--handle-update-tab))) 281 | 282 | (provide 'spookfox-org-tabs) 283 | ;;; spookfox-org-tabs.el ends here 284 | -------------------------------------------------------------------------------- /lisp/apps/spookfox-tabs.el: -------------------------------------------------------------------------------- 1 | ;;; spookfox-tabs -- Spookfox app which provide access to browser's tabs -*- lexical-binding: t -*- 2 | 3 | ;;; Commentary: 4 | ;; Access browser tabs from Emacs 5 | 6 | ;;; Code: 7 | (require 'org) 8 | (require 'org-id) 9 | (require 'cl-lib) 10 | (require 'spookfox) 11 | 12 | (defvar spookfox-tabs--msg-prefix "T_") 13 | 14 | (defun spookfox-tabs--request (&rest args) 15 | "Make spookfox-request with CLIENT and ARGS but with prefixed NAME." 16 | (let ((spookfox--msg-prefix spookfox-tabs--msg-prefix)) 17 | (apply #'spookfox-request args))) 18 | 19 | (defun spookfox-request-active-tab (&optional window-id) 20 | "Get details of active tab in browser. Optionally provide a numeric WINDOW-ID." 21 | (let ((client (cl-first spookfox--connected-clients))) 22 | (when client 23 | (plist-get 24 | (spookfox--poll-response (spookfox-tabs--request client "GET_ACTIVE_TAB" `(:windowId ,window-id))) 25 | :payload)))) 26 | 27 | (defun spookfox--request-all-tabs () 28 | "Get all tabs currently present in browser." 29 | (let ((client (cl-first spookfox--connected-clients))) 30 | (when client 31 | (plist-get 32 | (spookfox--poll-response 33 | (spookfox-tabs--request client "GET_ALL_TABS")) 34 | :payload)))) 35 | 36 | (defun sft--open-or-search (term) 37 | "Open new tab with TERM." 38 | (dolist (client spookfox--connected-clients) 39 | (when client 40 | (cond 41 | ((string-match "https?:\/\/.*[\.].*" term) 42 | (spookfox-tabs--request client "OPEN_TAB" `(:url ,term))) 43 | (t 44 | (spookfox-tabs--request client "SEARCH_FOR" term)))))) 45 | 46 | ;;;###autoload 47 | (defun spookfox-switch-tab () 48 | "Like `switch-buffer' but for browser tabs. 49 | When you have too many tabs to find what you want; or you want to 50 | jump to browser with your desired tab already in focus. Or to open a new tab. 51 | 52 | Note that this do not bring the browser window to focus. 53 | Depending on the kind of system, user have to do it by themselves. 54 | [[https://github.com/bitspook/spookmax.d/blob/aae6c47e5def0f2bc113f22931ec27c62b5365b6/readme.org?plain=1#L1757-L1764][Example]]" 55 | (interactive) 56 | (let* ((tabs (spookfox--request-all-tabs)) 57 | (tabs (mapcar (lambda (tab) 58 | (cons (concat (plist-get tab :title) "\t" 59 | (propertize (plist-get tab :url) 'face 'font-lock-comment-face)) 60 | tab)) 61 | tabs)) 62 | (read-tab (completing-read "Select tab: " tabs)) 63 | (selected-tab (alist-get read-tab tabs nil nil #'string=))) 64 | (if selected-tab 65 | (let ((tab-id (plist-get selected-tab :id)) 66 | (window-id (plist-get selected-tab :windowId))) 67 | (sfjsi-eval (format "browser.tabs.update(%s, { active: true });browser.windows.update(%s, { focused: true });" tab-id window-id))) 68 | (sft--open-or-search read-tab)))) 69 | 70 | ;; Spookfox iTabs 71 | ;; 72 | ;; iTabs is iBuffer like interface but for Firefox tabs. So you can see list of all the tabs in 73 | ;; Firefox, and operate on them. 74 | ;; (define-derived-mode spookfox-itabs-mode special-mode "Spookfox iTabs" 75 | ;; (buffer-disable-undo)) 76 | 77 | ;; (defun spookfox--itabs-update () 78 | ;; (let ((tabs (spookfox--request-all-tabs))) 79 | ;; (cl-dolist (tab tabs) 80 | ;; (insert (plist-get tab :title)) 81 | ;; (insert "\n")))) 82 | 83 | ;; (defun spookfox-itabs () 84 | ;; (interactive) 85 | ;; (let ((buf (get-buffer-create "*spookfox-itabs*"))) 86 | ;; (switch-to-buffer buf) 87 | ;; (with-current-buffer buf 88 | ;; (save-selected-window 89 | ;; (select-window (get-buffer-window buf 0)) 90 | ;; (spookfox-itabs-mode))))) 91 | ;; End Spookfox iTabs 92 | 93 | (provide 'spookfox-tabs) 94 | ;;; spookfox-tabs.el ends here 95 | ;; Local Variables: 96 | ;; read-symbol-shorthands: (("sft-" . "spookfox-tabs-") ("sfjsi-" . "spookfox-js-injection-")) 97 | ;; End: 98 | -------------------------------------------------------------------------------- /lisp/apps/spookfox-windows.el: -------------------------------------------------------------------------------- 1 | ;;; spookfox-windows -- Spookfox app which provide access to browser's windows -*- lexical-binding: t -*- 2 | 3 | ;;; Commentary: 4 | ;; Access browser windows from Emacs 5 | 6 | ;;; Code: 7 | (require 'spookfox) 8 | 9 | (defvar sfw--msg-prefix "WINDOWS") 10 | 11 | (defun sfw--request-all-windows () 12 | "Get all tabs currently present in browser." 13 | (let ((windows nil)) 14 | (dolist (client spookfox--connected-clients windows) 15 | (when client 16 | (push (plist-get 17 | (spookfox--poll-response 18 | (spookfox-request client (format "%s_GET_ALL" sfw--msg-prefix))) 19 | :payload) 20 | windows))))) 21 | 22 | (provide 'spookfox-windows) 23 | ;;; spookfox-windows.el ends here 24 | ;; Local Variables: 25 | ;; read-symbol-shorthands: (("sfw-" . "spookfox-windows-")) 26 | ;; End: 27 | -------------------------------------------------------------------------------- /lisp/spookfox.el: -------------------------------------------------------------------------------- 1 | ;;; spookfox.el --- Communicate with a browser which have spookfox browser addon installed. -*- lexical-binding: t; -*- 2 | ;; 3 | ;; Copyright © 2022 bitspook 4 | ;; 5 | ;; Author: bitspook 6 | ;; Homepage: https://bitspook.in/projects/spookfox 7 | ;; Keywords: Firefox 8 | ;; Version: 0.7.1 9 | ;; Package-Requires: ((websocket "1.13")) 10 | ;; 11 | ;;; Commentary: 12 | ;; 13 | ;; Spookfox provides means to communicate with your browser. It is (or should 14 | ;; be, after you write some code) capable of doing everything which the browser 15 | ;; allows its extensions to do. 16 | ;; 17 | ;; Please read the readme.org file in this repository for details. 18 | ;; 19 | ;;; Code: 20 | (require 'cl-lib) 21 | (require 'json) 22 | (require 'org) 23 | (require 'org-capture) 24 | (require 'org-id) 25 | (require 'websocket) 26 | 27 | (defvar spookfox-version "0.7.1" 28 | "Spookfox version.") 29 | (defvar spookfox--responses nil 30 | "Alist of responses received. Key is the request-id, val is the response.") 31 | (defvar spookfox--req-handlers-alist nil 32 | "A mapping of spookfox requests and their handlers.") 33 | (defvar spookfox--last-faulty-msg nil 34 | "Last Packet which caused a json encoding error. Useful for debugging.") 35 | (defvar spookfox-server--port 59001) 36 | (defvar spookfox--connected-clients nil) 37 | (defvar spookfox--server-process nil) 38 | (defvar spookfox--msg-prefix "" 39 | "String to prefix names of messages sent by `spookfox-request'.") 40 | (defvar spookfox-debug nil 41 | "When non-nil, spookfox will log its communication in *spookfox* buffer.") 42 | 43 | (defvar spookfox-client-connected-hook nil 44 | "Hook that gets called every time a new client connects to spookfox server.") 45 | 46 | (defvar spookfox-client-disconnected-hook nil 47 | "Hook that gets called every time a client connected to spookfox server disconnects.") 48 | 49 | ;; lib 50 | (defun spookfox--string-blank-p (str) 51 | "Return t if STR is blank. 52 | Considers hard-space (ASCII 160) as space." 53 | (string-blank-p (string-replace (concat '(160)) "" str))) 54 | ;; lib ends here 55 | 56 | (defun spookfox--log (msg &rest args) 57 | "Log a MSG formatted with ARGS to *spookfox* buffer." 58 | (when spookfox-debug 59 | (with-current-buffer (get-buffer-create "*spookfox*") 60 | (goto-char (point-max)) 61 | (insert (apply #'format (concat "\n" msg) args))))) 62 | 63 | (defun spookfox--handle-new-client (ws) 64 | "When a new client connects, save the connected websocket WS." 65 | (cl-pushnew ws spookfox--connected-clients) 66 | (run-hook-with-args 'spookfox-client-connected-hook ws) 67 | (spookfox--log "[CONNECTED] Total clients: %s" (length spookfox--connected-clients))) 68 | 69 | (defun spookfox--handle-disconnect-client (ws) 70 | "When a client connection closes, remove the websocket WS from saved sockets." 71 | (setf spookfox--connected-clients (cl-remove-if (lambda (saved-ws) (eq saved-ws ws)) spookfox--connected-clients)) 72 | (run-hook-with-args 'spookfox-client-disconnected-hook ws) 73 | (spookfox--log "[DISCONNECTED] Total clients: %s" (length spookfox--connected-clients))) 74 | 75 | (defun spookfox--handle-server-error (_ws sym err) 76 | "Handle WS server error ERR in SYM callback." 77 | (warn "[spookfox-server] Error %s occurred in %s" err sym)) 78 | 79 | (defun spookfox--send-msg (msg &optional client-ws) 80 | "Send MSG to all connected clients. 81 | If CLIENT-WS is provided, message is ent only to this client." 82 | (if client-ws (websocket-send-text client-ws msg) 83 | (cl-dolist (ws spookfox--connected-clients) 84 | (websocket-send-text ws msg)))) 85 | 86 | (defun spookfox--handle-msg (_ws frame) 87 | "Choose what to do with FRAME sent by a connected client. 88 | It try to convert the FRAME text to JSON, and pass it to 89 | request-handler if it is a request. Otherwise, it is treated as a 90 | response. Response is saved in `spookfox--responses' alist with 91 | request-id as key." 92 | (let ((msg (websocket-frame-text frame))) 93 | (spookfox--log "[MESSAGE] %s" msg) 94 | 95 | (let ((msg (condition-case nil 96 | (json-parse-string msg :object-type 'plist) 97 | (error (setf spookfox--last-faulty-msg msg) 98 | (spookfox--log "[ERROR] Spookfox failed to parse a message. Check `spookfox--last-faulty-msg`") 99 | nil)))) 100 | (if (plist-get msg :name) 101 | (spookfox--handle-request msg) 102 | ;; FIXME There is a memory leak here. If a response is not polled for 103 | ;; (with `spookfox--poll-response', it is never removed from 104 | ;; `spookfox--responses'. We should implement a fixed-length data 105 | ;; structure; so even if nobody polls for a response, old responses 106 | ;; don't just keep lying around in `spookfox--responses' 107 | (push (cons (plist-get msg :requestId) msg) spookfox--responses))))) 108 | 109 | ;;;###autoload 110 | (defun spookfox-start-server () 111 | "Start websockets server." 112 | (interactive) 113 | (setf spookfox--connected-clients nil) 114 | 115 | (when (and spookfox--server-process 116 | (not (eq (process-status spookfox--server-process) 'closed))) 117 | (websocket-server-close spookfox--server-process)) 118 | 119 | (setf spookfox--server-process 120 | (websocket-server 121 | spookfox-server--port 122 | :host 'local 123 | :on-open #'spookfox--handle-new-client 124 | :on-close #'spookfox--handle-disconnect-client 125 | :on-message #'spookfox--handle-msg 126 | :on-error #'spookfox--handle-server-error))) 127 | 128 | (defun spookfox-stop-server () 129 | "Stop websockets server." 130 | (websocket-server-close spookfox--server-process)) 131 | 132 | (defun spookfox-request (client-ws name &optional payload) 133 | "Make a request to CLIENT-WS with NAME and optionally a PAYLOAD to browser. 134 | Returns the request-id so the caller can retrieve a response 135 | corresponding to this request." 136 | (let ((id (org-id-uuid)) 137 | (name (concat 138 | spookfox--msg-prefix 139 | (if (symbolp name) 140 | (string-replace "-" "_" (upcase (symbol-name name))) 141 | (upcase name))))) 142 | (spookfox--send-msg 143 | (json-encode `((name . ,name) 144 | (id . ,id) 145 | (payload . ,payload))) 146 | client-ws) 147 | id)) 148 | 149 | (defun spookfox-request-all (name &optional payload) 150 | "Make a NAME request to all connected spookfox clients with optional PAYLOAD." 151 | (dolist (ws spookfox--connected-clients) 152 | (spookfox-request ws name payload))) 153 | 154 | (defun spookfox--poll-response (request-id &optional retry-count) 155 | "Synchronously provide response for request with id REQUEST-ID. 156 | Returns a plist obtained by decoding the response. Since 157 | socket-communication with spookfox is async, this function blocks 158 | Emacs for maximum 1 second. If it don't receive a response in 159 | that time, it returns `nil`. RETRY-COUNT is for internal use, for 160 | reaching exit condition in recursive re-checks." 161 | (cl-block spookfox--poll-response 162 | (let ((msg (cdr (assoc request-id spookfox--responses 'equal))) 163 | (retry-count (or retry-count 0))) 164 | (when (> retry-count 10) 165 | (cl-return-from spookfox--poll-response)) 166 | (when (not msg) 167 | (sleep-for 0 50) 168 | (cl-return-from spookfox--poll-response (spookfox--poll-response request-id (1+ retry-count)))) 169 | (setf spookfox--responses (delq (assoc request-id spookfox--responses 'equal) spookfox--responses)) 170 | msg))) 171 | 172 | (defun spookfox--handle-request (request &optional client-ws) 173 | "Handle REQUEST sent from browser. 174 | If CLIENT-WS is provided, response is sent to this client only." 175 | (let* ((request-name (plist-get request :name)) 176 | (handler (cdr (assoc request-name spookfox--req-handlers-alist #'string=))) 177 | (request-id (or (plist-get request :id) "")) 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 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /spookfox-addon/src/icons/chained-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 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 | file_type_emacs -------------------------------------------------------------------------------- /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 | GNU Emacs icon -------------------------------------------------------------------------------- /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 | 37 | 39 | 41 | 44 | 47 | 48 | 49 | 51 | 52 | 54 | 55 | 57 | 58 | 60 | 61 | 63 | 64 | 66 | 67 | 69 | 70 | 72 | 73 | 75 | 76 | 78 | 79 | 81 | 82 | 84 | 85 | 87 | 88 | 90 | 91 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /spookfox-addon/src/icons/refresh-light--active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 40 | 43 | 47 | 51 | 52 | 53 | 55 | 56 | 58 | 59 | 61 | 62 | 64 | 65 | 67 | 68 | 70 | 71 | 73 | 74 | 76 | 77 | 79 | 80 | 82 | 83 | 85 | 86 | 88 | 89 | 91 | 92 | 94 | 95 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /spookfox-addon/src/icons/refresh-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 40 | 43 | 47 | 51 | 52 | 53 | 55 | 56 | 58 | 59 | 61 | 62 | 64 | 65 | 67 | 68 | 70 | 71 | 73 | 74 | 76 | 77 | 79 | 80 | 82 | 83 | 85 | 86 | 88 | 89 | 91 | 92 | 94 | 95 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /spookfox-addon/src/icons/unchained-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /spookfox-addon/src/icons/unchained-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 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 | --------------------------------------------------------------------------------