├── .github └── workflows │ ├── createrelease.yml │ ├── pythonapp.yml │ ├── pythonbuildandrun.yml │ └── pythonpublish.yml ├── .gitignore ├── .pylintrc ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── DEVELOPMENT.md ├── Dockerfile.integration ├── INTEGRATION.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── assets ├── icons │ ├── brotab-icon-128x128.jpg │ ├── brotab-icon-128x128.png │ ├── brotab-small-tile-icon-440x280.jpg │ └── brotab-small-tile-icon-440x280.png └── screenshots │ └── sample_1280x800.png ├── brotab ├── __init__.py ├── __version__.py ├── albert │ ├── Brotab.qss │ ├── __init__.py │ └── brotab_search.py ├── api.py ├── const.py ├── env.py ├── extension │ ├── background.js │ ├── chrome │ │ ├── background.js │ │ ├── brotab-icon-128x128.png │ │ └── manifest.json │ └── firefox │ │ ├── background.js │ │ └── manifest.json ├── files.py ├── inout.py ├── main.py ├── mediator │ ├── Makefile │ ├── __init__.py │ ├── brotab_mediator.py │ ├── chromium_mediator.json │ ├── chromium_mediator_tests.json │ ├── const.py │ ├── firefox_mediator.json │ ├── http_server.py │ ├── log.py │ ├── remote_api.py │ ├── runner.py │ ├── sig.py │ ├── support.py │ └── transport.py ├── operations.py ├── parallel.py ├── platform.py ├── search │ ├── __init__.py │ ├── index.py │ ├── init.sql │ └── query.py ├── tab.py ├── tests │ ├── __init__.py │ ├── mocks.py │ ├── test_brotab.py │ ├── test_inout.py │ ├── test_integration.py │ ├── test_main.py │ ├── test_utils.py │ └── utils.py ├── utils.py └── wait.py ├── fastentrypoints.py ├── jess.Dockerfile ├── requirements ├── base.txt ├── dev.txt └── test.txt ├── setup.py ├── shell ├── brotab-fzf-zsh.sh ├── brotab.sh ├── rofi_activate_tab.sh ├── rofi_close_tabs.sh └── rofi_dispatcher.sh ├── smoke.Dockerfile ├── src ├── main.rs └── net.rs ├── startup.sh ├── test_build_install_run.sh └── xvfb-chromium /.github/workflows/createrelease.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - '*' # Push events to matching v*, i.e. v1.0, v20.15.10 8 | 9 | jobs: 10 | build: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@master 16 | 17 | - name : GITHUB CONTEXT 18 | env: 19 | GITHUB_CONTEXT: ${{ toJson(github) }} 20 | run: echo "$GITHUB_CONTEXT" 21 | 22 | - name: Show commit message 23 | run : echo "${{ github.event.head_commit.message }}" 24 | 25 | - name: Create Release 26 | id: create_release 27 | uses: actions/create-release@v1 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 30 | with: 31 | tag_name: ${{ github.ref }} 32 | release_name: Release ${{ github.ref }} 33 | body: "${{ github.event.head_commit.message }}" 34 | draft: true 35 | prerelease: false 36 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | # You can use PyPy versions in python-version. For example, pypy2 and pypy3 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | python-version: ["3.10", "3.11", "3.12"] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install -r requirements/test.txt 24 | - name: Display Python version 25 | run: python -c "import sys; print(sys.version)" 26 | # - name: Lint with flake8 27 | # run: | 28 | # pip install flake8 29 | # # stop the build if there are Python syntax errors or undefined names 30 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 31 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 32 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 33 | - name: Test with pytest 34 | run: | 35 | pip install pytest 36 | pip install pytest-cov 37 | pytest brotab -s -vvv --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html --ignore brotab/albert --ignore brotab/tests/integration 38 | -------------------------------------------------------------------------------- /.github/workflows/pythonbuildandrun.yml: -------------------------------------------------------------------------------- 1 | name: Build Python Package And Smoke Run 2 | 3 | on: [push] 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: '3.x' 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install setuptools wheel 18 | - name: Build and run smoke test 19 | run: | 20 | python setup.py sdist bdist_wheel 21 | bash test_build_install_run.sh 22 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created, published, edited, prereleased] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .mypy_cache 3 | .vscode 4 | .pytest_cache 5 | ignore 6 | dist 7 | build 8 | brotab.egg-info 9 | target 10 | brotab/node_modules 11 | 12 | node_modules 13 | tags 14 | npm-debug.log 15 | tmp 16 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Specify a score threshold to be exceeded before program exits with error. 9 | fail-under=10.0 10 | 11 | # Add files or directories to the blacklist. They should be base names, not 12 | # paths. 13 | ignore=CVS 14 | 15 | # Add files or directories matching the regex patterns to the blacklist. The 16 | # regex matches against base names, not paths. 17 | ignore-patterns= 18 | 19 | # Python code to execute, usually for sys.path manipulation such as 20 | # pygtk.require(). 21 | #init-hook= 22 | 23 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 24 | # number of processors available to use. 25 | jobs=1 26 | 27 | # Control the amount of potential inferred values when inferring a single 28 | # object. This can help the performance when dealing with large functions or 29 | # complex, nested conditions. 30 | limit-inference-results=100 31 | 32 | # List of plugins (as comma separated values of python module names) to load, 33 | # usually to register additional checkers. 34 | load-plugins= 35 | 36 | # Pickle collected data for later comparisons. 37 | persistent=yes 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | invalid-name, 65 | missing-module-docstring, 66 | missing-class-docstring, 67 | missing-function-docstring, 68 | parameter-unpacking, 69 | unpacking-in-except, 70 | old-raise-syntax, 71 | backtick, 72 | long-suffix, 73 | old-ne-operator, 74 | old-octal-literal, 75 | import-star-module-level, 76 | non-ascii-bytes-literal, 77 | raw-checker-failed, 78 | bad-inline-option, 79 | locally-disabled, 80 | file-ignored, 81 | suppressed-message, 82 | useless-suppression, 83 | deprecated-pragma, 84 | use-symbolic-message-instead, 85 | apply-builtin, 86 | basestring-builtin, 87 | buffer-builtin, 88 | cmp-builtin, 89 | coerce-builtin, 90 | execfile-builtin, 91 | file-builtin, 92 | long-builtin, 93 | raw_input-builtin, 94 | reduce-builtin, 95 | standarderror-builtin, 96 | unicode-builtin, 97 | xrange-builtin, 98 | coerce-method, 99 | delslice-method, 100 | getslice-method, 101 | setslice-method, 102 | no-absolute-import, 103 | old-division, 104 | dict-iter-method, 105 | dict-view-method, 106 | next-method-called, 107 | metaclass-assignment, 108 | indexing-exception, 109 | raising-string, 110 | reload-builtin, 111 | oct-method, 112 | hex-method, 113 | nonzero-method, 114 | cmp-method, 115 | input-builtin, 116 | round-builtin, 117 | intern-builtin, 118 | unichr-builtin, 119 | map-builtin-not-iterating, 120 | zip-builtin-not-iterating, 121 | range-builtin-not-iterating, 122 | filter-builtin-not-iterating, 123 | using-cmp-argument, 124 | eq-without-hash, 125 | div-method, 126 | idiv-method, 127 | rdiv-method, 128 | exception-message-attribute, 129 | invalid-str-codec, 130 | sys-max-int, 131 | bad-python3-import, 132 | deprecated-string-function, 133 | deprecated-str-translate-call, 134 | deprecated-itertools-function, 135 | deprecated-types-field, 136 | next-method-defined, 137 | dict-items-not-iterating, 138 | dict-keys-not-iterating, 139 | dict-values-not-iterating, 140 | deprecated-operator-function, 141 | deprecated-urllib-function, 142 | xreadlines-attribute, 143 | deprecated-sys-function, 144 | exception-escape, 145 | comprehension-escape 146 | 147 | # Enable the message, report, category or checker with the given id(s). You can 148 | # either give multiple identifier separated by comma (,) or put this option 149 | # multiple time (only on the command line, not in the configuration file where 150 | # it should appear only once). See also the "--disable" option for examples. 151 | enable=c-extension-no-member 152 | 153 | 154 | [REPORTS] 155 | 156 | # Python expression which should return a score less than or equal to 10. You 157 | # have access to the variables 'error', 'warning', 'refactor', and 'convention' 158 | # which contain the number of messages in each category, as well as 'statement' 159 | # which is the total number of statements analyzed. This score is used by the 160 | # global evaluation report (RP0004). 161 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 162 | 163 | # Template used to display messages. This is a python new-style format string 164 | # used to format the message information. See doc for all details. 165 | #msg-template= 166 | 167 | # Set the output format. Available formats are text, parseable, colorized, json 168 | # and msvs (visual studio). You can also give a reporter class, e.g. 169 | # mypackage.mymodule.MyReporterClass. 170 | output-format=text 171 | 172 | # Tells whether to display a full report or only the messages. 173 | reports=no 174 | 175 | # Activate the evaluation score. 176 | score=yes 177 | 178 | 179 | [REFACTORING] 180 | 181 | # Maximum number of nested blocks for function / method body 182 | max-nested-blocks=5 183 | 184 | # Complete name of functions that never returns. When checking for 185 | # inconsistent-return-statements if a never returning function is called then 186 | # it will be considered as an explicit return statement and no message will be 187 | # printed. 188 | never-returning-functions=sys.exit 189 | 190 | 191 | [LOGGING] 192 | 193 | # The type of string formatting that logging methods do. `old` means using % 194 | # formatting, `new` is for `{}` formatting. 195 | logging-format-style=old 196 | 197 | # Logging modules to check that the string format arguments are in logging 198 | # function parameter format. 199 | logging-modules=logging 200 | 201 | 202 | [SPELLING] 203 | 204 | # Limits count of emitted suggestions for spelling mistakes. 205 | max-spelling-suggestions=4 206 | 207 | # Spelling dictionary name. Available dictionaries: none. To make it work, 208 | # install the python-enchant package. 209 | spelling-dict= 210 | 211 | # List of comma separated words that should not be checked. 212 | spelling-ignore-words= 213 | 214 | # A path to a file that contains the private dictionary; one word per line. 215 | spelling-private-dict-file= 216 | 217 | # Tells whether to store unknown words to the private dictionary (see the 218 | # --spelling-private-dict-file option) instead of raising a message. 219 | spelling-store-unknown-words=no 220 | 221 | 222 | [BASIC] 223 | 224 | # Naming style matching correct argument names. 225 | argument-naming-style=snake_case 226 | 227 | # Regular expression matching correct argument names. Overrides argument- 228 | # naming-style. 229 | #argument-rgx= 230 | 231 | # Naming style matching correct attribute names. 232 | attr-naming-style=snake_case 233 | 234 | # Regular expression matching correct attribute names. Overrides attr-naming- 235 | # style. 236 | #attr-rgx= 237 | 238 | # Bad variable names which should always be refused, separated by a comma. 239 | bad-names=foo, 240 | bar, 241 | baz, 242 | toto, 243 | tutu, 244 | tata 245 | 246 | # Bad variable names regexes, separated by a comma. If names match any regex, 247 | # they will always be refused 248 | bad-names-rgxs= 249 | 250 | # Naming style matching correct class attribute names. 251 | class-attribute-naming-style=any 252 | 253 | # Regular expression matching correct class attribute names. Overrides class- 254 | # attribute-naming-style. 255 | #class-attribute-rgx= 256 | 257 | # Naming style matching correct class names. 258 | class-naming-style=PascalCase 259 | 260 | # Regular expression matching correct class names. Overrides class-naming- 261 | # style. 262 | #class-rgx= 263 | 264 | # Naming style matching correct constant names. 265 | const-naming-style=UPPER_CASE 266 | 267 | # Regular expression matching correct constant names. Overrides const-naming- 268 | # style. 269 | #const-rgx= 270 | 271 | # Minimum line length for functions/classes that require docstrings, shorter 272 | # ones are exempt. 273 | docstring-min-length=-1 274 | 275 | # Naming style matching correct function names. 276 | function-naming-style=snake_case 277 | 278 | # Regular expression matching correct function names. Overrides function- 279 | # naming-style. 280 | #function-rgx= 281 | 282 | # Good variable names which should always be accepted, separated by a comma. 283 | good-names=i, 284 | j, 285 | k, 286 | ex, 287 | Run, 288 | _ 289 | 290 | # Good variable names regexes, separated by a comma. If names match any regex, 291 | # they will always be accepted 292 | good-names-rgxs= 293 | 294 | # Include a hint for the correct naming format with invalid-name. 295 | include-naming-hint=no 296 | 297 | # Naming style matching correct inline iteration names. 298 | inlinevar-naming-style=any 299 | 300 | # Regular expression matching correct inline iteration names. Overrides 301 | # inlinevar-naming-style. 302 | #inlinevar-rgx= 303 | 304 | # Naming style matching correct method names. 305 | method-naming-style=snake_case 306 | 307 | # Regular expression matching correct method names. Overrides method-naming- 308 | # style. 309 | #method-rgx= 310 | 311 | # Naming style matching correct module names. 312 | module-naming-style=snake_case 313 | 314 | # Regular expression matching correct module names. Overrides module-naming- 315 | # style. 316 | #module-rgx= 317 | 318 | # Colon-delimited sets of names that determine each other's naming style when 319 | # the name regexes allow several styles. 320 | name-group= 321 | 322 | # Regular expression which should only match function or class names that do 323 | # not require a docstring. 324 | no-docstring-rgx=^_ 325 | 326 | # List of decorators that produce properties, such as abc.abstractproperty. Add 327 | # to this list to register other decorators that produce valid properties. 328 | # These decorators are taken in consideration only for invalid-name. 329 | property-classes=abc.abstractproperty 330 | 331 | # Naming style matching correct variable names. 332 | variable-naming-style=snake_case 333 | 334 | # Regular expression matching correct variable names. Overrides variable- 335 | # naming-style. 336 | #variable-rgx= 337 | 338 | 339 | [VARIABLES] 340 | 341 | # List of additional names supposed to be defined in builtins. Remember that 342 | # you should avoid defining new builtins when possible. 343 | additional-builtins= 344 | 345 | # Tells whether unused global variables should be treated as a violation. 346 | allow-global-unused-variables=yes 347 | 348 | # List of strings which can identify a callback function by name. A callback 349 | # name must start or end with one of those strings. 350 | callbacks=cb_, 351 | _cb 352 | 353 | # A regular expression matching the name of dummy variables (i.e. expected to 354 | # not be used). 355 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 356 | 357 | # Argument names that match this expression will be ignored. Default to name 358 | # with leading underscore. 359 | ignored-argument-names=_.*|^ignored_|^unused_ 360 | 361 | # Tells whether we should check for unused import in __init__ files. 362 | init-import=no 363 | 364 | # List of qualified module names which can have objects that can redefine 365 | # builtins. 366 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 367 | 368 | 369 | [FORMAT] 370 | 371 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 372 | expected-line-ending-format= 373 | 374 | # Regexp for a line that is allowed to be longer than the limit. 375 | ignore-long-lines=^\s*(# )??$ 376 | 377 | # Number of spaces of indent required inside a hanging or continued line. 378 | indent-after-paren=4 379 | 380 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 381 | # tab). 382 | indent-string=' ' 383 | 384 | # Maximum number of characters on a single line. 385 | max-line-length=100 386 | 387 | # Maximum number of lines in a module. 388 | max-module-lines=1000 389 | 390 | # Allow the body of a class to be on the same line as the declaration if body 391 | # contains single statement. 392 | single-line-class-stmt=no 393 | 394 | # Allow the body of an if to be on the same line as the test if there is no 395 | # else. 396 | single-line-if-stmt=no 397 | 398 | 399 | [MISCELLANEOUS] 400 | 401 | # List of note tags to take in consideration, separated by a comma. 402 | notes=FIXME, 403 | XXX, 404 | TODO 405 | 406 | # Regular expression of note tags to take in consideration. 407 | #notes-rgx= 408 | 409 | 410 | [SIMILARITIES] 411 | 412 | # Ignore comments when computing similarities. 413 | ignore-comments=yes 414 | 415 | # Ignore docstrings when computing similarities. 416 | ignore-docstrings=yes 417 | 418 | # Ignore imports when computing similarities. 419 | ignore-imports=no 420 | 421 | # Minimum lines number of a similarity. 422 | min-similarity-lines=4 423 | 424 | 425 | [STRING] 426 | 427 | # This flag controls whether inconsistent-quotes generates a warning when the 428 | # character used as a quote delimiter is used inconsistently within a module. 429 | check-quote-consistency=no 430 | 431 | # This flag controls whether the implicit-str-concat should generate a warning 432 | # on implicit string concatenation in sequences defined over several lines. 433 | check-str-concat-over-line-jumps=no 434 | 435 | 436 | [TYPECHECK] 437 | 438 | # List of decorators that produce context managers, such as 439 | # contextlib.contextmanager. Add to this list to register other decorators that 440 | # produce valid context managers. 441 | contextmanager-decorators=contextlib.contextmanager 442 | 443 | # List of members which are set dynamically and missed by pylint inference 444 | # system, and so shouldn't trigger E1101 when accessed. Python regular 445 | # expressions are accepted. 446 | generated-members= 447 | 448 | # Tells whether missing members accessed in mixin class should be ignored. A 449 | # mixin class is detected if its name ends with "mixin" (case insensitive). 450 | ignore-mixin-members=yes 451 | 452 | # Tells whether to warn about missing members when the owner of the attribute 453 | # is inferred to be None. 454 | ignore-none=yes 455 | 456 | # This flag controls whether pylint should warn about no-member and similar 457 | # checks whenever an opaque object is returned when inferring. The inference 458 | # can return multiple potential results while evaluating a Python object, but 459 | # some branches might not be evaluated, which results in partial inference. In 460 | # that case, it might be useful to still emit no-member and other checks for 461 | # the rest of the inferred objects. 462 | ignore-on-opaque-inference=yes 463 | 464 | # List of class names for which member attributes should not be checked (useful 465 | # for classes with dynamically set attributes). This supports the use of 466 | # qualified names. 467 | ignored-classes=optparse.Values,thread._local,_thread._local 468 | 469 | # List of module names for which member attributes should not be checked 470 | # (useful for modules/projects where namespaces are manipulated during runtime 471 | # and thus existing member attributes cannot be deduced by static analysis). It 472 | # supports qualified module names, as well as Unix pattern matching. 473 | ignored-modules= 474 | 475 | # Show a hint with possible names when a member name was not found. The aspect 476 | # of finding the hint is based on edit distance. 477 | missing-member-hint=yes 478 | 479 | # The minimum edit distance a name should have in order to be considered a 480 | # similar match for a missing member name. 481 | missing-member-hint-distance=1 482 | 483 | # The total number of similar names that should be taken in consideration when 484 | # showing a hint for a missing member. 485 | missing-member-max-choices=1 486 | 487 | # List of decorators that change the signature of a decorated function. 488 | signature-mutators= 489 | 490 | 491 | [IMPORTS] 492 | 493 | # List of modules that can be imported at any level, not just the top level 494 | # one. 495 | allow-any-import-level= 496 | 497 | # Allow wildcard imports from modules that define __all__. 498 | allow-wildcard-with-all=no 499 | 500 | # Analyse import fallback blocks. This can be used to support both Python 2 and 501 | # 3 compatible code, which means that the block might have code that exists 502 | # only in one or another interpreter, leading to false positives when analysed. 503 | analyse-fallback-blocks=no 504 | 505 | # Deprecated modules which should not be used, separated by a comma. 506 | deprecated-modules=optparse,tkinter.tix 507 | 508 | # Create a graph of external dependencies in the given file (report RP0402 must 509 | # not be disabled). 510 | ext-import-graph= 511 | 512 | # Create a graph of every (i.e. internal and external) dependencies in the 513 | # given file (report RP0402 must not be disabled). 514 | import-graph= 515 | 516 | # Create a graph of internal dependencies in the given file (report RP0402 must 517 | # not be disabled). 518 | int-import-graph= 519 | 520 | # Force import order to recognize a module as part of the standard 521 | # compatibility libraries. 522 | known-standard-library= 523 | 524 | # Force import order to recognize a module as part of a third party library. 525 | known-third-party=enchant 526 | 527 | # Couples of modules and preferred modules, separated by a comma. 528 | preferred-modules= 529 | 530 | 531 | [DESIGN] 532 | 533 | # Maximum number of arguments for function / method. 534 | max-args=5 535 | 536 | # Maximum number of attributes for a class (see R0902). 537 | max-attributes=7 538 | 539 | # Maximum number of boolean expressions in an if statement (see R0916). 540 | max-bool-expr=5 541 | 542 | # Maximum number of branch for function / method body. 543 | max-branches=12 544 | 545 | # Maximum number of locals for function / method body. 546 | max-locals=15 547 | 548 | # Maximum number of parents for a class (see R0901). 549 | max-parents=7 550 | 551 | # Maximum number of public methods for a class (see R0904). 552 | max-public-methods=20 553 | 554 | # Maximum number of return / yield for function / method body. 555 | max-returns=6 556 | 557 | # Maximum number of statements in function / method body. 558 | max-statements=50 559 | 560 | # Minimum number of public methods for a class (see R0903). 561 | min-public-methods=2 562 | 563 | 564 | [CLASSES] 565 | 566 | # Warn about protected attribute access inside special methods 567 | check-protected-access-in-special-methods=no 568 | 569 | # List of method names used to declare (i.e. assign) instance attributes. 570 | defining-attr-methods=__init__, 571 | __new__, 572 | setUp, 573 | __post_init__ 574 | 575 | # List of member names, which should be excluded from the protected access 576 | # warning. 577 | exclude-protected=_asdict, 578 | _fields, 579 | _replace, 580 | _source, 581 | _make 582 | 583 | # List of valid names for the first argument in a class method. 584 | valid-classmethod-first-arg=cls 585 | 586 | # List of valid names for the first argument in a metaclass class method. 587 | valid-metaclass-classmethod-first-arg=cls 588 | 589 | 590 | [EXCEPTIONS] 591 | 592 | # Exceptions that will emit a warning when being caught. Defaults to 593 | # "BaseException, Exception". 594 | overgeneral-exceptions=BaseException, 595 | Exception 596 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.5.0 (2025-01-22) 2 | 3 | * Added "bt screenshot" command 4 | * Fixed some dependencies in requirements/base.txt 5 | 6 | 1.4.2 (2022-05-29) 7 | 8 | * Support config file in `$XDG_CONFIG_HOME/brotab/brotab.env`: 9 | ```env 10 | HTTP_IFACE=0.0.0.0 11 | MIN_HTTP_PORT=4625 12 | MAX_HTTP_PORT=4635 13 | ``` 14 | This is useful if you want to change interface mediator is binding to. 15 | 16 | 1.4.1 (2022-05-29) 17 | 18 | * Better syntax for navigate and update: 19 | > bt navigate b.1.862 "https://google.com" 20 | > bt update -tabId b.1.862 -url="http://www.google.com" 21 | 22 | 1.4.0 (2022-05-29) 23 | 24 | * Added "bt navigate" and "bt update" commands 25 | 26 | * Fix "bt open" and "bt new": now they print tab IDs of the created tabs, one 27 | per line 28 | 29 | 1.3.0 (2020-06-02) 30 | 31 | * Added "bt html" command #31, #34 32 | 33 | 1.2.2 (2020-05-05) 34 | 35 | * Added Brave Browser support #29 36 | 37 | 1.2.1 (2020-02-19) 38 | 39 | * fix setup.py and add smoke integration tests to build package and run the app 40 | 41 | 1.2.0 (2020-02-16) 42 | 43 | * add "--target" argument to disable automatic mediator discovery and be 44 | able to specify mediator's host:port address. Multiple entries are 45 | separated with a comma, e.g. --target "localhost:2000,127.0.0.1:3000" 46 | * add "--focused" argument to "activate" tab command. This will bring browser 47 | into focus 48 | * automatically register native app manifest in the Windows Registry when doing 49 | "bt install" (Windows only) 50 | * detect user's temporary directory (Windows-related fix) 51 | * use "notepad" editor for "bt move" command on Windows 52 | * add optional tab_ids filter to "bt text [tab_id]" command 53 | 54 | 1.1.0 (2019-12-15) 55 | 56 | * add "query" command that allows for more fine-tuned querying of tabs 57 | 58 | 1.0.6 (2019-12-08) 59 | 60 | * print all active tabs from all windows (#8) 61 | * autorotate mediator logs to make sure it doesn't grow too large 62 | * make sure mediator (flask) works in single-threaded mode 63 | * bt words, bt text, bt index now support customization of regexpes 64 | that are used to match words, split text and replacement/join strings 65 | 66 | 0.0.5 (2019-10-27) 67 | 68 | Console client requests only those mediator ports that are actually available. 69 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "brotab" 3 | version = "0.1.0" 4 | authors = ["Yuri Bochkarev "] 5 | 6 | [dependencies] 7 | reqwest = "0.8.7" 8 | # crossbeam = "0.4.1" 9 | clap = "2.32.0" 10 | tempfile = "3.0.3" 11 | 12 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Build, test and manual installation 4 | 5 | 1. Install docker: https://docs.docker.com/get-docker/ 6 | 1. Install Python3 7 | 1. Clone repository and cd into it 8 | 1. Run:```rm -rf ./dist && python3 setup.py sdist bdist_wheel && docker build -t brotab-buildinstallrun . && docker run -it brotab-buildinstallrun``` 9 | 1. The build is in the dist folder and can be installed with ```pip install $(find ./dist -name *.whl -type f) ``` 10 | 11 | ## Installation in development mode 12 | 13 | cd brotab 14 | pip install --user -e . 15 | bt install --tests 16 | 17 | In firefox go to: about:debugging#/runtime/this-firefox -> Load temporary addon 18 | 19 | In Chrome/Chromium go to: chrome://extensions/ -> Developer mode -> Load 20 | unpacked 21 | 22 | You should see the following output: 23 | 24 | ```txt 25 | $ bt clients 26 | a. localhost:4625 23744 firefox 27 | b. localhost:4626 28895 chrome/chromium 28 | ``` 29 | 30 | ## Running tests 31 | 32 | ```bash 33 | $ pytest brotab/tests 34 | ``` 35 | 36 | ## Rest 37 | 38 | This document serves the purpose of being a reminder to dev 39 | 40 | Chrome extension IDs: 41 | 42 | Debug: 43 | "chrome-extension://knldjmfmopnpolahpmmgbagdohdnhkik/" 44 | 45 | // Extension ID: knldjmfmopnpolahpmmgbagdohdnhkik 46 | "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcBHwzDvyBQ6bDppkIs9MP4ksKqCMyXQ/A52JivHZKh4YO/9vJsT3oaYhSpDCE9RPocOEQvwsHsFReW2nUEc6OLLyoCFFxIb7KkLGs 47 | mfakkut/fFdNJYh0xOTbSN8YvLWcqph09XAY2Y/f0AL7vfO1cuCqtkMt8hFrBGWxDdf9CQIDAQAB", 48 | 49 | Prod: 50 | "chrome-extension://mhpeahbikehnfkfnmopaigggliclhmnc/" 51 | 52 | ## TODO, things to implement 53 | 54 | [_] provide zsh completion: ~/rc.arch/bz/.config/zsh/completion/_bt 55 | [_] add config and allow setting debug level. prod in release, debug in dev 56 | [_] automake deployment of extensions and pypi packaging 57 | [_] automate switching to environments (dev, prod) 58 | [_] add regexp argument to bt words command 59 | this will allow configuration on vim plugin side 60 | 61 | - rofi: close multiple tabs (multiselect), should be opened with current tab 62 | selected for convenience 63 | 64 | ## Notes 65 | 66 | Use this command to print current tabs in firefox: 67 | 68 | echo -e 'repl.load("file:///home/ybochkarev/rc.arch/bz/.config/mozrepl/mozrepl.js"); hello(300);' | nc -q0 localhost 4242 | sed '/repl[0-9]*> .\+/!d' | sed 's/repl[0-9]*> //' | rev | cut -c2- | rev | cut -c2- J -C G '"title"' F --no-sort 69 | 70 | fr (FiRefox) usage (or br - BRowser): 71 | 72 | fr open open a tab by title 73 | fr close mark and close multiple tabs 74 | 75 | fc close 76 | fo open 77 | fs search 78 | 79 | Getting data from PDF page: 80 | 81 | Firefox: 82 | var d = await window.PDFViewerApplication.pdfDocument.getData() 83 | Uint8Array(100194) [ 37, 80, 68, 70, 45, 49, 46, 51, 10, 37, … ] 84 | 85 | ## CompleBox 86 | 87 | Desired modes: 88 | 89 | - insert 90 | - rt ticket number 91 | - rt ticket: title 92 | - ticket url 93 | - ? insert all: ticket: title (url) 94 | - open rt ticket in a browser 95 | 96 | - open sheet ticket in a browser 97 | 98 | - activate browser tab 99 | - close browser tab 100 | 101 | ## Multiple extensions/browsers/native apps 102 | 103 | [+] differentiate browser instances, how? use prefixes ('a.', 'b.', ...) 104 | [+] support gathering data from multiple mediators 105 | [+] native mediator should try binding to a free port [4625..4635] 106 | [+] brotab.py should try a range of ports 107 | [+] build a unified interface for different browsers in background.js 108 | [+] try putting window id into list response 109 | 110 | ## Roadmap 111 | 112 | Install/devops 113 | [_] put helpers (colors) into brotab.sh 114 | [_] create helpers bt-list, bt-move, etc 115 | [_] add integration with rofi 116 | [_] zsh completion for commands 117 | [+] add file with fzf integration: brotab-fzf.zsh 118 | [+] add setup.py, make sure brotab, bt binary is available (python code) 119 | 120 | Testing: 121 | [_] how to setup integration testing? w chromium, firefox 122 | use docker 123 | 124 | ## Product features 125 | 126 | [_] full-text search using extenal configured service (e.g. solr) 127 | [_] all current operations should be supported on multiple browsers at a time 128 | [_] move should work with multiple browsers and multiple windows 129 | [_] ability to move within window of the same browser 130 | [_] ability to move across windows of the same browser 131 | [_] ability to move across windows of different browsers 132 | 133 | ## Bugs 134 | 135 | [_] bt move hangs after interacting with chromium 136 | [_] bt close, chromium timeout 137 | [_] bt active is broken with chromium extension 138 | [_] rofi, activate, close tabs: should select currently active tab 139 | [_] rofi, close tabs: should be multi-selectable 140 | 141 | ## Release procedure 142 | 143 | ```bash 144 | # Bump bersion in brotab/__version__.py 145 | 146 | $ nvim CHANGELOG.md 147 | $ git ci -m 'Bump version from 1.2.0 to 1.2.1\n' 148 | $ git tag 1.2.1 149 | $ git push origin master && git push --tags 150 | 151 | # Go to releases and update the CHANGELOG: https://github.com/balta2ar/brotab/releases 152 | # This will trigger PyPI package build and upload 153 | ``` 154 | 155 | Go to Github tags and manually create a release from the tag: 156 | https://github.com/balta2ar/brotab/tags 157 | 158 | Load env file as follows: 159 | set -o allexport; source .env; set +o allexport 160 | 161 | ## Old steps of release procedure 162 | 163 | $ python setup.py sdist bdist_wheel 164 | $ uv build # this one is faster 165 | $ twine upload dist/* 166 | 167 | ## Commands 168 | 169 | chromium-browser --pack-extension=chrome 170 | 171 | To make sure that extension works under selenium, copy brotab_mediator.json to: 172 | /etc/opt/chrome/native-messaging-hosts 173 | 174 | ## Testing extension 175 | 176 | To perform integration tests for the extension, chromium and firefox have 177 | different approaches to load it upon the start. 178 | 179 | ### Chromium 180 | 181 | chromium: google-chrome-stable --disable-gpu --load-extension=./firefox_extension 182 | 183 | Chromium is a bit more demading. Several conditions are required before you can 184 | run Chromium in Xvfb in integration tests: 185 | 186 | 1. Use extension from brotab/extension/chrome-tests. It contains the correct 187 | fake Key and extension ID (knldjmfmopnpolahpmmgbagdohdnhkik). The same 188 | extension ID is installed when you run `bt install` command in Docker. 189 | This very extension ID is also present in 190 | brotab/mediator/chromium_mediator_tests.json, which is used in `bt install`. 191 | 192 | firefox: use web-ext run 193 | https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Getting_started_with_web-ext 194 | 195 | -------------------------------------------------------------------------------- /Dockerfile.integration: -------------------------------------------------------------------------------- 1 | FROM ubuntu:21.10 2 | 3 | ENV DEBIAN_FRONTEND noninteractive 4 | RUN apt-get update && apt-get install -y --no-install-recommends \ 5 | ca-certificates fonts-liberation \ 6 | git fontconfig fontconfig-config fonts-dejavu-core gconf-service gconf2-common \ 7 | libasn1-8-heimdal libasound2 libasound2-data libatk1.0-0 libatk1.0-data libavahi-client3 libavahi-common-data \ 8 | libavahi-common3 libcairo2 libcups2 libdatrie1 libdbus-1-3 libdbus-glib-1-2 libexpat1 libfontconfig1 \ 9 | libfreetype6 libgconf-2-4 libgdk-pixbuf2.0-0 libgdk-pixbuf2.0-common libgmp10 \ 10 | libgnutls30 libgraphite2-3 libgssapi-krb5-2 libgssapi3-heimdal libgtk2.0-0 \ 11 | libgtk2.0-common libharfbuzz0b libhcrypto4-heimdal libheimbase1-heimdal libheimntlm0-heimdal \ 12 | libhx509-5-heimdal libjbig0 libk5crypto3 libkeyutils1 \ 13 | libkrb5-26-heimdal libkrb5-3 libkrb5support0 libnspr4 libnss3 \ 14 | libp11-kit0 libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 libpixman-1-0 libpng16-16 libroken18-heimdal \ 15 | libsasl2-2 libsasl2-modules-db libsqlite3-0 libtasn1-6 libthai-data libthai0 libtiff5 libwind0-heimdal libx11-6 \ 16 | libx11-data libxau6 libxcb-render0 libxcb-shm0 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxdmcp6 \ 17 | libxext6 libxfixes3 libxi6 libxinerama1 libxml2 libxrandr2 libxrender1 libxss1 libxtst6 shared-mime-info ucf \ 18 | x11-common xdg-utils libpulse0 pulseaudio-utils wget libatk-bridge2.0-0 libatspi2.0-0 libgtk-3-0 \ 19 | mesa-va-drivers mesa-vdpau-drivers mesa-utils libosmesa6 libegl1-mesa libwayland-egl1-mesa libgl1-mesa-dri 20 | 21 | #RUN apt-get update 22 | #RUN apt-get install --yes python-setuptools python-dev curl \ 23 | # chromium-browser xvfb python3-pip sudo 24 | # RUN apt-get install --yes software-properties-common \ 25 | # python-setuptools python-dev build-essential apt-transport-https curl \ 26 | # chromium-browser firefox curl xvfb python3-pip sudo mc net-tools htop \ 27 | # less lsof 28 | #RUN apt-get update 29 | 30 | #RUN easy_install pip 31 | #RUN pip3 install flask httpie 32 | # RUN pip3 install -r /brotab/requirements.txt 33 | #RUN cd /brotab && pip3 install -e . 34 | 35 | #ADD xvfb-chromium /usr/bin/xvfb-chromium 36 | 37 | ADD deb/google-chrome-stable_current_amd64.deb /tmp/chrome.deb 38 | RUN echo "Installing chrome" && dpkg -i /tmp/chrome.deb 39 | 40 | #RUN adduser --disabled-password --gecos '' user 41 | WORKDIR /brotab 42 | 43 | # Build: 44 | # docker build -t brotab-integration -f Dockerfile.integration . 45 | # Run: 46 | # xhost +local:docker 47 | # 48 | # docker run --rm --privileged -v "$(pwd):/brotab" -p 10222:9222 -e DISPLAY=unix$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix -v /dev:/dev -v /run:/run -v /etc/machine-id:/etc/machine-id --ipc=host --device /dev/dri --group-add video brotab-integration 49 | # docker run -it --rm -v "$(pwd):/brotab" -p 10222:9222 -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix brotab 50 | 51 | RUN /bin/bash 52 | #RUN /usr/bin/google-chrome --no-sandbox --no-first-run --disable-gpu --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 53 | 54 | 55 | 56 | # xvfb-run chromium-browser --no-sandbox --no-first-run --disable-gpu --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 57 | # curl localhost:9222/json/list 58 | # python3 -m http.server 59 | 60 | # cd /brotab && pip3 install -e . && cd /brotab/brotab/firefox_mediator && make install 61 | # chromium-browser --headless --no-sandbox --no-first-run --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 62 | # cat ~/.config/chromium/NativeMessagingHosts/brotab_mediator.json 63 | # py.test brotab/tests/test_integration.py -s 64 | 65 | # How to run integration tests: 66 | # pip3 install -e . 67 | # bt install --tests 68 | # py.test brotab/tests/test_integration.py -s 69 | # py.test brotab/tests/test_integration.py -k test_active_tabs -s 70 | # pkill python3; pkill xvfb-run; pkill node; pkill Xvfb; pkill firefox 71 | 72 | # docker run -it --rm -v "$(pwd):/brotab" -p 127.0.0.1:10222:9222 brotab 73 | # docker run -it --rm -v "$(pwd):/brotab" -p 10222:9222 brotab 74 | 75 | # run this on host to be able to view chromimum GUI: 76 | # xhost local:docker 77 | # xhost local:root 78 | 79 | # docker run -ti --rm -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix firefox 80 | # docker run -it --rm -v "$(pwd):/brotab" -p 10222:9222 -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix brotab 81 | # xvfb-run chromium-browser --no-sandbox --no-first-run --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 82 | # xvfb-run chromium-browser --no-sandbox --no-first-run --disable-gpu --load-extension=/brotab/brotab/extension/chrome 83 | 84 | # cd /brotab/brotab/firefox_extension 85 | # web-ext run 86 | 87 | # test list tabs: 88 | # curl 'http://localhost:4625/list_tabs' 89 | 90 | # EXPOSE 10222:9222 91 | #EXPOSE 10222 92 | # EXPOSE 8000 93 | 94 | #RUN /bin/bash 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | # FROM ubuntu:16.04 108 | # 109 | # ENV DEBIAN_FRONTEND noninteractive 110 | # 111 | # RUN apt-get update 112 | # RUN apt-get install --yes software-properties-common python-software-properties \ 113 | # python-setuptools python-dev build-essential apt-transport-https curl \ 114 | # chromium-browser firefox curl xvfb python3-pip sudo mc net-tools htop \ 115 | # less lsof 116 | # #RUN add-apt-repository ppa:chromium-daily/stable 117 | # RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - 118 | # RUN apt-get update 119 | # #RUN apt-get install --yes chromium-browser firefox curl xvfb python3-pip sudo mc net-tools htop less 120 | # # RUN apt-get install --yes xvfb 121 | # # This will install latest Firefox 122 | # #RUN apt-get upgrade 123 | # 124 | # #RUN curl -sSL https://deb.nodesource.com/gpgkey/nodesource.gpg.key | sudo apt-key add - 125 | # #RUN echo "deb https://deb.nodesource.com/node_8.x xenial main" | sudo tee /etc/apt/sources.list.d/nodesource.list 126 | # #RUN echo "deb-src https://deb.nodesource.com/node_8.x xenial main" | sudo tee -a /etc/apt/sources.list.d/nodesource.list 127 | # 128 | # RUN apt-get install --yes nodejs 129 | # 130 | # # RUN mkdir /root/.npm-global 131 | # # ENV PATH=/root/.npm-global/bin:$PATH 132 | # # ENV NPM_CONFIG_PREFIX=/root/.npm-global 133 | # 134 | # RUN npm install --global web-ext --unsafe 135 | # 136 | # RUN easy_install pip 137 | # RUN pip3 install flask httpie 138 | # # RUN pip3 install -r /brotab/requirements.txt 139 | # #RUN cd /brotab && pip3 install -e . 140 | # 141 | # ADD xvfb-chromium /usr/bin/xvfb-chromium 142 | # # RUN ln -s /usr/bin/xvfb-chromium /usr/bin/google-chrome 143 | # # RUN ln -s /usr/bin/xvfb-chromium /usr/bin/chromium-browser 144 | # 145 | # # RUN useradd --create-home --shell /bin/bash user 146 | # RUN adduser --disabled-password --gecos '' user 147 | # # USER user 148 | # #WORKDIR /home/user 149 | # WORKDIR /brotab 150 | # 151 | # # xvfb-run chromium-browser --no-sandbox --no-first-run --disable-gpu --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 152 | # # curl localhost:9222/json/list 153 | # # python3 -m http.server 154 | # 155 | # # cd /brotab && pip3 install -e . && cd /brotab/brotab/firefox_mediator && make install 156 | # # chromium-browser --headless --no-sandbox --no-first-run --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 157 | # # cat ~/.config/chromium/NativeMessagingHosts/brotab_mediator.json 158 | # # py.test brotab/tests/test_integration.py -s 159 | # 160 | # # How to run integration tests: 161 | # # pip3 install -e . 162 | # # bt install --tests 163 | # # py.test brotab/tests/test_integration.py -s 164 | # # py.test brotab/tests/test_integration.py -k test_active_tabs -s 165 | # # pkill python3; pkill xvfb-run; pkill node; pkill Xvfb; pkill firefox 166 | # 167 | # # docker run -it --rm -v "$(pwd):/brotab" -p 127.0.0.1:10222:9222 brotab 168 | # # docker run -it --rm -v "$(pwd):/brotab" -p 10222:9222 brotab 169 | # 170 | # # run this on host to be able to view chromimum GUI: 171 | # # xhost local:docker 172 | # # xhost local:root 173 | # 174 | # # docker run -ti --rm -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix firefox 175 | # # docker run -it --rm -v "$(pwd):/brotab" -p 10222:9222 -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix brotab 176 | # # xvfb-run chromium-browser --no-sandbox --no-first-run --remote-debugging-port=10222 --remote-debugging-address=0.0.0.0 --load-extension=/brotab/brotab/firefox_extension 177 | # # xvfb-run chromium-browser --no-sandbox --no-first-run --disable-gpu --load-extension=/brotab/brotab/extension/chrome 178 | # 179 | # # cd /brotab/brotab/firefox_extension 180 | # # web-ext run 181 | # 182 | # # test list tabs: 183 | # # curl 'http://localhost:4625/list_tabs' 184 | # 185 | # # EXPOSE 10222:9222 186 | # #EXPOSE 10222 187 | # # EXPOSE 8000 188 | # 189 | # RUN /bin/bash 190 | # 191 | -------------------------------------------------------------------------------- /INTEGRATION.md: -------------------------------------------------------------------------------- 1 | 2 | ```bash 3 | # Testing command in docker: 4 | # pip install -e . 5 | # ~/.local/bin/bt install 6 | # chromium-browser --load-extension=/brotab/brotab/extension/chrome --headless --use-gl=swiftshader --disable-software-rasterizer --disable-dev-shm-usage --no-sandbox --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 https://www.chromestatus.com/ 7 | # xvfb-chromium --no-sandbox --load-extension=/brotab/brotab/extension/chrome --use-gl=swiftshader --disable-software-rasterizer --disable-dev-shm-usage --no-sandbox --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 https://www.chromestatus.com/ 8 | 9 | # chromium --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=19222 https://ipinfo.io/json 10 | # chromium --no-sandbox --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=19222 https://ipinfo.io/json 11 | # docker run -v "$(pwd):/brotab" -p 19222:9222 -it --rm --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm --security-opt seccomp=$(pwd)/chrome.json brotab-integration chromium --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 https://ipinfo.io/json 12 | # docker run -v "$(pwd):/brotab" -p 19222:9222 -it --rm --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm brotab-integration chromium --no-sandbox --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 https://ipinfo.io/json 13 | 14 | # jess 15 | # docker run -it --rm --net host --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm --security-opt seccomp=$(pwd)/chrome.json --name chromium brotab-integration 16 | # working option: 17 | # docker run -it --rm --net host --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm --security-opt seccomp=$(pwd)/chrome.json brotab-integration --remote-debugging-address=0.0.0.0 --remote-debugging-port=19222 --disable-gpu https://ipinfo.io/json 18 | 19 | # Run docker: 20 | # docker run -v "$(pwd):/brotab" -p 19222:9222 -p 14625:4625 -it --rm --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm brotab-integration 21 | 22 | # Inside: 23 | # pip install -e . 24 | # bt install --tests 25 | # chromium --no-sandbox --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=19222 --load-extension=/brotab/brotab/extension/chrome file:/// 26 | 27 | # chromium --no-sandbox --disable-gpu --remote-debugging-address=0.0.0.0 --remote-debugging-port=19222 --load-extension=/brotab/brotab/extension/chrome https://ipinfo.io/json 28 | 29 | # socat TCP4-LISTEN:8000,fork,reuseaddr,bind=172.17.0.2 TCP4:127.0.0.1:8000 30 | 31 | # python3 -m http.server --bind :: 32 | # python3 -m http.server --bind 172.17.0.2 33 | 34 | # -v $HOME/Downloads:/home/chromium/Downloads \ 35 | # -v $HOME/.config/chromium/:/data \ # if you want to save state 36 | # --security-opt seccomp=$HOME/chrome.json \ 37 | # --device /dev/snd \ # so we have sound 38 | 39 | # Remote: 40 | # http://0.0.0.0:19222/devtools/inspector.html?ws=localhost:19222/devtools/page/AEDF6B9CB4D1DD63E26826BBA3EC50B5 41 | 42 | 43 | ``` 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Yuri Bochkarev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements/* -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | unit-test: 2 | pytest -v 3 | 4 | smoke-build: 5 | rm -rf ./dist && \ 6 | python setup.py sdist bdist_wheel && \ 7 | docker build -t brotab-smoke -f smoke.Dockerfile . 8 | 9 | smoke-test: 10 | docker run -it brotab-smoke 11 | 12 | integration-build: 13 | docker build -t brotab-integration -f jess.Dockerfile . 14 | 15 | integration-run-container: 16 | docker run -v "$(pwd):/brotab" -p 19222:9222 -p 14625:4625 -it --rm --cpuset-cpus 0 --memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix -e DISPLAY=unix$DISPLAY -v /dev/shm:/dev/shm brotab-integration 17 | 18 | integration-test: export INTEGRATION_TEST = 1 19 | 20 | integration-test: 21 | xhost +local:docker 22 | pytest -v -k test_integration -s 23 | 24 | test-all: unit-test smoke-build smoke-test integration-build integration-test 25 | @echo Testing all 26 | 27 | all: 28 | echo ALL 29 | 30 | reset: 31 | pkill python3; pkill xvfb-run; pkill node; pkill Xvfb; pkill firefox 32 | 33 | switch_to_dev: 34 | echo "Switching to DEV" 35 | 36 | switch_to_prod: 37 | echo "Switching to PROD" 38 | 39 | .PHONY: reset switch_to_dev switch_to_prod 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BroTab 2 | 3 | ![GitHub](https://img.shields.io/github/license/balta2ar/brotab) 4 | ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/balta2ar/brotab) 5 | [![PyPI version](https://badge.fury.io/py/brotab.svg)](https://badge.fury.io/py/brotab) 6 | ![Mozilla Add-on](https://img.shields.io/amo/v/brotab) 7 | ![Chrome Web Store](https://img.shields.io/chrome-web-store/v/mhpeahbikehnfkfnmopaigggliclhmnc) 8 | 9 | Control your browser's tabs from the terminal. 10 | 11 | ## About 12 | 13 | ```txt 14 | No command has been specified 15 | usage: bt [-h] {move,list,close,activate,search,open,words,text,html,dup,windows,clients} ... 16 | 17 | bt (brotab = Browser Tabs) is a command-line tool that helps you manage browser tabs. It can 18 | help you list, close, reorder, open and activate your tabs. 19 | 20 | positional arguments: 21 | {move,list,close,activate,active,search,index,open,words,text,html,dup,windows,clients,install} 22 | move move tabs around. This command lists available tabs and runs 23 | the editor. In the editor you can 1) reorder tabs -- tabs 24 | will be moved in the browser 2) delete tabs -- tabs will be 25 | closed 3) change window ID of the tabs -- tabs will be moved 26 | to specified windows 27 | list list available tabs. The command will request all available 28 | clients (browser plugins, mediators), and will display 29 | browser tabs in the following format: 30 | "..Page titleURL" 31 | close close specified tab IDs. Tab IDs should be in the following 32 | format: "..". You can use "list" 33 | command to obtain tab IDs (first column) 34 | activate activate given tab ID. Tab ID should be in the following 35 | format: ".." 36 | active display active tabs for each client/window in the following 37 | format: ".." 38 | search Search across your indexed tabs using sqlite fts5 plugin. 39 | query Filter tabs using chrome.tabs api. 40 | index Index the text from browser's tabs. Text is put into sqlite 41 | fts5 table. 42 | open open URLs from the stdin (one URL per line). One positional 43 | argument is required: . OR . If 44 | window_id is not specified, URL will be opened in the active 45 | window of the specifed client 46 | navigate navigate to URLs. There are two ways to specify tab ids and 47 | URLs: 1. stdin: lines with pairs of "tab_idurl" 2. 48 | arguments: bt navigate "", e.g. bt navigate b.20.1 49 | "https://google.com" stdin has the priority. 50 | update Update tabs state, e.g. URL. There are two ways to specify 51 | updates: 1. stdin, pass JSON of the form: [{"tab_id": 52 | "b.20.130", "properties": {"url": "http://www.google.com"}}] 53 | Where "properties" can be anything defined here: 54 | https://developer.mozilla.org/en-US/docs/Mozilla/Add- 55 | ons/WebExtensions/API/tabs/update Example: echo 56 | '[{"tab_id":"a.2118.2156", 57 | "properties":{"url":"https://google.com"}}]' | bt update 2. 58 | arguments, e.g.: bt update -tabId b.1.862 59 | -url="http://www.google.com" +muted 60 | words show sorted unique words from all active tabs of all 61 | clients. This is a helper for webcomplete deoplete plugin 62 | that helps complete words from the browser 63 | text show text from all tabs 64 | html show html from all tabs 65 | dup display reminder on how to close duplicate tabs using 66 | command-line tools 67 | windows display available prefixes and window IDs, along with the 68 | number of tabs in every window 69 | clients display available browser clients (mediators), their 70 | prefixes and address (host:port), native app PIDs, and 71 | browser names 72 | install configure browser settings to use bt mediator (native 73 | messaging app) 74 | 75 | optional arguments: 76 | -h, --help show this help message and exit 77 | --target TARGET_HOSTS 78 | Target hosts IP:Port 79 | ``` 80 | 81 | ## Demo [TBD] 82 | 83 | Features to show: 84 | 85 | * list tabs 86 | * close multiple tabs (fzf) 87 | * move tabs, move, same window 88 | * move tabs, move, different window 89 | * move tabs, move, different browser (NOT IMPLEMENTED) 90 | * move tabs, close 91 | * words, complete in neovim (integration with coc, ncm2, deoplete) 92 | * open tabs by url 93 | * open tab by google query, search (should be extendable, NOT IMPLEMENTED) 94 | * integration with fzf: 95 | * activate tab 96 | * close tabs 97 | * integration with rofi: 98 | * activate tab 99 | * close tabs 100 | * integration with albert 101 | * index text of available tabs (requires sqlite 3.25, fts5 plugin) 102 | * search a tab by text in albert 103 | * show duplicate tabs and close them 104 | 105 | ## Installation 106 | 107 | 1. Install command-line client: 108 | ``` 109 | $ pipx install brotab # preferred method, if pipx not installed: $ sudo apt install pipx 110 | $ uv tool install brotab # alternative 111 | $ pip install --user brotab # alternative 112 | $ sudo pip install brotab # alternative 113 | ``` 114 | 2. Install native app manifests: `bt install` 115 | 3. Install Firefox extension: https://addons.mozilla.org/en-US/firefox/addon/brotab/ 116 | 4. Install Chrome (Chromium) / Brave extension: https://chrome.google.com/webstore/detail/brotab/mhpeahbikehnfkfnmopaigggliclhmnc/ 117 | 5. Enjoy! (try `bt clients`, `bt windows`, `bt list`, `bt words`) 118 | 119 | ## Build, test and manual installation 120 | 121 | see [DEVELOPMENT.md](DEVELOPMENT.md) 122 | 123 | ## Related projects 124 | 125 | * [TabFS](https://github.com/osnr/TabFS) -- mounts tabs info a filesystem using FUSE 126 | * [dudetab](https://github.com/CRImier/dudetab) -- collection of useful scripts on top of brotab 127 | * [ulauncher-brotab](https://github.com/brpaz/ulauncher-brotab) -- Ulauncher extension for brotab 128 | * [cmp-brotab](https://github.com/pschmitt/cmp-brotab) -- brotab completion for nvim-cmp 129 | * [tab-search](https://github.com/reblws/tab-search) -- shows a nice icon with a number of tabs (Firefox) 130 | * [tab_wrangler](https://github.com/doctorcolossus/tab_wrangler) -- a text-based tab browser for tabaholics 131 | * [vimium-c](https://github.com/gdh1995/vimium-c) -- switch between tabs/history, close tabs with shift-del 132 | 133 | ## Author 134 | 135 | Yuri Bochkarev 136 | 137 | ## License 138 | 139 | MIT 140 | 141 | -------------------------------------------------------------------------------- /assets/icons/brotab-icon-128x128.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/assets/icons/brotab-icon-128x128.jpg -------------------------------------------------------------------------------- /assets/icons/brotab-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/assets/icons/brotab-icon-128x128.png -------------------------------------------------------------------------------- /assets/icons/brotab-small-tile-icon-440x280.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/assets/icons/brotab-small-tile-icon-440x280.jpg -------------------------------------------------------------------------------- /assets/icons/brotab-small-tile-icon-440x280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/assets/icons/brotab-small-tile-icon-440x280.png -------------------------------------------------------------------------------- /assets/screenshots/sample_1280x800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/assets/screenshots/sample_1280x800.png -------------------------------------------------------------------------------- /brotab/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/__init__.py -------------------------------------------------------------------------------- /brotab/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.5.0' 2 | -------------------------------------------------------------------------------- /brotab/albert/Brotab.qss: -------------------------------------------------------------------------------- 1 | /* 2 | * author: ubervison 3 | * 4 | * Check http://doc.qt.io/qt-5/stylesheet-syntax.html especially the subtopics: 5 | * The Style Sheet Syntax (http://doc.qt.io/qt-5/stylesheet-syntax.html) 6 | * Qt Style Sheets Reference (http://doc.qt.io/qt-5/stylesheet-reference.html) 7 | */ 8 | 9 | * { 10 | border: none; 11 | color : #727A8F; 12 | background-color: #e7e8eb; 13 | } 14 | 15 | #frame { 16 | padding: 4px; 17 | border-radius: 3px; 18 | background-color: #e7e8eb; 19 | 20 | /* Workaround for Qt to get fixed size button*/ 21 | min-width:1400px; 22 | max-width:1400px; 23 | /* 640 was here */ 24 | } 25 | 26 | #inputLine { 27 | padding: 2px; 28 | border-radius: 2px; 29 | border: 1px solid #CFD6E6; 30 | font-size: 36px; 31 | selection-color: #e7e8eb; 32 | selection-background-color: #727A8F; 33 | background-color: #fdfdfd; 34 | } 35 | 36 | #settingsButton { 37 | color : #ffffff; 38 | background-color: #e7e8eb; 39 | padding: 4px; 40 | 41 | /* Respect the frame border */ 42 | margin: 6px 6px 0px 0px; 43 | border-top-right-radius: 6px; 44 | border-bottom-left-radius: 10px; 45 | 46 | /* Workaround for Qt to get fixed size button*/ 47 | min-width:13px; 48 | min-height:13px; 49 | max-width:13px; 50 | max-height:13px; 51 | } 52 | 53 | /********** ListViews **********/ 54 | 55 | QListView { 56 | selection-color: #727A8F; 57 | } 58 | 59 | QListView::item:selected { 60 | background: #F5F6F7; 61 | border: 1px solid #4084D6; 62 | } 63 | 64 | QListView QScrollBar:vertical { 65 | width: 5px; 66 | background: transparent; 67 | } 68 | 69 | QListView QScrollBar::handle:vertical { 70 | background: #b8babf; 71 | min-height: 24px; 72 | } 73 | 74 | QListView QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical, 75 | QListView QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical, 76 | QListView QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 77 | border: 0px; 78 | width: 0px; 79 | height: 0px; 80 | background: transparent; 81 | } 82 | 83 | /********** actionList **********/ 84 | 85 | QListView#actionList { 86 | font-size: 20px; 87 | } 88 | 89 | QListView#actionList::item{ 90 | height:28px; 91 | } 92 | 93 | /********** resultsList **********/ 94 | 95 | QListView#resultsList { 96 | icon-size: 44px; 97 | font-size: 18px; 98 | /* font-size: 26px; */ 99 | } 100 | 101 | QListView#resultsList::item{ 102 | height:40px; 103 | /* 48 */ 104 | } 105 | -------------------------------------------------------------------------------- /brotab/albert/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/albert/__init__.py -------------------------------------------------------------------------------- /brotab/albert/brotab_search.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | The extension searches your indexed browser tabs' contents. 5 | 6 | To install put into: 7 | ~/.local/share/albert/org.albert.extension.python/modules/brotab_search.py 8 | """ 9 | 10 | import os 11 | import time 12 | import subprocess 13 | 14 | from albert import QueryHandler, Item, info, Action, runDetachedProcess 15 | 16 | from brotab.search.query import query as brotab_query 17 | 18 | __title__ = "BroTab Search" 19 | __version__ = "0.1" 20 | __triggers__ = "s " 21 | __authors__ = ["Yuri Bochkarev"] 22 | __dependencies__ = [] 23 | __exec_deps__ = ["bt"] 24 | 25 | md_iid = "0.5" 26 | md_version = "0.1" 27 | md_id = "brotab" 28 | md_name = "BroTab Search" 29 | md_description = "Search your indexed browser tabs' contents." 30 | md_license = "BSD-2" 31 | md_url = "https://github.com/balta2ar/brotab" 32 | md_maintainers = "@balta2ar" 33 | 34 | SQL_DB_FILENAME = '/tmp/tabs.sqlite' 35 | SQL_DB_TTL_SECONDS = 5 * 60 36 | QUERY_DELAY = 0.3 37 | 38 | 39 | def refresh_index(): 40 | info('Brotab: refreshing index') 41 | subprocess.Popen(['bt', 'index']) 42 | 43 | 44 | def need_refresh_index(): 45 | if not os.path.isfile(SQL_DB_FILENAME): 46 | return True 47 | 48 | mtime = os.stat(SQL_DB_FILENAME).st_mtime 49 | return time.time() - mtime > SQL_DB_TTL_SECONDS 50 | 51 | 52 | def handleQuery(query): 53 | # it's not our query 54 | # if not query.isTriggered: 55 | # return None 56 | 57 | # query is empty 58 | user_query = query.string.strip() 59 | if not user_query: 60 | return None 61 | 62 | # slight delay to avoid too many pointless lookups 63 | time.sleep(QUERY_DELAY) 64 | if not query.isValid: 65 | return None 66 | 67 | if need_refresh_index(): 68 | refresh_index() 69 | 70 | items = [] 71 | 72 | tokens = user_query.split() 73 | if tokens and tokens[0] == 'index': 74 | items.append(Item( 75 | id=__name__, 76 | text='Reindex browser tabs', 77 | subtext='> bt index', 78 | actions=[ 79 | Action( 80 | id='reindex', 81 | text='Reindex browser tabs', 82 | callable=lambda: runDetachedProcess(cmdln=['bt', 'index'], workdir='~'), 83 | ) 84 | ] 85 | )) 86 | 87 | info('query %s' % user_query) 88 | query_results = brotab_query( 89 | SQL_DB_FILENAME, user_query, max_tokens=20, max_results=100, marker_cut='') 90 | info('brotab search: %s results' % len(query_results)) 91 | for query_result in query_results: 92 | items.append(Item( 93 | id=__name__, 94 | text=query_result.snippet, 95 | subtext=query_result.title, 96 | actions=[ 97 | Action( 98 | id='activate', 99 | text='Activate', 100 | callable=lambda: runDetachedProcess(cmdln=['bt', 'activate', query_result.tab_id], workdir='~'), 101 | ) 102 | ] 103 | )) 104 | 105 | #return items 106 | query.add(items) 107 | 108 | class Plugin(QueryHandler): 109 | def id(self): return md_id 110 | def name(self): return md_name 111 | def description(self): return md_description 112 | def initialize(self): info('brotab initialize') 113 | def finalize(self): info('brotab finalize') 114 | def defaultTrigger(self): return __triggers__ 115 | def handleQuery(self, query): 116 | info('brotab handleQuery') 117 | return handleQuery(query) 118 | -------------------------------------------------------------------------------- /brotab/api.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import logging 4 | import socket 5 | import sys 6 | from collections.abc import Mapping 7 | from copy import deepcopy 8 | from functools import partial 9 | from http.client import RemoteDisconnected 10 | from json import dumps 11 | from traceback import print_exc 12 | from typing import List 13 | from urllib.error import HTTPError 14 | from urllib.error import URLError 15 | from urllib.parse import quote_plus 16 | from urllib.request import Request 17 | from urllib.request import urlopen 18 | 19 | from brotab.env import http_iface 20 | from brotab.inout import MultiPartForm 21 | from brotab.inout import edit_tabs_in_editor 22 | from brotab.operations import infer_all_commands 23 | from brotab.parallel import call_parallel 24 | from brotab.tab import parse_tab_lines 25 | from brotab.utils import encode_query 26 | from brotab.wait import ConditionTrue 27 | from brotab.wait import Waiter 28 | 29 | logger = logging.getLogger('brotab') 30 | 31 | HTTP_TIMEOUT = 10.0 32 | MAX_NUMBER_OF_TABS = 5000 33 | 34 | 35 | class HttpClient: 36 | def __init__(self, host='localhost', port=4625, timeout=HTTP_TIMEOUT): 37 | self._host: str = host 38 | self._port: int = port 39 | self._timeout: float = timeout 40 | 41 | def get(self, path, data=None): 42 | url = 'http://%s:%s%s' % (self._host, self._port, path) 43 | logger.info('GET %s' % url) 44 | if data is not None: 45 | data = data.encode('utf8') 46 | request = Request(url=url, data=data, method='GET') 47 | 48 | with urlopen(request, timeout=self._timeout) as response: 49 | return response.read().decode('utf8') 50 | 51 | def post(self, path, files=None): 52 | url = 'http://%s:%s%s' % (self._host, self._port, path) 53 | logger.info('POST %s' % url) 54 | form = MultiPartForm() 55 | for filename, content in files.items(): 56 | form.add_file(filename, filename, 57 | io.BytesIO(content.encode('utf8'))) 58 | 59 | data = bytes(form) 60 | request = Request(url=url, data=data, method='POST') 61 | request.add_header('Content-Type', form.get_content_type()) 62 | request.add_header('Content-Length', str(len(data))) 63 | 64 | with urlopen(request, timeout=self._timeout) as response: 65 | return response.read().decode('utf8') 66 | 67 | 68 | class StartupTimeout(BaseException): 69 | pass 70 | 71 | 72 | class SingleMediatorAPI(object): 73 | """ 74 | This API is designed to work with a single mediator. 75 | """ 76 | 77 | def __init__(self, prefix, host='localhost', port=4625, startup_timeout: float = None, client: HttpClient = None): 78 | self._prefix = '%s.' % prefix 79 | self._host = host 80 | self._port = port 81 | self._client = HttpClient(host=host, port=port) if client is None else client 82 | if startup_timeout is not None: 83 | self.must_ready(timeout=startup_timeout) 84 | self._pid = self.get_pid() 85 | self._browser = self.get_browser() 86 | 87 | def must_ready(self, timeout: float) -> None: 88 | condition = ConditionTrue(lambda: self.get_pid() != -1) 89 | if not Waiter(condition).wait(timeout=timeout): 90 | raise StartupTimeout('Failed to start in %s seconds' % timeout) 91 | 92 | def pid_ready(self) -> bool: 93 | return self.get_pid() != -1 94 | 95 | def pid_not_ready(self) -> bool: 96 | return not self.pid_ready() 97 | 98 | @property 99 | def browser(self) -> str: 100 | return self._browser 101 | 102 | @property 103 | def ready(self) -> bool: 104 | return self._browser != '' 105 | 106 | def __str__(self): 107 | return '%s\t%s:%s\t%s\t%s' % ( 108 | self._prefix, self._host, self._port, self._pid, self._browser) 109 | 110 | def prefix_tab(self, tab): 111 | return '%s%s' % (self._prefix, tab) 112 | 113 | def prefix_tabs(self, tabs): 114 | return list(map(self.prefix_tab, tabs)) 115 | 116 | def unprefix_tabs(self, tabs): 117 | num = len(self._prefix) 118 | return [tab[num:] 119 | if tab.startswith(self._prefix) 120 | else tab for tab in tabs] 121 | 122 | def prefix_match(self, tab): 123 | return tab.startswith(self._prefix) 124 | 125 | def filter_tabs(self, tabs): 126 | # N = len(self._prefix) 127 | # return [tab[N:] for tab in tabs 128 | return [tab for tab in tabs if self.prefix_match(tab)] 129 | 130 | def _split_tabs(self, tabs): 131 | return [tab.split('.') for tab in tabs] 132 | 133 | def get_pid(self): 134 | """Get process ID from the mediator.""" 135 | try: 136 | return int(self._get('/get_pid')) 137 | except (URLError, HTTPError, socket.timeout, RemoteDisconnected, ConnectionResetError) as e: 138 | logger.info('_get_pid failed: %s', e) 139 | return -1 140 | 141 | def get_browser(self): 142 | """Get browser name from the mediator.""" 143 | try: 144 | return self._get('/get_browser') 145 | except (URLError, HTTPError, socket.timeout, RemoteDisconnected, ConnectionResetError) as e: 146 | logger.info('_get_browser failed: %s', e) 147 | return '' 148 | 149 | def close_tabs(self, args): 150 | tabs = ','.join(tab_id for _prefix, _window_id, 151 | tab_id in self._split_tabs(args)) 152 | return self._get('/close_tabs/%s' % tabs) 153 | 154 | def activate_tab(self, args: List[str], focused: bool): 155 | if len(args) == 0: 156 | return 157 | 158 | # args: ['a.1.2'] 159 | _prefix, _window_id, tab_id = args[0].split('.') 160 | self._get('/activate_tab/%s%s' % (tab_id, '?focused=1' if focused else '')) 161 | 162 | def get_active_tabs(self, args) -> List[str]: 163 | return [self.prefix_tab(tab) for tab in self._get('/get_active_tabs').split(',')] 164 | 165 | def get_screenshot(self, args): 166 | return self._get('/get_screenshot') 167 | 168 | def query_tabs(self, args): 169 | query = args 170 | if isinstance(query, str): 171 | try: 172 | query = json.loads(query) 173 | if not isinstance(query, Mapping): 174 | raise json.JSONDecodeError("json has attributes unsupported by brotab.", "", 0) 175 | except json.JSONDecodeError as e: 176 | print("Cannot decode JSON: %s: %s" % (__name__, e), file=sys.stderr) 177 | return [] 178 | 179 | result = self._get('/query_tabs/%s' % encode_query(json.dumps(query))) 180 | lines = result.splitlines()[:MAX_NUMBER_OF_TABS] 181 | return self.prefix_tabs(lines) 182 | 183 | def query_tabs_safe(self, args, print_error=False): 184 | args = args or [] 185 | tabs = [] 186 | try: 187 | tabs = self.query_tabs(args) 188 | except ValueError as e: 189 | print("Cannot decode JSON: %s: %s" % (self, e), file=sys.stderr) 190 | if print_error: 191 | print_exc(file=sys.stderr) 192 | except URLError as e: 193 | print("Cannot access API %s: %s" % (self, e), file=sys.stderr) 194 | if print_error: 195 | print_exc(file=sys.stderr) 196 | return tabs 197 | 198 | def list_tabs(self, args): 199 | num_tabs = MAX_NUMBER_OF_TABS 200 | if len(args) > 0: 201 | num_tabs = int(args[0]) 202 | 203 | result = self._get('/list_tabs') 204 | lines = [] 205 | for line in result.splitlines()[:num_tabs]: 206 | lines.append(line) 207 | return self.prefix_tabs(lines) 208 | 209 | def list_tabs_safe(self, args, print_error=False): 210 | args = args or [] 211 | tabs = [] 212 | try: 213 | tabs = self.list_tabs(args) 214 | except ValueError as e: 215 | print("Cannot decode JSON: %s: %s" % (self, e), file=sys.stderr) 216 | if print_error: 217 | print_exc(file=sys.stderr) 218 | except URLError as e: 219 | print("Cannot access API %s: %s" % (self, e), file=sys.stderr) 220 | if print_error: 221 | print_exc(file=sys.stderr) 222 | return tabs 223 | 224 | def move_tabs(self, args): 225 | logger.info('SENDING MOVE COMMANDS: %s', args) 226 | commands = ','.join( 227 | '%s %s %s' % (tab_id, window_id, new_index) 228 | for tab_id, window_id, new_index in args) 229 | return self._get('/move_tabs/%s' % quote_plus(commands)) 230 | 231 | def open_urls(self, urls, window_id=None): 232 | data = '\n'.join(urls) 233 | logger.info('SingleMediatorAPI: open_urls: %s', urls) 234 | files = {'urls': data} 235 | ids = self._post('/open_urls' 236 | if window_id is None 237 | else ('/open_urls/%s' % window_id), 238 | files) 239 | return self.prefix_tabs(ids.splitlines()) 240 | 241 | def update_tabs(self, updates): 242 | logger.info('SingleMediatorAPI: update_tabs: %s', updates) 243 | files = {'updates': dumps(updates)} 244 | ids = self._post('/update_tabs', files) 245 | return self.prefix_tabs(ids.splitlines()) 246 | 247 | def get_words(self, tab_ids, match_regex, join_with): 248 | words = set() 249 | match_regex = encode_query(match_regex) 250 | join_with = encode_query(join_with) 251 | 252 | for tab_id in tab_ids: 253 | prefix, _window_id, tab_id = tab_id.split('.') 254 | if prefix + '.' != self._prefix: 255 | continue 256 | 257 | logger.info( 258 | 'SingleMediatorAPI: get_words: %s, match_regex: %s, join_with: %s', 259 | tab_id, match_regex, join_with) 260 | words |= set(self._get( 261 | '/get_words/%s?match_regex=%s&join_with=%s' % (tab_id, match_regex, join_with) 262 | ).splitlines()) 263 | 264 | if not tab_ids: 265 | words = set(self._get( 266 | '/get_words?match_regex=%s&join_with=%s' % (match_regex, join_with) 267 | ).splitlines()) 268 | 269 | return sorted(list(words)) 270 | 271 | def get_text_or_html(self, command, args, delimiter_regex, replace_with): 272 | num_tabs = MAX_NUMBER_OF_TABS 273 | if len(args) > 0: 274 | num_tabs = int(args[0]) 275 | 276 | result = self._get( 277 | '/%s?delimiter_regex=%s&replace_with=%s' % ( 278 | command, 279 | encode_query(delimiter_regex), 280 | encode_query(replace_with), 281 | ), 282 | ) 283 | lines = [] 284 | for line in result.splitlines()[:num_tabs]: 285 | lines.append(line) 286 | return self.prefix_tabs(lines) 287 | 288 | def get_text(self, args, delimiter_regex, replace_with): 289 | return self.get_text_or_html('get_text', args, delimiter_regex, replace_with) 290 | 291 | def get_html(self, args, delimiter_regex, replace_with): 292 | return self.get_text_or_html('get_html', args, delimiter_regex, replace_with) 293 | 294 | def shutdown(self): 295 | return self._get('/shutdown') 296 | 297 | def _get(self, path, data=None): 298 | return self._client.get(path, data) 299 | 300 | def _post(self, path, files=None): 301 | return self._client.post(path, files) 302 | 303 | 304 | def api_must_ready(port: int, browser: str, 305 | prefix='a', 306 | client_timeout: float = 0.1, 307 | startup_timeout: float = 1.0) -> SingleMediatorAPI: 308 | client = HttpClient(http_iface(), port, timeout=client_timeout) 309 | api = SingleMediatorAPI(prefix=prefix, port=port, startup_timeout=startup_timeout, client=client) 310 | assert api.browser == browser 311 | return api 312 | 313 | 314 | def int_tab_id(tab_id: str) -> int: 315 | """Convert from str(b.20.123) to int(123)""" 316 | return int(tab_id.split('.')[-1]) 317 | 318 | 319 | class MultipleMediatorsAPI(object): 320 | """ 321 | This API is designed to work with multiple mediators. 322 | """ 323 | 324 | def __init__(self, apis): 325 | self._apis = apis 326 | 327 | @property 328 | def ready_apis(self): 329 | return [api for api in self._apis if api.ready] 330 | 331 | def close_tabs(self, args): 332 | for api in self._apis: 333 | api.close_tabs(args) 334 | 335 | def activate_tab(self, args: List[str], focused: bool): 336 | if len(args) == 0: 337 | print('Usage: brotab_client.py activate_tab [--focused] <#tab>') 338 | return 2 339 | 340 | for api in self._apis: 341 | api.activate_tab(args, focused) 342 | 343 | def get_active_tabs(self, args): 344 | return [api.get_active_tabs(args) for api in self._apis] 345 | 346 | def query_tabs(self, args, print_error=False): 347 | functions = [partial(api.query_tabs_safe, args, print_error) 348 | for api in self.ready_apis] 349 | if not functions: 350 | return [] 351 | tabs = sum(call_parallel(functions), []) 352 | return tabs 353 | 354 | def list_tabs(self, args, print_error=False): 355 | functions = [partial(api.list_tabs_safe, args, print_error) 356 | for api in self.ready_apis] 357 | if not functions: 358 | return [] 359 | tabs = sum(call_parallel(functions), []) 360 | return tabs 361 | 362 | def _move_tabs_if_changed(self, api, tabs_before, tabs_after): 363 | delete_commands, move_commands, update_commands = infer_all_commands( 364 | parse_tab_lines(tabs_before), 365 | parse_tab_lines(tabs_after)) 366 | 367 | if delete_commands: 368 | print('DELETE', delete_commands) 369 | api.close_tabs(delete_commands) 370 | 371 | if move_commands: 372 | print('MOVE', move_commands) 373 | api.move_tabs(move_commands) 374 | 375 | if update_commands: 376 | print('UPDATE', update_commands) 377 | api.update_tabs(update_commands) 378 | 379 | def update_tabs(self, all_updates): 380 | results = [] 381 | for api in self._apis: 382 | updates = [deepcopy(u) for u in all_updates if api.prefix_match(u['tab_id'])] 383 | for u in updates: 384 | u['tab_id'] = int_tab_id(u['tab_id']) 385 | results.extend(api.update_tabs(updates)) 386 | return results 387 | 388 | def move_tabs(self, args): 389 | """ 390 | This command allows closing tabs and move them around. 391 | 392 | It lists current tabs, opens an editor, and when editor is done, it 393 | detects which tabs where deleted and which where moved. It closes/ 394 | removes tabs, and moves the rest accordingly. 395 | 396 | Alg: 397 | 1. find maximum sequence of ordered tabs in the output 398 | 2. take another tab from the input, 399 | - find a position in the output where to put that new tab, 400 | using binary search 401 | - insert that tab 402 | 3. continue until no input tabs out of order are left 403 | """ 404 | tabs_before = self.list_tabs(args, print_error=True) 405 | tabs_after = edit_tabs_in_editor(tabs_before) 406 | if tabs_after is None: 407 | return 408 | 409 | for api in self._apis: 410 | self._move_tabs_if_changed( 411 | api, 412 | api.filter_tabs(tabs_before), 413 | api.filter_tabs(tabs_after)) 414 | 415 | def _get_api_by_prefix(self, prefix): 416 | for api in self._apis: 417 | if api._prefix == prefix: 418 | return api 419 | raise ValueError('No such client with prefix "%s"' % prefix) 420 | 421 | def open_urls(self, urls, prefix, window_id=None): 422 | assert len(self._apis) > 0, \ 423 | 'There should be at least one client connected: %s' % self._apis 424 | # client = self._apis[0] 425 | client = self._get_api_by_prefix(prefix) 426 | return client.open_urls(urls, window_id) 427 | 428 | def get_words(self, tab_ids, match_regex, join_with): 429 | words = set() 430 | import time 431 | for api in self.ready_apis: 432 | start = time.time() 433 | words |= set(api.get_words(tab_ids, match_regex, join_with)) 434 | delta = time.time() - start 435 | # print('DELTA', delta, file=sys.stderr) 436 | return sorted(list(words)) 437 | 438 | def _get_text_or_html(self, api, getter, args, delimiter_regex, replace_with): 439 | result = [] 440 | try: 441 | import time 442 | start = time.time() 443 | result = getter(args, delimiter_regex, replace_with) 444 | delta = time.time() - start 445 | logger.info('get text/html (single client) took %s', delta) 446 | except ValueError as e: 447 | print("Cannot decode JSON: %s: %s" % (api, e), file=sys.stderr) 448 | logger.error("Cannot decode JSON: %s: %s" % (api, e)) 449 | except URLError as e: 450 | print("Cannot access API %s: %s" % (api, e), file=sys.stderr) 451 | logger.error("Cannot access API %s: %s" % (api, e)) 452 | except Exception as e: 453 | logger.error("Unknown exception: %s %s" % (api, e)) 454 | return result 455 | 456 | def get_text(self, args, delimiter_regex, replace_with): 457 | tabs = [] 458 | for api in self.ready_apis: 459 | tabs.extend(self._get_text_or_html(api, api.get_text, args, 460 | delimiter_regex, replace_with)) 461 | return tabs 462 | 463 | def get_html(self, args, delimiter_regex, replace_with): 464 | tabs = [] 465 | for api in self.ready_apis: 466 | tabs.extend(self._get_text_or_html(api, api.get_html, args, 467 | delimiter_regex, replace_with)) 468 | return tabs 469 | -------------------------------------------------------------------------------- /brotab/const.py: -------------------------------------------------------------------------------- 1 | DEFAULT_GET_WORDS_MATCH_REGEX = r'/\w+/g' 2 | DEFAULT_GET_WORDS_JOIN_WITH = r'"\n"' 3 | 4 | DEFAULT_GET_TEXT_DELIMITER_REGEX = r'/\n|\r|\t/g' 5 | DEFAULT_GET_TEXT_REPLACE_WITH = r'" "' 6 | 7 | DEFAULT_GET_HTML_DELIMITER_REGEX = r'/\n|\r|\t/g' 8 | DEFAULT_GET_HTML_REPLACE_WITH = r'" "' 9 | -------------------------------------------------------------------------------- /brotab/env.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from os.path import exists 3 | from os.path import expanduser 4 | 5 | from brotab.files import slurp_lines 6 | from brotab.mediator.const import DEFAULT_HTTP_IFACE 7 | from brotab.mediator.const import DEFAULT_MAX_HTTP_PORT 8 | from brotab.mediator.const import DEFAULT_MIN_HTTP_PORT 9 | from brotab.mediator.log import mediator_logger 10 | 11 | CONFIG = environ.get('XDG_CONFIG_HOME', expanduser('~/.config')) 12 | DEFAULT_FILENAME = '{0}/brotab/brotab.env'.format(CONFIG) 13 | 14 | 15 | def http_iface(): 16 | return environ.get('HTTP_IFACE', DEFAULT_HTTP_IFACE) 17 | 18 | 19 | def min_http_port(): 20 | return environ.get('MIN_HTTP_PORT', DEFAULT_MIN_HTTP_PORT) 21 | 22 | 23 | def max_http_port(): 24 | return environ.get('MAX_HTTP_PORT', DEFAULT_MAX_HTTP_PORT) 25 | 26 | 27 | def load_dotenv(filename=None): 28 | if filename is None: filename = DEFAULT_FILENAME 29 | mediator_logger.info('Loading .env file: %s', filename) 30 | if not exists(filename): 31 | mediator_logger.info('No .env file found: %s', filename) 32 | return 33 | for line in slurp_lines(filename): 34 | if not line or line.startswith('#'): continue 35 | key, value = line.split('=', 1) 36 | environ[key] = value 37 | -------------------------------------------------------------------------------- /brotab/extension/chrome/brotab-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/extension/chrome/brotab-icon-128x128.png -------------------------------------------------------------------------------- /brotab/extension/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | // Extension ID: knldjmfmopnpolahpmmgbagdohdnhkik 3 | "key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDcBHwzDvyBQ6bDppkIs9MP4ksKqCMyXQ/A52JivHZKh4YO/9vJsT3oaYhSpDCE9RPocOEQvwsHsFReW2nUEc6OLLyoCFFxIb7KkLGsmfakkut/fFdNJYh0xOTbSN8YvLWcqph09XAY2Y/f0AL7vfO1cuCqtkMt8hFrBGWxDdf9CQIDAQAB", 4 | "description": "Control your browser's tabs from command line", 5 | "manifest_version": 2, 6 | "name": "BroTab", 7 | "version": "1.4.0", 8 | "background": { 9 | "scripts": ["background.js"] 10 | }, 11 | "icons": { 12 | "128": "brotab-icon-128x128.png" 13 | }, 14 | "permissions": ["nativeMessaging", "tabs", "activeTab", ""], 15 | "short_name": "BroTab" 16 | } 17 | -------------------------------------------------------------------------------- /brotab/extension/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Control your browser's tabs from command line", 3 | "manifest_version": 2, 4 | "name": "BroTab", 5 | "version": "1.4.0", 6 | "background": { 7 | "scripts": ["background.js"] 8 | }, 9 | "icons": { 10 | "128": "brotab-icon-128x128.png" 11 | }, 12 | "permissions": ["nativeMessaging", "tabs", "activeTab", ""], 13 | "applications": { 14 | "gecko": { 15 | "id": "brotab_mediator@example.org", 16 | "strict_min_version": "50.0" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /brotab/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | 5 | def slurp(filename): 6 | with open(filename) as file_: 7 | return file_.read() 8 | 9 | 10 | def slurp_lines(filename): 11 | with open(filename) as file_: 12 | return [line.strip() for line in file_.readlines()] 13 | 14 | 15 | def spit(filename, contents): 16 | with open(filename, 'w', encoding='utf-8') as file_: 17 | file_.write(contents) 18 | 19 | 20 | def in_temp_dir(filename) -> str: 21 | temp_dir = tempfile.gettempdir() 22 | return os.path.join(temp_dir, filename) 23 | -------------------------------------------------------------------------------- /brotab/inout.py: -------------------------------------------------------------------------------- 1 | import io 2 | import mimetypes 3 | import os 4 | import socket 5 | import sys 6 | import uuid 7 | from select import select 8 | from subprocess import CalledProcessError 9 | from subprocess import check_call 10 | from tempfile import NamedTemporaryFile 11 | from typing import BinaryIO 12 | from typing import Iterable 13 | from typing import Union 14 | 15 | from brotab.env import max_http_port 16 | from brotab.env import min_http_port 17 | from brotab.platform import get_editor 18 | 19 | 20 | def get_mediator_ports() -> Iterable: 21 | return range(min_http_port(), max_http_port()) 22 | 23 | 24 | def get_available_tcp_port(start=1025, end=65536, host='127.0.0.1'): 25 | for port in range(start, end): 26 | if not is_port_accepting_connections(port, host): 27 | return port 28 | return RuntimeError('Cannot find available port in range %d:%d' % (start, end)) 29 | 30 | 31 | def is_port_accepting_connections(port, host='127.0.0.1'): 32 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 33 | s.settimeout(0.100) 34 | result = s.connect_ex((host, port)) 35 | s.close() 36 | return result == 0 37 | 38 | 39 | def save_tabs_to_file(tabs, filename): 40 | with open(filename, 'w', encoding='utf-8') as file_: 41 | file_.write('\n'.join(tabs)) 42 | 43 | 44 | def maybe_remove_file(filename): 45 | if os.path.exists(filename): 46 | os.remove(filename) 47 | 48 | 49 | def load_tabs_from_file(filename): 50 | with open(filename, encoding='utf-8') as file_: 51 | return [line.strip() for line in file_.readlines()] 52 | 53 | 54 | def run_editor(executable: str, filename: str): 55 | return check_call([executable, filename]) 56 | 57 | 58 | def edit_tabs_in_editor(tabs_before): 59 | with NamedTemporaryFile() as file_: 60 | file_name = file_.name 61 | file_.close() 62 | save_tabs_to_file(tabs_before, file_name) 63 | try: 64 | run_editor(get_editor(), file_name) 65 | tabs_after = load_tabs_from_file(file_name) 66 | maybe_remove_file(file_name) 67 | return tabs_after 68 | except CalledProcessError: 69 | return None 70 | 71 | 72 | def read_stdin(timeout=1.0): 73 | if select([sys.stdin, ], [], [], timeout)[0]: 74 | return sys.stdin.read() 75 | return '' 76 | 77 | 78 | def read_stdin_lines(): 79 | return [line.strip() for line in sys.stdin.readlines()] 80 | 81 | 82 | def marshal(obj): 83 | if isinstance(obj, str): 84 | return obj.encode('utf-8') 85 | if isinstance(obj, list): 86 | data = '\n'.join(obj) + '\n' 87 | return data.encode('utf-8') 88 | return str(obj).encode('utf-8') 89 | 90 | 91 | def stdout_buffer_write(message): 92 | return sys.stdout.buffer.write(message) 93 | 94 | 95 | # Taken from https://pymotw.com/3/urllib.request/ 96 | class MultiPartForm: 97 | """Accumulate the data to be used when posting a form.""" 98 | 99 | def __init__(self): 100 | self.form_fields = [] 101 | self.files = [] 102 | # Use a large random byte string to separate 103 | # parts of the MIME data. 104 | self.boundary = uuid.uuid4().hex.encode('utf-8') 105 | return 106 | 107 | def get_content_type(self): 108 | return 'multipart/form-data; boundary={}'.format( 109 | self.boundary.decode('utf-8')) 110 | 111 | def add_field(self, name, value): 112 | """Add a simple field to the form data.""" 113 | self.form_fields.append((name, value)) 114 | 115 | def add_file(self, fieldname, filename, fileHandle, 116 | mimetype=None): 117 | """Add a file to be uploaded.""" 118 | body = fileHandle.read() 119 | if mimetype is None: 120 | mimetype = ( 121 | mimetypes.guess_type(filename)[0] or 122 | 'application/octet-stream' 123 | ) 124 | self.files.append((fieldname, filename, mimetype, body)) 125 | return 126 | 127 | @staticmethod 128 | def _form_data(name): 129 | return ('Content-Disposition: form-data; ' 130 | 'name="{}"\r\n').format(name).encode('utf-8') 131 | 132 | @staticmethod 133 | def _attached_file(name, filename): 134 | return ('Content-Disposition: file; ' 135 | 'name="{}"; filename="{}"\r\n').format( 136 | name, filename).encode('utf-8') 137 | 138 | @staticmethod 139 | def _content_type(ct): 140 | return 'Content-Type: {}\r\n'.format(ct).encode('utf-8') 141 | 142 | def __bytes__(self): 143 | """Return a byte-string representing the form data, 144 | including attached files. 145 | """ 146 | buffer = io.BytesIO() 147 | boundary = b'--' + self.boundary + b'\r\n' 148 | 149 | # Add the form fields 150 | for name, value in self.form_fields: 151 | buffer.write(boundary) 152 | buffer.write(self._form_data(name)) 153 | buffer.write(b'\r\n') 154 | buffer.write(value.encode('utf-8')) 155 | buffer.write(b'\r\n') 156 | 157 | # Add the files to upload 158 | for f_name, filename, f_content_type, body in self.files: 159 | buffer.write(boundary) 160 | buffer.write(self._attached_file(f_name, filename)) 161 | buffer.write(self._content_type(f_content_type)) 162 | buffer.write(b'\r\n') 163 | buffer.write(body) 164 | buffer.write(b'\r\n') 165 | 166 | buffer.write(b'--' + self.boundary + b'--\r\n') 167 | return buffer.getvalue() 168 | 169 | 170 | class TimeoutIO(io.BytesIO): 171 | def __init__(self, file_: Union[BinaryIO, int], timeout: float): 172 | super().__init__() 173 | self.file_ = file_ 174 | self.timeout = timeout 175 | if isinstance(file_, int): 176 | self._write = lambda *args, **kwargs: os.write(file_, *args, **kwargs) 177 | self._read = lambda *args, **kwargs: os.read(file_, *args, **kwargs) 178 | self._close = lambda: os.close(file_) 179 | self._flush = lambda: None 180 | elif isinstance(file_, io.BufferedIOBase): 181 | self._write = file_.write 182 | self._read = file_.read 183 | self._close = file_.close 184 | self._flush = file_.flush 185 | else: 186 | raise TypeError('file_ must be an int or BinaryIO: %', type(file_)) 187 | 188 | def read(self, *args, **kwargs) -> bytes: 189 | rlist, _, _ = select([self.file_], [], [], self.timeout) 190 | if rlist: 191 | return self._read(*args, **kwargs) 192 | else: 193 | raise TimeoutError('Read timeout ({}s)'.format(self.timeout)) 194 | 195 | def write(self, *args, **kwargs) -> int: 196 | _, wlist, _ = select([], [self.file_], [], self.timeout) 197 | if wlist: 198 | return self._write(*args, **kwargs) 199 | else: 200 | raise TimeoutError('Write timeout ({}s)'.format(self.timeout)) 201 | 202 | def flush(self) -> None: 203 | self._flush() 204 | 205 | def close(self) -> None: 206 | self._close() 207 | 208 | 209 | # http://code.activestate.com/recipes/576655-wait-for-network-service-to-appear/ 210 | def wait_net_service(server, port, timeout=None): 211 | """ Wait for network service to appear 212 | @param timeout: in seconds, if None or 0 wait forever 213 | @return: True of False, if timeout is None may return only True or 214 | throw unhandled network exception 215 | """ 216 | s = socket.socket() 217 | if timeout: 218 | from time import time as now 219 | # time module is needed to calc timeout shared between two exceptions 220 | end = now() + timeout 221 | 222 | while True: 223 | try: 224 | if timeout: 225 | next_timeout = end - now() 226 | if next_timeout < 0: 227 | raise TimeoutError('Timed out: %s' % timeout) 228 | else: 229 | s.settimeout(next_timeout) 230 | 231 | s.connect((server, port)) 232 | 233 | except socket.timeout as err: 234 | # this exception occurs only if timeout is set 235 | if timeout: 236 | raise TimeoutError('Timed out: %s' % timeout) 237 | 238 | except socket.error as err: 239 | # catch timeout exception from underlying network library 240 | # this one is different from socket.timeout 241 | if type(err.args) != tuple: # or err[0] != errno.ETIMEDOUT: 242 | raise 243 | else: 244 | s.close() 245 | return 246 | -------------------------------------------------------------------------------- /brotab/mediator/Makefile: -------------------------------------------------------------------------------- 1 | 2 | TARGET_FIREFOX=~/.mozilla/native-messaging-hosts/brotab_mediator.json 3 | TARGET_CHROMIUM=~/.config/chromium/NativeMessagingHosts/brotab_mediator.json 4 | TARGET_CHROME=~/.config/google-chrome/NativeMessagingHosts/brotab_mediator.json 5 | 6 | install_firefox: 7 | mkdir -p $(dir $(TARGET_FIREFOX)) 8 | install firefox_mediator.json $(TARGET_FIREFOX) 9 | sed -i 's|$$PWD|$(PWD)|' $(TARGET_FIREFOX) 10 | 11 | install_chromium: 12 | mkdir -p $(dir $(TARGET_CHROMIUM)) 13 | install chromium_mediator.json $(TARGET_CHROMIUM) 14 | sed -i 's|$$PWD|$(PWD)|' $(TARGET_CHROMIUM) 15 | 16 | install_chrome: 17 | mkdir -p $(dir $(TARGET_CHROME)) 18 | install chromium_mediator.json $(TARGET_CHROME) 19 | sed -i 's|$$PWD|$(PWD)|' $(TARGET_CHROME) 20 | 21 | install: install_firefox install_chromium install_chrome 22 | -------------------------------------------------------------------------------- /brotab/mediator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/mediator/__init__.py -------------------------------------------------------------------------------- /brotab/mediator/brotab_mediator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import logging.handlers 5 | import os 6 | import re 7 | import socket 8 | 9 | from brotab.env import http_iface 10 | from brotab.env import load_dotenv 11 | from brotab.inout import get_mediator_ports 12 | from brotab.inout import is_port_accepting_connections 13 | from brotab.mediator import sig 14 | from brotab.mediator.const import DEFAULT_SHUTDOWN_POLL_INTERVAL 15 | from brotab.mediator.http_server import MediatorHttpServer 16 | from brotab.mediator.log import disable_click_echo 17 | from brotab.mediator.log import mediator_logger 18 | from brotab.mediator.remote_api import default_remote_api 19 | from brotab.mediator.transport import default_transport 20 | 21 | 22 | # TODO: 23 | # 1. Run HTTP server and accept the following commands: 24 | # - /list_tabs 25 | # - /close_tabs 26 | # - /move_tabs ??? 27 | # - /open_tab 28 | # - /open_urls 29 | # - /new_tab (google search) 30 | # - /get_tab_text 31 | # - /get_active_tabs_text 32 | # 33 | # TODO: fix bug when the number of tabs > 1100 34 | # TODO: read stdin continuously in a separate thread, 35 | # detect if it's closed, shutdown the server, and exit. 36 | # make sure this threaded reader and server reader are mutually exclusive. 37 | # TODO: all commands should be synchronous and should only terminate after 38 | # the action has been actually executed in the browser. 39 | # TODO: logs from main and mediator should go into different files 40 | # TODO: bt html may cause "Uncaught (in promise) Error: Message length exceeded maximum allowed length." 41 | 42 | 43 | def monkeypatch_socket_bind_allow_port_reuse(): 44 | """Allow port reuse by default""" 45 | socket.socket._bind = socket.socket.bind 46 | 47 | def my_socket_bind(self, *args, **kwargs): 48 | mediator_logger.info('Custom bind called: %s, %s', args, kwargs) 49 | self.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 50 | return socket.socket._bind(self, *args, **kwargs) 51 | 52 | socket.socket.bind = my_socket_bind 53 | 54 | 55 | def blacklist_loggers(): 56 | blacklist = r'.*pyppeteer.*|.*urllib.*|.*socks.*|.*requests.*|.*dotenv.*' 57 | for name in logging.root.manager.loggerDict: 58 | match = re.match(blacklist, name) is not None 59 | # print(name, match) 60 | if match: 61 | logger = logging.getLogger(name) 62 | logger.setLevel(logging.ERROR) 63 | logger.propagate = False 64 | 65 | 66 | def mediator_main(): 67 | monkeypatch_socket_bind_allow_port_reuse() 68 | disable_click_echo() 69 | 70 | load_dotenv() 71 | port_range = list(get_mediator_ports()) 72 | transport = default_transport() 73 | # transport = transport_with_timeout(sys.stdin.buffer, sys.stdout.buffer, DEFAULT_TRANSPORT_TIMEOUT) 74 | # transport = transport_with_timeout(sys.stdin.buffer, sys.stdout.buffer, 1.0) 75 | remote_api = default_remote_api(transport) 76 | host = http_iface() 77 | poll_interval = DEFAULT_SHUTDOWN_POLL_INTERVAL 78 | 79 | for port in port_range: 80 | mediator_logger.info('Starting mediator on %s:%s...', host, port) 81 | if is_port_accepting_connections(port): 82 | continue 83 | try: 84 | server = MediatorHttpServer(host, port, remote_api, poll_interval) 85 | thread = server.run.in_thread() 86 | sig.setup(lambda: server.run.shutdown(join=False)) 87 | # server.run.parent_watcher(thread.is_alive, interval=1.0) 88 | thread.join() 89 | mediator_logger.info('Exiting mediator pid=%s on %s:%s...', os.getpid(), host, port) 90 | break 91 | except OSError as e: 92 | # TODO: fixme: we won't get this if we run in a process 93 | mediator_logger.info('Cannot bind on port %s: %s', port, e) 94 | except BrokenPipeError as e: 95 | # TODO: probably also won't work with processes, also a race 96 | mediator_logger.exception('Pipe has been closed (%s)', e) 97 | server.run.shutdown(join=True) 98 | break 99 | 100 | else: 101 | mediator_logger.error('No TCP ports available for bind in range %s', port_range) 102 | 103 | 104 | def main(): 105 | mediator_main() 106 | 107 | 108 | if __name__ == '__main__': 109 | main() 110 | -------------------------------------------------------------------------------- /brotab/mediator/chromium_mediator.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brotab_mediator", 3 | "description": "This mediator exposes interface over TCP to control browser's tabs", 4 | "path": "$PWD/brotab_mediator.py", 5 | "type": "stdio", 6 | "allowed_extensions": [ "brotab_mediator@example.org" ], 7 | "allowed_origins": [ 8 | "chrome-extension://mhpeahbikehnfkfnmopaigggliclhmnc/" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /brotab/mediator/chromium_mediator_tests.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brotab_mediator", 3 | "description": "This mediator exposes interface over TCP to control browser's tabs", 4 | "path": "$PWD/brotab_mediator.py", 5 | "type": "stdio", 6 | "allowed_extensions": [ "brotab_mediator@example.org" ], 7 | "allowed_origins": [ 8 | "chrome-extension://knldjmfmopnpolahpmmgbagdohdnhkik/" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /brotab/mediator/const.py: -------------------------------------------------------------------------------- 1 | from brotab.const import DEFAULT_GET_HTML_DELIMITER_REGEX 2 | from brotab.const import DEFAULT_GET_HTML_REPLACE_WITH 3 | from brotab.const import DEFAULT_GET_TEXT_DELIMITER_REGEX 4 | from brotab.const import DEFAULT_GET_TEXT_REPLACE_WITH 5 | from brotab.const import DEFAULT_GET_WORDS_JOIN_WITH 6 | from brotab.const import DEFAULT_GET_WORDS_MATCH_REGEX 7 | from brotab.utils import encode_query 8 | 9 | DEFAULT_TRANSPORT_TIMEOUT = 60.0 10 | DEFAULT_SHUTDOWN_POLL_INTERVAL = 1.0 11 | DEFAULT_HTTP_IFACE = '127.0.0.1' 12 | DEFAULT_MIN_HTTP_PORT = 4625 13 | DEFAULT_MAX_HTTP_PORT = DEFAULT_MIN_HTTP_PORT + 10 14 | DEFAULT_GET_WORDS_MATCH_REGEX = encode_query(DEFAULT_GET_WORDS_MATCH_REGEX) 15 | DEFAULT_GET_WORDS_JOIN_WITH = encode_query(DEFAULT_GET_WORDS_JOIN_WITH) 16 | DEFAULT_GET_TEXT_DELIMITER_REGEX = encode_query(DEFAULT_GET_TEXT_DELIMITER_REGEX) 17 | DEFAULT_GET_TEXT_REPLACE_WITH = encode_query(DEFAULT_GET_TEXT_REPLACE_WITH) 18 | DEFAULT_GET_HTML_DELIMITER_REGEX = encode_query(DEFAULT_GET_HTML_DELIMITER_REGEX) 19 | DEFAULT_GET_HTML_REPLACE_WITH = encode_query(DEFAULT_GET_HTML_REPLACE_WITH) 20 | -------------------------------------------------------------------------------- /brotab/mediator/firefox_mediator.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brotab_mediator", 3 | "description": "This mediator exposes interface over TCP to control browser's tabs", 4 | "path": "$PWD/brotab_mediator.py", 5 | "type": "stdio", 6 | "allowed_extensions": [ "brotab_mediator@example.org" ] 7 | } 8 | -------------------------------------------------------------------------------- /brotab/mediator/http_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from json import loads 3 | from threading import Thread 4 | from urllib.parse import unquote_plus 5 | from wsgiref.simple_server import make_server 6 | 7 | from flask import Flask 8 | from flask import request 9 | 10 | from brotab.mediator.const import DEFAULT_GET_HTML_DELIMITER_REGEX 11 | from brotab.mediator.const import DEFAULT_GET_HTML_REPLACE_WITH 12 | from brotab.mediator.const import DEFAULT_GET_TEXT_DELIMITER_REGEX 13 | from brotab.mediator.const import DEFAULT_GET_TEXT_REPLACE_WITH 14 | from brotab.mediator.const import DEFAULT_GET_WORDS_JOIN_WITH 15 | from brotab.mediator.const import DEFAULT_GET_WORDS_MATCH_REGEX 16 | from brotab.mediator.log import mediator_logger 17 | from brotab.mediator.remote_api import BrowserRemoteAPI 18 | from brotab.mediator.runner import Runner 19 | from brotab.mediator.support import is_valid_integer 20 | from brotab.mediator.transport import TransportError 21 | from brotab.utils import decode_query 22 | 23 | 24 | class MediatorHttpServer: 25 | def __init__(self, host: str, port: int, remote_api: BrowserRemoteAPI, poll_interval: float): 26 | self.host: str = host 27 | self.port: int = port 28 | self.remote_api: BrowserRemoteAPI = remote_api 29 | self.pid: int = os.getpid() 30 | self.app = Flask(__name__) 31 | self.http_server = make_server(host=host, port=port, app=self.app) 32 | self._setup_routes() 33 | 34 | def serve(): 35 | mediator_logger.info('Serving mediator on %s:%s', host, port) 36 | self.http_server.serve_forever(poll_interval=poll_interval) 37 | 38 | def shutdown(join: bool): 39 | mediator_logger.info('Closing mediator http server on %s:%s', host, port) 40 | self.http_server.server_close() 41 | mediator_logger.info('Shutting down mediator http server on %s:%s', host, port) 42 | thread = Thread(target=self.http_server.shutdown) 43 | thread.daemon = True 44 | thread.start() 45 | if join: 46 | thread.join() 47 | mediator_logger.info('Done shutting down mediator (is_alive=%s) http server on %s:%s', 48 | thread.is_alive(), host, port) 49 | 50 | self.run = Runner(serve, shutdown) 51 | 52 | def _setup_routes(self) -> None: 53 | mediator_logger.info('Starting mediator http server on %s:%s pid=%s', self.host, self.port, self.pid) 54 | self.app.register_error_handler(ConnectionError, self.error_handler) 55 | self.app.register_error_handler(TimeoutError, self.error_handler) 56 | self.app.register_error_handler(ValueError, self.error_handler) 57 | self.app.register_error_handler(TransportError, self.error_handler) 58 | self.app.route('/', methods=['GET'])(self.root_handler) 59 | self.app.route('/shutdown', methods=['GET'])(self.shutdown) 60 | self.app.route('/list_tabs', methods=['GET'])(self.list_tabs) 61 | self.app.route('/query_tabs/', methods=['GET'])(self.query_tabs) 62 | self.app.route('/move_tabs/', methods=['GET'])(self.move_tabs) 63 | self.app.route('/open_urls/', methods=['POST'])(self.open_urls) 64 | self.app.route('/update_tabs', methods=['POST'])(self.update_tabs) 65 | self.app.route('/open_urls', methods=['POST'])(self.open_urls) 66 | self.app.route('/close_tabs/', methods=['GET'])(self.close_tabs) 67 | self.app.route('/new_tab/', methods=['GET'])(self.new_tab) 68 | self.app.route('/activate_tab/', methods=['GET'])(self.activate_tab) 69 | self.app.route('/get_active_tabs', methods=['GET'])(self.get_active_tabs) 70 | self.app.route('/get_screenshot', methods=['GET'])(self.get_screenshot) 71 | self.app.route('/get_words/', methods=['GET'])(self.get_words) 72 | self.app.route('/get_words', methods=['GET'])(self.get_words) 73 | self.app.route('/get_text', methods=['GET'])(self.get_text) 74 | self.app.route('/get_html', methods=['GET'])(self.get_html) 75 | self.app.route('/get_pid', methods=['GET'])(self.get_pid) 76 | self.app.route('/get_browser', methods=['GET'])(self.get_browser) 77 | self.app.route('/echo', methods=['GET'])(self.echo) 78 | 79 | def error_handler(self, e: Exception): 80 | mediator_logger.exception('Shutting down mediator http server due to exception: %s', e) 81 | # can't wait for shutdown here because we're processing a request right now, 82 | # we will get deadlocked if we wait (join=True) 83 | self.run.shutdown(join=False) 84 | return '' 85 | 86 | def root_handler(self): 87 | links = [] 88 | for rule in self.app.url_map.iter_rules(): 89 | methods = ','.join(rule.methods) 90 | line = '{0}\t{1}\t{2}'.format(rule.endpoint, methods, rule) 91 | links.append(line) 92 | return '\n'.join(links) 93 | 94 | def shutdown(self): 95 | # can't wait for shutdown here because we're processing a request right now, 96 | # we will get deadlocked if we wait (join=True) 97 | self.run.shutdown(join=False) 98 | return 'OK' 99 | 100 | def list_tabs(self): 101 | tabs = self.remote_api.list_tabs() 102 | return '\n'.join(tabs) 103 | 104 | def query_tabs(self, query_info): 105 | tabs = self.remote_api.query_tabs(query_info) 106 | return '\n'.join(tabs) 107 | 108 | def move_tabs(self, move_triplets): 109 | return self.remote_api.move_tabs(unquote_plus(move_triplets)) 110 | 111 | def open_urls(self, window_id=None): 112 | urls = request.files.get('urls') 113 | if urls is None: 114 | return 'ERROR: Please provide urls file in the request' 115 | urls = urls.stream.read().decode('utf8').splitlines() 116 | mediator_logger.info('Open urls (window_id = %s): %s', window_id, urls) 117 | result = self.remote_api.open_urls(urls, window_id) 118 | mediator_logger.info('Open urls result: %s', str(result)) 119 | return '\n'.join(result) 120 | 121 | def update_tabs(self): 122 | updates = request.files.get('updates') 123 | if updates is None: 124 | return 'ERROR: Please provide updates in the request' 125 | updates = loads(updates.stream.read().decode('utf8')) 126 | mediator_logger.info('Sending tab updates: %s', updates) 127 | result = self.remote_api.update_tabs(updates) 128 | mediator_logger.info('Update tabs result: %s', str(result)) 129 | return '\n'.join(result) 130 | 131 | def close_tabs(self, tab_ids): 132 | return self.remote_api.close_tabs(tab_ids) 133 | 134 | def new_tab(self, query): 135 | return self.remote_api.new_tab(query) 136 | 137 | def activate_tab(self, tab_id): 138 | focused = bool(request.args.get('focused', False)) 139 | self.remote_api.activate_tab(tab_id, focused) 140 | return 'OK' 141 | 142 | def get_active_tabs(self): 143 | return self.remote_api.get_active_tabs() 144 | 145 | def get_screenshot(self): 146 | return self.remote_api.get_screenshot() 147 | 148 | def get_words(self, tab_id=None): 149 | tab_id = int(tab_id) if is_valid_integer(tab_id) else None 150 | match_regex = request.args.get('match_regex', DEFAULT_GET_WORDS_MATCH_REGEX) 151 | join_with = request.args.get('join_with', DEFAULT_GET_WORDS_JOIN_WITH) 152 | words = self.remote_api.get_words(tab_id, 153 | decode_query(match_regex), 154 | decode_query(join_with)) 155 | mediator_logger.info('words for tab_id %s (match_regex %s, join_with %s): %s', 156 | tab_id, match_regex, join_with, words) 157 | return '\n'.join(words) 158 | 159 | def get_text(self): 160 | delimiter_regex = request.args.get('delimiter_regex', DEFAULT_GET_TEXT_DELIMITER_REGEX) 161 | replace_with = request.args.get('replace_with', DEFAULT_GET_TEXT_REPLACE_WITH) 162 | lines = self.remote_api.get_text(decode_query(delimiter_regex), 163 | decode_query(replace_with)) 164 | return '\n'.join(lines) 165 | 166 | def get_html(self): 167 | delimiter_regex = request.args.get('delimiter_regex', DEFAULT_GET_HTML_DELIMITER_REGEX) 168 | replace_with = request.args.get('replace_with', DEFAULT_GET_HTML_REPLACE_WITH) 169 | lines = self.remote_api.get_html(decode_query(delimiter_regex), 170 | decode_query(replace_with)) 171 | return '\n'.join(lines) 172 | 173 | def get_pid(self): 174 | mediator_logger.info('getting pid') 175 | return str(os.getpid()) 176 | 177 | def get_browser(self): 178 | mediator_logger.info('getting browser name') 179 | return self.remote_api.get_browser() 180 | 181 | def echo(self): 182 | title = request.args.get('title', 'title') 183 | body = request.args.get('body', 'body') 184 | reply = ('%s' 185 | '%s' 186 | % (title, body)) 187 | return reply 188 | -------------------------------------------------------------------------------- /brotab/mediator/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | from traceback import format_stack 4 | 5 | from brotab.files import in_temp_dir 6 | 7 | 8 | def _init_logger(tag, filename: str): 9 | FORMAT = '%(asctime)-15s %(process)-5d %(levelname)-8s %(filename)s:%(lineno)d:%(funcName)s %(message)s' 10 | MAX_LOG_SIZE = 50 * 1024 * 1024 11 | LOG_BACKUP_COUNT = 1 12 | 13 | log = logging.getLogger('brotab') 14 | log.setLevel(logging.DEBUG) 15 | handler = logging.handlers.RotatingFileHandler( 16 | filename=filename, 17 | maxBytes=MAX_LOG_SIZE, 18 | backupCount=LOG_BACKUP_COUNT, 19 | ) 20 | handler.setFormatter(logging.Formatter(FORMAT)) 21 | log.addHandler(handler) 22 | log.info('Logger has been created (%s)', tag) 23 | return log 24 | 25 | 26 | def init_brotab_logger(tag: str): 27 | return _init_logger(tag, in_temp_dir('brotab.log')) 28 | 29 | 30 | def init_mediator_logger(tag: str): 31 | return _init_logger(tag, in_temp_dir('brotab_mediator.log')) 32 | 33 | 34 | def disable_logging(): 35 | # disables flask request logging 36 | log = logging.getLogger('werkzeug') 37 | log.setLevel(logging.ERROR) 38 | log.disabled = True 39 | # TODO: investigate this, maybe we can redirect werkzeug from stdout to a file 40 | # log.handlers = [] 41 | # disables my own logging in log_and_suppress_exceptions 42 | # app.logger.disabled = True 43 | # from flask.logging import default_handler 44 | # app.logger.removeHandler(default_handler) 45 | 46 | 47 | def disable_click_echo(): 48 | """Stupid flask started using click which unconditionally prints stupid 49 | messages""" 50 | 51 | def numb_echo(*args, **kwargs): 52 | pass 53 | 54 | import click 55 | click.echo = numb_echo 56 | click.secho = numb_echo 57 | 58 | 59 | def stack(): 60 | return '\n'.join(format_stack()) 61 | 62 | 63 | mediator_logger = init_mediator_logger('mediator') 64 | brotab_logger = init_brotab_logger('brotab') 65 | -------------------------------------------------------------------------------- /brotab/mediator/remote_api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from urllib.parse import quote_plus 3 | 4 | from brotab.mediator.log import mediator_logger 5 | from brotab.mediator.transport import Transport 6 | 7 | 8 | class BrowserRemoteAPI: 9 | """ 10 | Communicates with a browser using stdin/stdout. This mediator is supposed 11 | to be run by the browser after a request from the helper extension. 12 | """ 13 | 14 | def __init__(self, transport: Transport): 15 | self._transport: Transport = transport 16 | 17 | def list_tabs(self): 18 | command = {'name': 'list_tabs'} 19 | self._transport.send(command) 20 | return self._transport.recv() 21 | 22 | def query_tabs(self, query_info: str): 23 | mediator_logger.info('query info: %s', query_info) 24 | command = {'name': 'query_tabs', 'query_info': query_info} 25 | self._transport.send(command) 26 | return self._transport.recv() 27 | 28 | def move_tabs(self, move_triplets: str): 29 | """ 30 | :param move_triplets: Comma-separated list of: 31 | 32 | """ 33 | mediator_logger.info('move_tabs, move_triplets: %s', move_triplets) 34 | 35 | triplets = [list(map(int, triplet.split(' '))) 36 | for triplet in move_triplets.split(',')] 37 | mediator_logger.info('moving tab ids: %s', triplets) 38 | command = {'name': 'move_tabs', 'move_triplets': triplets} 39 | self._transport.send(command) 40 | return self._transport.recv() 41 | 42 | def open_urls(self, urls: List[str], window_id=None): 43 | """ 44 | Open specified list of URLs in a window, specified by window_id. 45 | 46 | If window_id is None, currently active window is used. 47 | """ 48 | mediator_logger.info('open urls: %s', urls) 49 | 50 | command = {'name': 'open_urls', 'urls': urls} 51 | if window_id is not None: 52 | command['window_id'] = window_id 53 | self._transport.send(command) 54 | return self._transport.recv() 55 | 56 | def update_tabs(self, updates: [object]): 57 | """ 58 | Sends a list of updates to the browser. Format: 59 | [ { 60 | 'tab_id': , 61 | 'properties': { 62 | 'url': , 63 | } 64 | } ] 65 | see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update 66 | """ 67 | mediator_logger.info('update tabs: %s', updates) 68 | command = {'name': 'update_tabs', 'updates': updates} 69 | self._transport.send(command) 70 | return self._transport.recv() 71 | 72 | def close_tabs(self, tab_ids: str): 73 | """ 74 | :param tab_ids: Comma-separated list of tab IDs to close. 75 | """ 76 | int_tab_ids = [int(id_) for id_ in tab_ids.split(',')] 77 | mediator_logger.info('closing tab ids: %s', int_tab_ids) 78 | command = {'name': 'close_tabs', 'tab_ids': int_tab_ids} 79 | self._transport.send(command) 80 | return self._transport.recv() 81 | 82 | def new_tab(self, query: str): 83 | url = "https://www.google.com/search?q=%s" % quote_plus(query) 84 | mediator_logger.info('opening url: %s', url) 85 | command = {'name': 'new_tab', 'url': url} 86 | self._transport.send(command) 87 | return self._transport.recv() 88 | 89 | def activate_tab(self, tab_id: int, focused: bool): 90 | mediator_logger.info('activating tab id: %s', tab_id) 91 | command = {'name': 'activate_tab', 'tab_id': tab_id, 'focused': focused} 92 | self._transport.send(command) 93 | 94 | def get_active_tabs(self) -> str: 95 | mediator_logger.info('getting active tabs') 96 | command = {'name': 'get_active_tabs'} 97 | self._transport.send(command) 98 | return self._transport.recv() 99 | 100 | def get_screenshot(self) -> str: 101 | mediator_logger.info('getting screemsjpt') 102 | command = {'name': 'get_screenshot'} 103 | self._transport.send(command) 104 | return self._transport.recv() 105 | 106 | def get_words(self, tab_id: str, match_regex: str, join_with: str): 107 | mediator_logger.info('getting tab words: %s', tab_id) 108 | command = { 109 | 'name': 'get_words', 110 | 'tab_id': tab_id, 111 | 'match_regex': match_regex, 112 | 'join_with': join_with, 113 | } 114 | self._transport.send(command) 115 | return self._transport.recv() 116 | 117 | def get_text(self, delimiter_regex: str, replace_with: str): 118 | mediator_logger.info('getting text, delimiter_regex=%s, replace_with=%s', 119 | delimiter_regex, replace_with) 120 | command = { 121 | 'name': 'get_text', 122 | 'delimiter_regex': delimiter_regex, 123 | 'replace_with': replace_with, 124 | } 125 | self._transport.send(command) 126 | return self._transport.recv() 127 | 128 | def get_html(self, delimiter_regex: str, replace_with: str): 129 | mediator_logger.info('getting html, delimiter_regex=%s, replace_with=%s', 130 | delimiter_regex, replace_with) 131 | command = { 132 | 'name': 'get_html', 133 | 'delimiter_regex': delimiter_regex, 134 | 'replace_with': replace_with, 135 | } 136 | self._transport.send(command) 137 | return self._transport.recv() 138 | 139 | def get_browser(self): 140 | mediator_logger.info('getting browser name') 141 | command = {'name': 'get_browser'} 142 | self._transport.send(command) 143 | return self._transport.recv() 144 | 145 | 146 | def default_remote_api(transport: Transport) -> BrowserRemoteAPI: 147 | return BrowserRemoteAPI(transport) 148 | -------------------------------------------------------------------------------- /brotab/mediator/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import time 4 | from multiprocessing import Process 5 | from threading import Thread 6 | from typing import Callable 7 | 8 | # from psutil import pid_exists 9 | 10 | from brotab.mediator.log import disable_logging 11 | from brotab.mediator.log import mediator_logger 12 | 13 | 14 | class NotStarted(Exception): 15 | pass 16 | 17 | 18 | class Runner: 19 | def __init__(self, serve: Callable[[], None], shutdown: Callable[[bool], None]): 20 | self._serve = serve 21 | self._shutdown = shutdown 22 | 23 | def shutdown(self, join: bool) -> None: 24 | # TODO: break this to test ctrl-c 25 | if not self._shutdown: 26 | raise NotStarted('start the runner first') 27 | mediator_logger.info('Runner: calling terminate (pid=%s): %s', os.getpid(), self._shutdown) 28 | self._shutdown(join) 29 | 30 | def here(self) -> None: 31 | mediator_logger.info('Started mediator process, pid=%s', os.getpid()) 32 | disable_logging() 33 | return self._serve() 34 | 35 | def in_thread(self) -> Thread: 36 | thread = Thread(target=self.here) 37 | thread.daemon = True 38 | thread.start() 39 | return thread 40 | 41 | # def parent_watcher(self, running: Callable, interval: float): 42 | # self._watcher(running, os.getppid(), interval=interval) 43 | # 44 | # def _watcher(self, running: Callable, parent_pid: int, interval: float) -> None: 45 | # mediator_logger.info('Watching parent process parent=%s current pid=%s', 46 | # parent_pid, os.getpid()) 47 | # while True: 48 | # time.sleep(interval) 49 | # if not running(): # someone shutdown mediator, let's bail 50 | # break 51 | # if not pid_exists(parent_pid): 52 | # mediator_logger.info('Parent process died pid=%s, shutting down mediator', parent_pid) 53 | # self.shutdown(join=False) 54 | # break 55 | -------------------------------------------------------------------------------- /brotab/mediator/sig.py: -------------------------------------------------------------------------------- 1 | import signal 2 | from typing import Callable 3 | 4 | from brotab.mediator.log import mediator_logger 5 | 6 | 7 | def setup(shutdown: Callable[[], None]) -> None: 8 | def handler(signum, _frame): 9 | mediator_logger.info('Got signal %s', signum) 10 | shutdown() 11 | 12 | signal.signal(signal.SIGINT, handler) 13 | signal.signal(signal.SIGTERM, handler) 14 | -------------------------------------------------------------------------------- /brotab/mediator/support.py: -------------------------------------------------------------------------------- 1 | def is_valid_integer(str_value): 2 | try: 3 | return int(str_value) >= 0 4 | except (ValueError, TypeError): 5 | return False -------------------------------------------------------------------------------- /brotab/mediator/transport.py: -------------------------------------------------------------------------------- 1 | import json 2 | import struct 3 | import sys 4 | from abc import ABC 5 | from abc import abstractmethod 6 | from typing import BinaryIO 7 | from typing import Union 8 | 9 | from brotab.inout import TimeoutIO 10 | from brotab.mediator.log import mediator_logger 11 | 12 | 13 | class Transport(ABC): 14 | @abstractmethod 15 | def send(self, command: dict) -> None: 16 | pass 17 | 18 | @abstractmethod 19 | def recv(self) -> dict: 20 | pass 21 | 22 | @abstractmethod 23 | def close(self) -> None: 24 | pass 25 | 26 | 27 | def default_transport() -> Transport: 28 | return StdTransport(sys.stdin.buffer, sys.stdout.buffer) 29 | 30 | 31 | def transport_with_timeout(input_, output: Union[BinaryIO, int], timeout: float) -> Transport: 32 | return StdTransport(TimeoutIO(input_, timeout), 33 | TimeoutIO(output, timeout)) 34 | 35 | 36 | class TransportError(Exception): 37 | pass 38 | 39 | 40 | class StdTransport(Transport): 41 | def __init__(self, input_file: BinaryIO, output_file: BinaryIO): 42 | self._in = input_file 43 | self._out = output_file 44 | 45 | def reset(self): 46 | self._in.seek(0) 47 | self._out.seek(0) 48 | 49 | def send(self, command: dict) -> None: 50 | encoded = self._encode(command) 51 | mediator_logger.info('StdTransport SENDING: %s', command) 52 | self._out.write(encoded['length']) 53 | self._out.write(encoded['content']) 54 | self._out.flush() 55 | mediator_logger.info('StdTransport SENDING DONE: %s', command) 56 | 57 | def recv(self) -> dict: 58 | mediator_logger.info('StdTransport RECEIVING') 59 | raw_length = self._in.read(4) 60 | if len(raw_length) == 0: 61 | raise TransportError('StdTransport: cannot read, raw_length is empty') 62 | message_length = struct.unpack('@I', raw_length)[0] 63 | message = self._in.read(message_length).decode('utf8') 64 | mediator_logger.info('StdTransport RECEIVED: %s', message.encode('utf8')) 65 | return json.loads(message) 66 | 67 | def _encode(self, message): 68 | encoded_content = json.dumps(message).encode('utf8') 69 | encoded_length = struct.pack('@I', len(encoded_content)) 70 | return {'length': encoded_length, 'content': encoded_content} 71 | 72 | def close(self): 73 | self._in.close() 74 | self._out.close() 75 | -------------------------------------------------------------------------------- /brotab/operations.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from brotab.tab import Tab 4 | 5 | 6 | # def _get_old_index(tab, tabs_before): 7 | # for index, value in enumerate(tabs_before): 8 | # if value == tab: 9 | # return index 10 | 11 | # raise ValueError('Tab %s not found' % tab) 12 | 13 | 14 | # def _get_tab_id(tab): 15 | # index = tab.split('\t')[0] 16 | # str_index = index.split('.')[1] 17 | # return int(str_index) 18 | 19 | 20 | def _get_index_by_tab_id(tab_id, tabs: [Tab]): 21 | for index, tab in enumerate(tabs): 22 | # if tab_id == _get_tab_id(tab): 23 | if tab_id == tab.tab_id: 24 | return index 25 | 26 | return None 27 | 28 | 29 | class KeyToIndexMapper: 30 | """ 31 | This class allows building different kind of mappings to retrieve 32 | values faster later. 33 | """ 34 | 35 | def __init__(self, make_key: Callable, tabs: [Tab]): 36 | self._make_key = make_key 37 | self._mapping = { 38 | make_key(tab): index 39 | for index, tab in enumerate(tabs) 40 | } 41 | 42 | def __contains__(self, item): 43 | key = self._make_key(item) 44 | return key in self._mapping 45 | 46 | def __getitem__(self, item): 47 | key = self._make_key(item) 48 | return self._mapping[key] 49 | 50 | 51 | class LineToIndexMapper(KeyToIndexMapper): 52 | def __init__(self, tabs: [Tab]): 53 | super().__init__(lambda tab: tab.line, tabs) 54 | 55 | 56 | class TabIdTitleToIndexMapper(KeyToIndexMapper): 57 | def __init__(self, tabs: [Tab]): 58 | super().__init__(lambda tab: (tab.tab_id, tab.title), tabs) 59 | 60 | 61 | # class TabIdIndexUrlToIndexMapper(KeyToIndexMapper): 62 | # def __init__(self, tabs: [Tab]): 63 | # super().__init__(lambda tab: (tab.tab_id, tab.title, tab.url), tabs) 64 | 65 | 66 | def get_longest_increasing_subsequence(X): 67 | """Returns the Longest Increasing Subsequence in the Given List/Array""" 68 | N = len(X) 69 | P = [0] * N 70 | M = [0] * (N + 1) 71 | L = 0 72 | for i in range(N): 73 | lo = 1 74 | hi = L 75 | while lo <= hi: 76 | mid = (lo + hi) // 2 77 | if (X[M[mid]] < X[i]): 78 | lo = mid + 1 79 | else: 80 | hi = mid - 1 81 | 82 | newL = lo 83 | P[i] = M[newL - 1] 84 | M[newL] = i 85 | 86 | if (newL > L): 87 | L = newL 88 | 89 | S = [] 90 | k = M[L] 91 | for i in range(L - 1, -1, -1): 92 | S.append(X[k]) 93 | k = P[k] 94 | return S[::-1] 95 | 96 | 97 | def infer_delete_commands(tabs_before: [Tab], tabs_after: [Tab]): 98 | tab_id_title_to_index = TabIdTitleToIndexMapper(tabs_after) 99 | 100 | commands = [] 101 | after = set(tabs_after) 102 | 103 | for index in range(len(tabs_before) - 1, -1, -1): 104 | tab_before = tabs_before[index] 105 | 106 | # if tab_before not in after: 107 | if tab_before not in tab_id_title_to_index: 108 | # commands.append(_get_tab_id(tab_before)) 109 | # commands.append(tab_before.tab_id) 110 | commands.append('%s.%s.%s' % (tab_before.prefix, 111 | tab_before.window_id, 112 | tab_before.tab_id)) 113 | return commands 114 | 115 | 116 | def _get_old_index(tab_after: Tab, 117 | line_to_index: LineToIndexMapper, 118 | tab_id_title_to_index: TabIdTitleToIndexMapper, 119 | tabs_before: [Tab]): 120 | """ 121 | Try to find out the index of the tab before the move. 122 | """ 123 | # Easy case: tab has been just moved without changing window ID. 124 | if tab_after in line_to_index: 125 | return line_to_index[tab_after] 126 | 127 | # The tab might have window ID changed. Try to look for the title only. 128 | if tab_after in tab_id_title_to_index: 129 | return tab_id_title_to_index[tab_after] 130 | 131 | # print('TAB AFTER', tab_after) 132 | # print('TAB AFTER LINE', tab_after.line) 133 | # print('KEY', key) 134 | # 135 | # from pprint import pprint 136 | # print('tab_line_to_old_index') 137 | # pprint(tab_line_to_old_index) 138 | # 139 | # print('tab_id_title_url_to_index') 140 | # pprint(tab_id_title_url_to_index) 141 | # 142 | # print('partilally matching keys in tab_line_to_old_index') 143 | # for line in tab_line_to_old_index: 144 | # if tab_after.title in line: 145 | # print('> %s' % line) 146 | 147 | # We're out of ideas... 148 | raise KeyError('Could not find line: "%s"' % tab_after.line) 149 | 150 | 151 | def infer_move_commands(tabs_before: [Tab], tabs_after: [Tab]): 152 | """ 153 | `tabs_before` and `tabs_after` contain an integer in the beginning 154 | but that's a tab ID, not a position. Thus, a move command means: 155 | 156 | move 157 | 158 | where is an index within a browser window. Consider this: 159 | 160 | Before: After: 161 | f.4\ta f.8\ta 162 | f.8\ta f.4\ta 163 | f.1\aa f.1\ta 164 | 165 | The corresponding move commands: 166 | 167 | move f.8 0 168 | 169 | """ 170 | # Remember which tab corresponds to which index in the old list 171 | line_to_index = LineToIndexMapper(tabs_before) 172 | # XXX: use tab ID + title as the key 173 | tab_id_title_to_index = TabIdTitleToIndexMapper(tabs_before) 174 | 175 | # Now see how indices have been reordered by user 176 | # reordered_indices = [line_to_index[tab.line] for tab in tabs_after] 177 | reordered_indices = [_get_old_index(tab_after, 178 | line_to_index, 179 | tab_id_title_to_index, 180 | tabs_before) 181 | for tab_after in tabs_after] 182 | # These indices are in correct order, we should not touch them 183 | correctly_ordered_new_indices = set( 184 | get_longest_increasing_subsequence(reordered_indices)) 185 | 186 | upward, downward = [], [] 187 | 188 | # print('reordered_indices', reordered_indices) 189 | # print('tab_id_title_to_index', tab_id_title_url_to_old_index) 190 | 191 | for new_index, old_index in enumerate(reordered_indices): 192 | tab_before = tabs_before[old_index] 193 | tab_after = tabs_after[new_index] 194 | 195 | index_changed = old_index not in correctly_ordered_new_indices 196 | window_changed = tab_before.window_id != tab_after.window_id 197 | 198 | if index_changed or window_changed: 199 | triplet = (tab_before.tab_id, tab_after.window_id, new_index) 200 | upward.append(triplet) if new_index > old_index else downward.append(triplet) 201 | commands = downward + list(reversed(upward)) 202 | return commands 203 | 204 | 205 | def make_update(tabId=None, 206 | active=None, 207 | autoDiscardable=None, 208 | highlighted=None, 209 | muted=None, 210 | pinned=None, 211 | url=None, 212 | openerTabId=None): 213 | if tabId is None: raise ValueError('tabId is not specified') 214 | op = {'tab_id': tabId, 'properties': {}} 215 | if active is not None: op['properties']['active'] = active 216 | if autoDiscardable is not None: op['properties']['autoDiscardable'] = autoDiscardable 217 | if highlighted is not None: op['properties']['highlighted'] = highlighted 218 | if muted is not None: op['properties']['muted'] = muted 219 | if pinned is not None: op['properties']['pinned'] = pinned 220 | if url is not None: op['properties']['url'] = url 221 | if openerTabId is not None: op['properties']['openerTabId'] = openerTabId 222 | return op 223 | 224 | 225 | def infer_update_commands(tabs_before: [Tab], tabs_after: [Tab]): 226 | updates = [] 227 | for tab_before, tab_after in zip(tabs_before, tabs_after): 228 | if tab_before.url != tab_after.url: 229 | updates.append(make_update(tabId=tab_after.tab_id, url=tab_after.url)) 230 | return updates 231 | 232 | 233 | def apply_delete_commands(tabs_before: [Tab], delete_commands): 234 | tabs = tabs_before[:] 235 | # for tab_id in delete_commands: 236 | for delete_command in delete_commands: 237 | prefix, window_id, tab_id = delete_command.split('.') 238 | window_id, tab_id = int(window_id), int(tab_id) 239 | # tab_id = int(command.split()[1]) 240 | del tabs[_get_index_by_tab_id(tab_id, tabs)] 241 | return tabs 242 | 243 | 244 | def apply_move_commands(tabs_before: [Tab], move_commands): 245 | tabs = tabs_before[:] 246 | for tab_id, window_id, index_to in move_commands: 247 | index_from = _get_index_by_tab_id(tab_id, tabs) 248 | tab = tabs.pop(index_from) 249 | # XXX: should we update the window_id? 250 | tab.window_id = window_id 251 | tabs.insert(index_to, tab) 252 | return tabs 253 | 254 | 255 | def apply_update_commands(tabs_before: [Tab], update_commands): 256 | tabs = tabs_before[:] 257 | for command in update_commands: 258 | tab_id = command['tab_id'] 259 | index = _get_index_by_tab_id(tab_id, tabs) 260 | tabs[index].url = command['properties']['url'] 261 | return tabs 262 | 263 | 264 | def infer_all_commands(tabs_before: [Tab], tabs_after: [Tab]): 265 | """ 266 | This command takes browser tabs before the edit and after the edit and 267 | infers a sequence of commands that need to be executed in a browser 268 | to make transform state from `tabs_before` to `tabs_after`. 269 | 270 | Sample input: 271 | f.0.0 GMail 272 | f.0.1 Posix man 273 | f.0.2 news 274 | 275 | Sample output: 276 | m 0 5,m 1 1,d 2 277 | Means: 278 | move 0 to index 5, 279 | move 1 to index 1, 280 | delete 2 281 | 282 | Note that after moves and deletes, indices do not need to be adjusted on the 283 | browser side. All the indices are calculated by the client program so that 284 | the JS extension can simply execute the commands without thinking. 285 | """ 286 | # For now, let's work only within chunks of tabs grouped by 287 | # the windowId, i.e. moves between windows of the same browser are not 288 | # supported yet. 289 | delete_commands, move_commands, update_commands = [], [], [] 290 | # for _window_id, chunk_before, chunk_after in iter_window_tabs(tabs_before, tabs_after): 291 | # delete_commands.extend(infer_delete_commands(chunk_before, chunk_after)) 292 | # chunk_before = apply_delete_commands(chunk_before, delete_commands) 293 | # move_commands.extend(infer_move_commands(chunk_before, chunk_after)) 294 | 295 | # I've added moves across different existing (!) windows of the same 296 | # browser to iter_window_tabs is not used. 297 | 298 | # [_] detect a move to a new nonexistent window 299 | 300 | delete_commands.extend(infer_delete_commands(tabs_before, tabs_after)) 301 | tabs_before = apply_delete_commands(tabs_before, delete_commands) 302 | move_commands.extend(infer_move_commands(tabs_before, tabs_after)) 303 | tabs_before = apply_move_commands(tabs_before, move_commands) 304 | update_commands.extend(infer_update_commands(tabs_before, tabs_after)) 305 | 306 | return delete_commands, move_commands, update_commands 307 | -------------------------------------------------------------------------------- /brotab/parallel.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from asyncio import get_event_loop, gather 3 | 4 | 5 | def call_parallel(functions): 6 | """ 7 | Call functions in multiple threads. 8 | 9 | Create a pool of thread as large as the number of functions. 10 | Functions should accept no parameters (wrap then with partial or lambda). 11 | """ 12 | loop = get_event_loop() 13 | executor = ThreadPoolExecutor(max_workers=len(functions)) 14 | 15 | try: 16 | tasks = [ 17 | loop.run_in_executor(executor, function) 18 | for function in functions 19 | ] 20 | result = loop.run_until_complete(gather(*tasks)) 21 | 22 | finally: 23 | loop.close() 24 | 25 | return result 26 | -------------------------------------------------------------------------------- /brotab/platform.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import platform 4 | 5 | 6 | logger = logging.getLogger('brotab') 7 | 8 | 9 | def make_windows_path(path): 10 | return path.replace('/', os.sep) 11 | 12 | 13 | def make_windows_path_double_sep(path): 14 | return path.replace('/', os.sep * 2).replace('\\', os.sep * 2) 15 | 16 | 17 | def windows_registry_set_key(key_path, value): 18 | from winreg import CreateKey, SetValue, HKEY_CURRENT_USER, REG_SZ 19 | with CreateKey(HKEY_CURRENT_USER, key_path) as sub_key: 20 | SetValue(sub_key, None, REG_SZ, value) 21 | 22 | 23 | def register_native_manifest_windows_chrome(manifest_filename): 24 | key_path = r'Software\Google\Chrome\NativeMessagingHosts\brotab_mediator' 25 | manifest_filename = make_windows_path(manifest_filename) 26 | logger.info('Setting registry key "%s" to "%s"', key_path, manifest_filename) 27 | print('Setting registry key "%s" to "%s"' % (key_path, manifest_filename)) 28 | windows_registry_set_key(key_path, manifest_filename) 29 | 30 | def register_native_manifest_windows_brave(manifest_filename): 31 | key_path = r'Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\brotab_mediator' 32 | manifest_filename = make_windows_path(manifest_filename) 33 | logger.info('Setting registry key "%s" to "%s"', key_path, manifest_filename) 34 | print('Setting registry key "%s" to "%s"' % (key_path, manifest_filename)) 35 | windows_registry_set_key(key_path, manifest_filename) 36 | 37 | 38 | def register_native_manifest_windows_firefox(manifest_filename): 39 | key_path = r'Software\Mozilla\NativeMessagingHosts\brotab_mediator' 40 | manifest_filename = make_windows_path(manifest_filename) 41 | logger.info('Setting registry key "%s" to "%s"', key_path, manifest_filename) 42 | print('Setting registry key "%s" to "%s"' % (key_path, manifest_filename)) 43 | windows_registry_set_key(key_path, manifest_filename) 44 | 45 | 46 | def is_windows() -> bool: 47 | return platform.system() == 'Windows' 48 | 49 | 50 | def get_editor() -> str: 51 | mapping = { 52 | 'Windows': 'notepad' 53 | } 54 | system = platform.system() 55 | editor = mapping.get(system, 'nvim') 56 | return os.environ.get('EDITOR', editor) 57 | -------------------------------------------------------------------------------- /brotab/search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/search/__init__.py -------------------------------------------------------------------------------- /brotab/search/index.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains helpers to work with an index of text from a browser. 3 | """ 4 | import argparse 5 | import csv 6 | import ctypes 7 | import logging 8 | import sqlite3 9 | import sys 10 | from contextlib import suppress 11 | 12 | MAX_FIELD_LEN = 131072 13 | 14 | 15 | logger = logging.getLogger('brotab') 16 | 17 | 18 | def index(sqlite_filename, tsv_filename): 19 | logger.info('Reading tsv file %s', tsv_filename) 20 | # https://stackoverflow.com/questions/15063936/csv-error-field-larger-than-field-limit-131072 21 | # https://github.com/balta2ar/brotab/issues/25 22 | # It should work on Python 3 and Python 2, on any CPU / OS. 23 | csv.field_size_limit(int(ctypes.c_ulong(-1).value // 2)) 24 | 25 | with open(tsv_filename, encoding='utf-8') as tsv_file: 26 | lines = [tuple(line) for line in csv.reader(tsv_file, delimiter='\t', 27 | quoting=csv.QUOTE_NONE)] 28 | 29 | logger.info( 30 | 'Creating sqlite DB filename %s from tsv %s (%s lines)', 31 | sqlite_filename, tsv_filename, len(lines)) 32 | conn = sqlite3.connect(sqlite_filename) 33 | cursor = conn.cursor() 34 | with suppress(sqlite3.OperationalError): 35 | cursor.execute('drop table tabs;') 36 | cursor.execute( 37 | 'create virtual table tabs using fts5(' 38 | ' tab_id, title, url, body, tokenize="porter unicode61");') 39 | cursor.executemany('insert into tabs values (?, ?, ?, ?)', lines) 40 | conn.commit() 41 | conn.close() 42 | 43 | 44 | def main(): 45 | parser = argparse.ArgumentParser(description='Index text from tabs') 46 | parser.add_argument('sqlite', help='output sqlite DB file name') 47 | parser.add_argument('tsv', help='input tsv file name') 48 | args = parser.parse_args() 49 | 50 | index(args.sqlite, args.tsv) 51 | 52 | 53 | if __name__ == '__main__': 54 | main() 55 | -------------------------------------------------------------------------------- /brotab/search/init.sql: -------------------------------------------------------------------------------- 1 | create virtual table tabs using fts5(tab_id, title, url, body); 2 | .mode tabs 3 | .import tabs.tsv tabs 4 | -------------------------------------------------------------------------------- /brotab/search/query.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains helpers that query the indexed database of text from a 3 | browser. 4 | """ 5 | 6 | import argparse 7 | import sqlite3 8 | import logging 9 | from collections import namedtuple 10 | 11 | logger = logging.getLogger('brotab') 12 | 13 | QueryResult = namedtuple('QueryResult', 'tab_id title snippet') 14 | 15 | 16 | def query(sqlite_filename, user_query, 17 | text_column_index=3, 18 | max_tokens=30, 19 | max_results=10, 20 | marker_start='', 21 | marker_end='', 22 | marker_cut='...'): 23 | logger.info('Executing sqlite db %s query "%s"', 24 | sqlite_filename, user_query) 25 | conn = sqlite3.connect(sqlite_filename) 26 | cursor = conn.cursor() 27 | query = """ 28 | select 29 | rank, 30 | tab_id, 31 | title, 32 | snippet(tabs, 33 | {text_column_index}, 34 | '{marker_start}', 35 | '{marker_end}', 36 | '{marker_cut}', 37 | {max_tokens}) body 38 | from tabs where tabs match ? order by rank limit {max_results}; 39 | """.format_map(locals()) 40 | results = [] 41 | try: 42 | for (_rank, tab_id, title, snippet) in cursor.execute(query, (user_query,)): 43 | results.append(QueryResult(tab_id, title, snippet)) 44 | except sqlite3.OperationalError as e: 45 | logger.exception('Error: %s', e) 46 | 47 | conn.close() 48 | return results 49 | 50 | 51 | def main(): 52 | parser = argparse.ArgumentParser(description='Query text DB') 53 | parser.add_argument('sqlite', help='sqlite DB filename') 54 | parser.add_argument('query', help='sqlite query') 55 | args = parser.parse_args() 56 | 57 | for result in query(args.sqlite, args.query): 58 | print('\t'.join([result.tab_id, result.title, result.snippet])) 59 | 60 | 61 | if __name__ == '__main__': 62 | main() 63 | -------------------------------------------------------------------------------- /brotab/tab.py: -------------------------------------------------------------------------------- 1 | class Tab: 2 | def __init__(self, prefix, window_id, tab_id, title, url): 3 | self.prefix = prefix 4 | self.window_id = window_id 5 | self.tab_id = tab_id 6 | self.title = title 7 | self.url = url 8 | 9 | @property 10 | def id(self): 11 | return '{prefix}.{window_id}.{tab_id}'.format( 12 | prefix=self.prefix, 13 | window_id=self.window_id, 14 | tab_id=self.tab_id, 15 | ) 16 | 17 | @property 18 | def line(self): 19 | return '{prefix}.{window_id}.{tab_id}\t{title}\t{url}'.format( 20 | prefix=self.prefix, 21 | window_id=self.window_id, 22 | tab_id=self.tab_id, 23 | title=self.title, 24 | url=self.url 25 | ) 26 | 27 | def __eq__(self, other): 28 | return hash(self) == hash(other) 29 | 30 | def __hash__(self): 31 | return hash(self.line) 32 | 33 | def __repr__(self): 34 | return self.line 35 | 36 | @staticmethod 37 | def from_line(line): 38 | ids, title, url = line.split('\t') 39 | prefix, window_id, tab_id = ids.split('.') 40 | return Tab(prefix, int(window_id), int(tab_id), title, url) 41 | 42 | 43 | def parse_tab_lines(tab_lines): 44 | return [Tab.from_line(line) for line in tab_lines] 45 | 46 | 47 | def iter_window_tabs(left: [Tab], right: [Tab]): 48 | # get_window_id = attrgetter('window_id') 49 | # left = sorted(left, key=get_window_id) 50 | # right = sorted(right, key=get_window_id) 51 | # groupby(left, get_window_id) 52 | 53 | all_window_ids = set(tab.window_id for tab in left + right) 54 | for window_id in all_window_ids: 55 | matching_left = list(filter(lambda x: x.window_id == window_id, left)) 56 | matching_right = list(filter(lambda x: x.window_id == window_id, right)) 57 | yield window_id, matching_left, matching_right 58 | -------------------------------------------------------------------------------- /brotab/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balta2ar/brotab/6c71e1013972b0d73f29e92239a695526e2e9c58/brotab/tests/__init__.py -------------------------------------------------------------------------------- /brotab/tests/mocks.py: -------------------------------------------------------------------------------- 1 | class BrowserPortMock: 2 | pass 3 | -------------------------------------------------------------------------------- /brotab/tests/test_brotab.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from brotab.operations import apply_move_commands 4 | from brotab.operations import apply_update_commands 5 | from brotab.operations import infer_all_commands 6 | from brotab.operations import infer_move_commands 7 | from brotab.tab import parse_tab_lines 8 | 9 | 10 | # class TestLIS(TestCase): 11 | # def test_one(self): 12 | # seq = [1, 3, 0, 5, 6, 4, 7, 2, 0] 13 | # result = get_longest_increasing_subsequence(seq) 14 | # self.assertEqual([1, 3, 5, 6, 7], result) 15 | 16 | 17 | class TestReconstruct(TestCase): 18 | def test_move_one_from_start_to_end(self): 19 | before = parse_tab_lines([ 20 | 'f.0.0\ta\turl', 21 | 'f.0.1\ta\turl', 22 | 'f.0.2\ta\turl', 23 | 'f.0.3\ta\turl', 24 | 'f.0.4\ta\turl', 25 | ]) 26 | after = parse_tab_lines([ 27 | 'f.0.1\ta\turl', 28 | 'f.0.2\ta\turl', 29 | 'f.0.3\ta\turl', 30 | 'f.0.4\ta\turl', 31 | 'f.0.0\ta\turl', 32 | ]) 33 | commands = infer_move_commands(before, after) 34 | self.assertEqual(commands, [(0, 0, 4)]) 35 | actual_after = apply_move_commands(before, commands) 36 | self.assertEqual(actual_after, after) 37 | 38 | def test_move_one_from_end_to_start(self): 39 | before = parse_tab_lines([ 40 | 'f.0.0\ta\turl', 41 | 'f.0.1\ta\turl', 42 | 'f.0.2\ta\turl', 43 | 'f.0.3\ta\turl', 44 | 'f.0.4\ta\turl', 45 | ]) 46 | after = parse_tab_lines([ 47 | 'f.0.4\ta\turl', 48 | 'f.0.0\ta\turl', 49 | 'f.0.1\ta\turl', 50 | 'f.0.2\ta\turl', 51 | 'f.0.3\ta\turl', 52 | ]) 53 | commands = infer_move_commands(before, after) 54 | self.assertEqual(commands, [(4, 0, 0)]) 55 | actual_after = apply_move_commands(before, commands) 56 | self.assertEqual(actual_after, after) 57 | 58 | def test_move_one_from_start_to_center(self): 59 | before = parse_tab_lines([ 60 | 'f.0.0\ta\turl', 61 | 'f.0.1\ta\turl', 62 | 'f.0.2\ta\turl', 63 | 'f.0.3\ta\turl', 64 | 'f.0.4\ta\turl', 65 | 'f.0.5\ta\turl', 66 | 'f.0.6\ta\turl', 67 | 'f.0.7\ta\turl', 68 | 'f.0.8\ta\turl', 69 | ]) 70 | after = parse_tab_lines([ 71 | 'f.0.1\ta\turl', 72 | 'f.0.2\ta\turl', 73 | 'f.0.3\ta\turl', 74 | 'f.0.4\ta\turl', 75 | 'f.0.0\ta\turl', 76 | 'f.0.5\ta\turl', 77 | 'f.0.6\ta\turl', 78 | 'f.0.7\ta\turl', 79 | 'f.0.8\ta\turl', 80 | ]) 81 | commands = infer_move_commands(before, after) 82 | self.assertEqual(commands, [(0, 0, 4)]) 83 | actual_after = apply_move_commands(before, commands) 84 | self.assertEqual(actual_after, after) 85 | 86 | def test_crossings(self): 87 | before = parse_tab_lines([ 88 | 'f.0.0\ta\turl', 89 | 'f.0.1\ta\turl', 90 | 'f.0.2\ta\turl', 91 | 'f.0.3\ta\turl', 92 | 'f.0.4\ta\turl', 93 | 'f.0.5\ta\turl', 94 | 'f.0.6\ta\turl', 95 | 'f.0.7\ta\turl', 96 | 'f.0.8\ta\turl', 97 | ]) 98 | after = parse_tab_lines([ 99 | 'f.0.4\ta\turl', 100 | 'f.0.0\ta\turl', 101 | 'f.0.1\ta\turl', 102 | 'f.0.2\ta\turl', 103 | 'f.0.3\ta\turl', 104 | 'f.0.6\ta\turl', 105 | 'f.0.7\ta\turl', 106 | 'f.0.8\ta\turl', 107 | 'f.0.5\ta\turl', 108 | ]) 109 | commands = infer_move_commands(before, after) 110 | self.assertEqual(commands, [(4, 0, 0), (5, 0, 8)]) 111 | actual_after = apply_move_commands(before, commands) 112 | self.assertEqual(actual_after, after) 113 | 114 | def test_decreasing_ids_from_start_to_end(self): 115 | before = parse_tab_lines([ 116 | 'f.0.10\ta\turl', 117 | 'f.0.9\ta\turl', 118 | 'f.0.8\ta\turl', 119 | 'f.0.7\ta\turl', 120 | ]) 121 | after = parse_tab_lines([ 122 | 'f.0.9\ta\turl', 123 | 'f.0.8\ta\turl', 124 | 'f.0.7\ta\turl', 125 | 'f.0.10\ta\turl', 126 | ]) 127 | commands = infer_move_commands(before, after) 128 | self.assertEqual(commands, [(10, 0, 3)]) 129 | actual_after = apply_move_commands(before, commands) 130 | self.assertEqual(actual_after, after) 131 | 132 | def test_move_pair_from_start_to_end(self): 133 | before = parse_tab_lines([ 134 | 'f.0.0\ta\turl', 135 | 'f.0.1\ta\turl', 136 | 'f.0.2\ta\turl', 137 | 'f.0.3\ta\turl', 138 | 'f.0.4\ta\turl', 139 | ]) 140 | after = parse_tab_lines([ 141 | 'f.0.2\ta\turl', 142 | 'f.0.3\ta\turl', 143 | 'f.0.4\ta\turl', 144 | 'f.0.0\ta\turl', 145 | 'f.0.1\ta\turl', 146 | ]) 147 | commands = infer_move_commands(before, after) 148 | self.assertEqual(commands, [(1, 0, 4), (0, 0, 3)]) 149 | actual_after = apply_move_commands(before, commands) 150 | self.assertEqual(actual_after, after) 151 | 152 | def test_move_pair_from_end_to_start(self): 153 | before = parse_tab_lines([ 154 | 'f.0.0\ta\turl', 155 | 'f.0.1\ta\turl', 156 | 'f.0.2\ta\turl', 157 | 'f.0.3\ta\turl', 158 | 'f.0.4\ta\turl', 159 | ]) 160 | after = parse_tab_lines([ 161 | 'f.0.3\ta\turl', 162 | 'f.0.4\ta\turl', 163 | 'f.0.0\ta\turl', 164 | 'f.0.1\ta\turl', 165 | 'f.0.2\ta\turl', 166 | ]) 167 | commands = infer_move_commands(before, after) 168 | # self.assertEqual(commands, [(1, 0, 4), (0, 0, 3)]) 169 | actual_after = apply_move_commands(before, commands) 170 | self.assertEqual(actual_after, after) 171 | 172 | def test_move_several_upwards(self): 173 | before = parse_tab_lines([ 174 | 'f.0.0\ta\turl', 175 | 'f.0.1\ta\turl', 176 | 'f.0.2\ta\turl', 177 | 'f.0.3\ta\turl', 178 | 'f.0.4\ta\turl', 179 | 'f.0.5\ta\turl', 180 | 'f.0.6\ta\turl', 181 | ]) 182 | after = parse_tab_lines([ 183 | 'f.0.1\ta\turl', 184 | 'f.0.3\ta\turl', 185 | 'f.0.5\ta\turl', 186 | 'f.0.6\ta\turl', 187 | 'f.0.0\ta\turl', 188 | 'f.0.2\ta\turl', 189 | 'f.0.4\ta\turl', 190 | ]) 191 | commands = infer_move_commands(before, after) 192 | # self.assertEqual(commands, [(1, 0, 4), (0, 0, 3)]) 193 | actual_after = apply_move_commands(before, commands) 194 | self.assertEqual(actual_after, after) 195 | 196 | def test_move_several_downwards(self): 197 | before = parse_tab_lines([ 198 | 'f.0.0\ta\turl', 199 | 'f.0.1\ta\turl', 200 | 'f.0.2\ta\turl', 201 | 'f.0.3\ta\turl', 202 | 'f.0.4\ta\turl', 203 | 'f.0.5\ta\turl', 204 | 'f.0.6\ta\turl', 205 | ]) 206 | after = parse_tab_lines([ 207 | 'f.0.2\ta\turl', 208 | 'f.0.4\ta\turl', 209 | 'f.0.6\ta\turl', 210 | 'f.0.0\ta\turl', 211 | 'f.0.1\ta\turl', 212 | 'f.0.3\ta\turl', 213 | 'f.0.5\ta\turl', 214 | ]) 215 | commands = infer_move_commands(before, after) 216 | actual_after = apply_move_commands(before, commands) 217 | self.assertEqual(actual_after, after) 218 | 219 | def test_move_one_to_another_existing_window_same_index(self): 220 | before = parse_tab_lines([ 221 | 'f.0.0\ta\turl', 222 | 'f.1.1\ta\turl', 223 | ]) 224 | after = parse_tab_lines([ 225 | 'f.1.0\ta\turl', 226 | 'f.1.1\ta\turl', 227 | ]) 228 | delete_commands, move_commands, update_commands = infer_all_commands(before, after) 229 | self.assertGreater(len(move_commands), 0) 230 | actual_after = apply_move_commands(before, move_commands) 231 | self.assertEqual(actual_after, after) 232 | 233 | def test_move_one_to_another_existing_window_below(self): 234 | before = parse_tab_lines([ 235 | 'f.0.0\ta1\turl1', 236 | 'f.1.1\ta2\turl2', 237 | 'f.1.2\ta3\turl3', 238 | ]) 239 | after = parse_tab_lines([ 240 | 'f.1.1\ta2\turl2', 241 | 'f.1.2\ta3\turl3', 242 | 'f.1.0\ta1\turl1', 243 | ]) 244 | delete_commands, move_commands, update_commands = infer_all_commands(before, after) 245 | self.assertGreater(len(move_commands), 0) 246 | actual_after = apply_move_commands(before, move_commands) 247 | self.assertEqual(actual_after, after) 248 | 249 | def test_move_one_to_another_existing_window_above(self): 250 | before = parse_tab_lines([ 251 | 'f.0.0\ta1\turl1', 252 | 'f.1.1\ta2\turl2', 253 | 'f.1.2\ta3\turl3', 254 | ]) 255 | after = parse_tab_lines([ 256 | 'f.0.2\ta3\turl3', 257 | 'f.0.0\ta1\turl1', 258 | 'f.1.1\ta2\turl2', 259 | ]) 260 | delete_commands, move_commands, update_commands = infer_all_commands(before, after) 261 | self.assertGreater(len(move_commands), 0) 262 | actual_after = apply_move_commands(before, move_commands) 263 | self.assertEqual(actual_after, after) 264 | 265 | def test_move_one_to_another_existing_window_above_2(self): 266 | before = parse_tab_lines([ 267 | 'f.0.0\ta1\turl1', 268 | 'f.0.1\ta2\turl2', 269 | 'f.1.2\ta3\turl3', 270 | 'f.1.3\ta4\turl4', 271 | ]) 272 | after = parse_tab_lines([ 273 | 'f.0.0\ta1\turl1', 274 | 'f.0.1\ta2\turl2', 275 | 'f.0.3\ta4\turl4', 276 | 'f.1.2\ta3\turl3', 277 | ]) 278 | delete_commands, move_commands, update_commands = infer_all_commands(before, after) 279 | self.assertGreater(len(move_commands), 0) 280 | actual_after = apply_move_commands(before, move_commands) 281 | self.assertEqual(actual_after, after) 282 | 283 | def test_move_one_to_another_existing_window_several_mix(self): 284 | before = parse_tab_lines([ 285 | 'f.0.0\ta1\turl1', 286 | 'f.0.1\ta2\turl2', 287 | 'f.0.2\ta3\turl3', 288 | 'f.1.3\ta3\turl4', 289 | 'f.1.4\ta3\turl5', 290 | 'f.1.5\ta3\turl6', 291 | ]) 292 | after = parse_tab_lines([ 293 | 'f.0.5\ta3\turl6', 294 | 'f.0.2\ta3\turl3', 295 | 'f.1.3\ta3\turl4', 296 | 'f.1.4\ta3\turl5', 297 | 'f.1.0\ta1\turl1', 298 | 'f.1.1\ta2\turl2', 299 | ]) 300 | delete_commands, move_commands, update_commands = infer_all_commands(before, after) 301 | self.assertGreater(len(move_commands), 0) 302 | actual_after = apply_move_commands(before, move_commands) 303 | self.assertEqual(actual_after, after) 304 | 305 | # def test_move_one_to_another_existing_window_fixture_test1(self): 306 | # before = parse_tab_lines(slurp_lines('tests/fixtures/move_to_another_window_test1_before.txt')) 307 | # after = parse_tab_lines(slurp_lines('tests/fixtures/move_to_another_window_test1_after.txt')) 308 | # delete_commands, move_commands = infer_delete_and_move_commands(before, after) 309 | # print('COMMANDS', move_commands) 310 | # self.assertGreater(len(move_commands), 0) 311 | # actual_after = apply_move_commands(before, move_commands) 312 | # self.assertEqual(actual_after, after) 313 | 314 | 315 | # class TestSequence(TestCase): 316 | # def test_get_longest_contiguous_increasing_sequence(self): 317 | # tabs = [ 318 | # 'f.0\tFirst', 319 | # 'f.1\tSecond', 320 | # 'f.2\tThird', 321 | # ] 322 | # result = get_longest_contiguous_increasing_sequence(tabs) 323 | # self.assertEqual(result, (0, 3)) 324 | 325 | # tabs = [ 326 | # 'f.4\te', 327 | # 'f.1\tb', 328 | # 'f.2\tc', 329 | # 'f.3\td', 330 | # 'f.0\ta', 331 | # ] 332 | # result = get_longest_contiguous_increasing_sequence(tabs) 333 | # self.assertEqual(result, (1, 3)) 334 | 335 | # tabs = [ 336 | # 'f.4\te', 337 | # 'f.1\tb', 338 | # 'f.2\tc', 339 | # 'f.3\td', 340 | # 'f.0\ta', 341 | # 'f.9\ta', 342 | # 'f.5\tz', 343 | # 'f.6\tz', 344 | # 'f.7\tz', 345 | # 'f.8\tz', 346 | # ] 347 | # result = get_longest_contiguous_increasing_sequence(tabs) 348 | # self.assertEqual(result, (6, 4)) 349 | 350 | # tabs = [ 351 | # 'f.9\te', 352 | # 'f.4\tz', 353 | # 'f.5\tz', 354 | # 'f.6\tz', 355 | # 'f.7\tz', 356 | # 'f.1\tb', 357 | # 'f.2\tc', 358 | # 'f.3\td', 359 | # 'f.0\ta', 360 | # ] 361 | # result = get_longest_contiguous_increasing_sequence(tabs) 362 | # self.assertEqual(result, (1, 4)) 363 | 364 | 365 | class TestInferDeleteMoveUpdateCommands(TestCase): 366 | def _eq(self, tabs_before, tabs_after, expected_deletes, expected_moves, expected_updates): 367 | deletes, moves, updates = infer_all_commands( 368 | parse_tab_lines(tabs_before), 369 | parse_tab_lines(tabs_after)) 370 | self.assertEqual(expected_deletes, deletes) 371 | self.assertEqual(expected_moves, moves) 372 | self.assertEqual(expected_updates, updates) 373 | 374 | def test_only_deletes(self): 375 | self._eq( 376 | ['f.0.0\ttitle\turl', 377 | 'f.0.1\ttitle\turl', 378 | 'f.0.2\ttitle\turl'], 379 | ['f.0.2\ttitle\turl'], 380 | ['f.0.1', 'f.0.0'], 381 | [], 382 | [] 383 | ) 384 | 385 | self._eq( 386 | ['f.0.0\ttitle\turl', 387 | 'f.0.1\ttitle\turl', 388 | 'f.0.2\ttitle\turl', 389 | 'f.0.3\ttitle\turl'], 390 | ['f.0.0\ttitle\turl', 391 | 'f.0.2\ttitle\turl'], 392 | ['f.0.3', 'f.0.1'], 393 | [], 394 | [] 395 | ) 396 | 397 | def test_only_moves(self): 398 | self._eq( 399 | [ 400 | 'f.0.0\ttitle\turl', 401 | 'f.0.1\ttitle\turl', 402 | 'f.0.2\ttitle\turl', 403 | ], 404 | [ 405 | 'f.0.2\ttitle\turl', 406 | 'f.0.0\ttitle\turl', 407 | 'f.0.1\ttitle\turl', 408 | ], 409 | [], 410 | [(2, 0, 0)], 411 | [] 412 | ) 413 | 414 | self._eq( 415 | [ 416 | 'f.0.0\ttitle\turl', 417 | 'f.0.1\ttitle\turl', 418 | 'f.0.2\ttitle\turl', 419 | ], 420 | [ 421 | 'f.0.2\ttitle\turl', 422 | 'f.0.1\ttitle\turl', 423 | 'f.0.0\ttitle\turl', 424 | ], 425 | [], 426 | [(2, 0, 0), (1, 0, 1)], 427 | [] 428 | ) 429 | 430 | 431 | class TestUpdate(TestCase): 432 | def test_update_only_url(self): 433 | before = parse_tab_lines([ 434 | 'f.0.0\ta\turl', 435 | ]) 436 | after = parse_tab_lines([ 437 | 'f.0.0\ta\turl_changed', 438 | ]) 439 | deletes, moves, updates = infer_all_commands(before, after) 440 | self.assertEqual([], deletes) 441 | self.assertEqual([], moves) 442 | 443 | actual_after = apply_update_commands(before, updates) 444 | self.assertEqual(actual_after, after) 445 | -------------------------------------------------------------------------------- /brotab/tests/test_inout.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from brotab.inout import edit_tabs_in_editor 6 | 7 | 8 | class TestEditor(TestCase): 9 | @patch('brotab.inout.run_editor') 10 | @patch('platform.system', side_effect=['Linux']) 11 | @patch('os.environ', new={}) 12 | def test_run_editor_linux(self, _system_mock, _run_editor_mock): 13 | assert ['1'] == edit_tabs_in_editor(['1']) 14 | editor, filename = _run_editor_mock.call_args[0] 15 | assert editor == 'nvim' 16 | assert not os.path.exists(filename) 17 | 18 | @patch('brotab.inout.run_editor') 19 | @patch('platform.system', side_effect=['Windows']) 20 | @patch('os.environ', new={}) 21 | def test_run_editor_windows(self, _system_mock, _run_editor_mock): 22 | assert ['1'] == edit_tabs_in_editor(['1']) 23 | editor, filename = _run_editor_mock.call_args[0] 24 | assert editor == 'notepad' 25 | assert not os.path.exists(filename) 26 | 27 | @patch('brotab.inout.run_editor') 28 | @patch('platform.system', side_effect=['Windows']) 29 | @patch('os.environ', new={'EDITOR': 'custom'}) 30 | def test_run_editor_windows_custom(self, _system_mock, _run_editor_mock): 31 | assert ['1'] == edit_tabs_in_editor(['1']) 32 | editor, filename = _run_editor_mock.call_args[0] 33 | assert editor == 'custom' 34 | assert not os.path.exists(filename) 35 | -------------------------------------------------------------------------------- /brotab/tests/test_integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import threading 4 | from http.server import BaseHTTPRequestHandler 5 | from http.server import HTTPServer 6 | from json import dumps 7 | from subprocess import Popen 8 | from subprocess import check_output 9 | from unittest import TestCase 10 | from urllib.parse import parse_qs 11 | from urllib.parse import urlparse 12 | 13 | import pytest 14 | 15 | from brotab.api import api_must_ready 16 | from brotab.env import min_http_port 17 | from brotab.inout import get_available_tcp_port 18 | from brotab.inout import wait_net_service 19 | from brotab.mediator.const import DEFAULT_MIN_HTTP_PORT 20 | from brotab.operations import make_update 21 | from brotab.tab import parse_tab_lines 22 | 23 | 24 | def run(args): 25 | return check_output(args, shell=True).decode('utf-8').strip().splitlines() 26 | 27 | 28 | def git_root(): 29 | return run(['git rev-parse --show-toplevel'])[0] 30 | 31 | 32 | def requires_integration_env(): 33 | value = os.environ.get('INTEGRATION_TEST') 34 | return pytest.mark.skipif( 35 | value is None, 36 | reason=f"Skipped because INTEGRATION_TEST=1 is not set" 37 | ) 38 | 39 | 40 | TIMEOUT = 60 # 15 41 | ECHO_SERVER_PORT = 8087 42 | 43 | 44 | class EchoRequestHandler(BaseHTTPRequestHandler): 45 | """ 46 | Sample URL: 47 | 48 | localhost:9000?title=tab1&body=tab1 49 | """ 50 | 51 | def _get_str_arg(self, path, arg_name): 52 | args = parse_qs(urlparse(path).query) 53 | return ''.join(args.get(arg_name, '')) 54 | 55 | def do_GET(self): 56 | title = self._get_str_arg(self.path, 'title') 57 | body = self._get_str_arg(self.path, 'body') 58 | print('EchoServer received TITLE "%s" BODY "%s"' % (title, body)) 59 | 60 | self.send_response(200) 61 | self.send_header("Content-Type", "text/html; charset=utf-8") 62 | reply = ('%s' 63 | '%s' 64 | % (title, body)).encode('utf-8') 65 | self.send_header("Content-Length", str(len(reply))) 66 | self.end_headers() 67 | self.wfile.write(reply) 68 | # self.wfile.close() 69 | 70 | 71 | ECHO_SERVER_HOST = 'localhost' 72 | ECHO_SERVER_PORT = 9000 73 | 74 | 75 | class EchoServer: 76 | """ 77 | This EchoServer is used to customize page title and content using URL 78 | parameters. 79 | """ 80 | 81 | def __init__(self): 82 | self._thread = None 83 | self._server = None 84 | 85 | def run(self, host=ECHO_SERVER_HOST, port=ECHO_SERVER_PORT): 86 | self._server = HTTPServer((host, port), EchoRequestHandler) 87 | self._thread = threading.Thread( 88 | target=self._server.serve_forever, daemon=True) 89 | self._thread.start() 90 | 91 | def stop(self): 92 | self._server.shutdown() 93 | self._server.socket.close() 94 | self._thread.join(TIMEOUT) 95 | 96 | @staticmethod 97 | def url(title='', body=''): 98 | return 'http://%s:%s?title=%s&body=%s' % ( 99 | ECHO_SERVER_HOST, ECHO_SERVER_PORT, title, body) 100 | 101 | 102 | class Brotab: 103 | def __init__(self, target_hosts: str): 104 | """ 105 | target_hosts: e.g. 'localhost:4625,localhost:4626' 106 | """ 107 | self.targets = target_hosts 108 | self.options = '--target %s' % self.targets if self.targets else '' 109 | 110 | def list(self): 111 | return run(f'bt {self.options} list') 112 | 113 | def tabs(self): 114 | return parse_tab_lines(self.list()) 115 | 116 | def open(self, window_id, url): 117 | return run(f'echo "{url}" | bt {self.options} open {window_id}') 118 | 119 | def active(self): 120 | return run(f'bt {self.options} active') 121 | 122 | def windows(self): 123 | return run(f'bt {self.options} windows') 124 | 125 | def navigate(self, tab_id, url): 126 | return run(f'bt {self.options} navigate {tab_id} "{url}"') 127 | 128 | def update_stdin(self, updates): 129 | updates = dumps(updates) 130 | return run(f'echo \'{updates}\' | bt {self.options} update') 131 | 132 | def update(self, args): 133 | return run(f'bt {self.options} update {args}') 134 | 135 | 136 | class Browser: 137 | CMD = '' 138 | CWD = '' 139 | PROFILE = '' 140 | 141 | def __init__(self): 142 | print('CMD', self.CMD, 'CWD', self.CWD) 143 | # Used a trick from here: https://stackoverflow.com/a/22582602/258421 144 | os.makedirs('/dev/shm/%s' % self.PROFILE, exist_ok=True) 145 | self._browser = Popen(self.CMD, shell=True, 146 | cwd=self.CWD, preexec_fn=os.setsid) 147 | print('PID', self._browser.pid) 148 | wait_net_service('localhost', min_http_port(), TIMEOUT) 149 | print('init done PID', self._browser.pid) 150 | 151 | def stop(self): 152 | os.killpg(os.getpgid(self._browser.pid), signal.SIGTERM) 153 | self._browser.wait(TIMEOUT) 154 | 155 | @property 156 | def pid(self): 157 | return self._browser.pid 158 | 159 | 160 | class Container: 161 | NAME = 'chrome/chromium' 162 | 163 | def __init__(self): 164 | root = git_root() 165 | self.guest_port = 4625 166 | self.host_port = get_available_tcp_port() 167 | display = os.environ.get('DISPLAY', ':0') 168 | args = ['docker', 'run', '-v', 169 | f'"{root}:/brotab"', 170 | # '-p', '19222:9222', 171 | '-p', f'{self.host_port}:{self.guest_port}', 172 | '--detach --rm --cpuset-cpus 0', 173 | '--memory 512mb -v /tmp/.X11-unix:/tmp/.X11-unix', 174 | f'-e DISPLAY=unix{display}', 175 | '-v /dev/shm:/dev/shm', 176 | 'brotab-integration'] 177 | cmd = ' '.join(args) 178 | self.container_id = run(cmd)[0] 179 | api_must_ready(self.host_port, self.NAME, 'a', client_timeout=3.0, startup_timeout=10.0) 180 | 181 | def stop(self): 182 | run(f'docker kill {self.container_id}') 183 | 184 | def __enter__(self): 185 | return self 186 | 187 | def __exit__(self, type_, value, tb): 188 | self.stop() 189 | 190 | @property 191 | def guest_addr(self): 192 | return f'localhost:{self.guest_port}' 193 | 194 | @property 195 | def host_addr(self): 196 | return f'localhost:{self.host_port}' 197 | 198 | def echo_url(self, title=None, body=None): 199 | url = f'http://{self.guest_addr}/echo?' 200 | url += 'title=' + title if title else '' 201 | url += '&body=' + body if body else '' 202 | return url 203 | 204 | 205 | def targets(containers: [Container]) -> str: 206 | return ','.join([c.host_addr for c in containers]) 207 | 208 | 209 | @requires_integration_env() 210 | class TestIntegration(TestCase): 211 | def test_open_single(self): 212 | with Container() as c: 213 | bt = Brotab(targets([c])) 214 | tabs = bt.list() 215 | assert 'tab1' not in ''.join(tabs) 216 | tab_ids = bt.open('a.1', c.echo_url('tab1')) 217 | assert len(tab_ids) == 1 218 | 219 | tabs = bt.list() 220 | assert 'tab1' in ''.join(tabs) 221 | assert tab_ids[0] in ''.join(tabs) 222 | 223 | def test_active_tabs(self): 224 | with Container() as c: 225 | bt = Brotab(targets([c])) 226 | bt.open('a.1', c.echo_url('tab1')) 227 | bt.open('a.1', c.echo_url('tab2')) 228 | bt.open('a.1', c.echo_url('tab3')) 229 | assert len(bt.tabs()) == 4 230 | active_id = bt.active()[0].split('\t')[0] 231 | assert active_id == bt.tabs()[-1].id 232 | 233 | def test_navigate_single(self): 234 | with Container() as c: 235 | bt = Brotab(targets([c])) 236 | tab_ids = bt.open('a.1', c.echo_url('tab1')) 237 | assert len(tab_ids) == 1 238 | 239 | tabs = bt.list() 240 | assert 'tab1' in ''.join(tabs) 241 | assert tab_ids[0] in ''.join(tabs) 242 | 243 | bt.navigate(tab_ids[0], c.echo_url('tab2')) 244 | tabs = bt.list() 245 | assert 'tab2' in ''.join(tabs) 246 | assert tab_ids[0] in ''.join(tabs) 247 | 248 | def test_update_three(self): 249 | with Container() as c: 250 | bt = Brotab(targets([c])) 251 | bt.open('a.1', c.echo_url('tab1')) 252 | bt.open('a.1', c.echo_url('tab1')) 253 | lines = sorted(bt.list()) 254 | assert len(lines) == 3 255 | assert 'tab2' not in ''.join(lines) 256 | 257 | tabs = parse_tab_lines(lines) 258 | bt.update_stdin([make_update(tabId=tabs[0].id, url=c.echo_url('tab2'))]) 259 | bt.update_stdin([make_update(tabId=tabs[1].id, url=c.echo_url('tab2'))]) 260 | bt.update('-tabId {0} -url="{1}"'.format(tabs[2].id, c.echo_url('tab2'))) 261 | 262 | lines = bt.list() 263 | assert 'tab1' not in ''.join(lines) 264 | assert 'tab2' in ''.join(lines) 265 | 266 | 267 | @pytest.mark.skip 268 | class TestChromium(TestCase): 269 | def setUp(self): 270 | self._echo_server = EchoServer() 271 | self._echo_server.run() 272 | self.addCleanup(self._echo_server.stop) 273 | 274 | self._browser = Chromium() 275 | # self._browser = Firefox() 276 | # self.addCleanup(self._browser.stop) 277 | print('SETUP DONE:', self._browser.pid) 278 | 279 | def tearDown(self): 280 | print('CHROME', self._browser) 281 | print('BLOCK DONE') 282 | 283 | def test_open_single(self): 284 | print('SINGLE START') 285 | 286 | tabs = Brotab.list() 287 | assert 'tab1' not in ''.join(tabs) 288 | Brotab.open('a.1', EchoServer.url('tab1')) 289 | 290 | tabs = Brotab.list() 291 | assert 'tab1' in ''.join(tabs) 292 | 293 | print('SINGLE END') 294 | 295 | def test_active_tabs(self): 296 | Brotab.open('a.1', EchoServer.url('tab1')) 297 | Brotab.open('a.2', EchoServer.url('tab2')) 298 | Brotab.open('a.3', EchoServer.url('tab3')) 299 | assert len(Brotab.tabs()) == 4 300 | assert Brotab.active()[0] == Brotab.tabs()[-1].id 301 | 302 | 303 | if __name__ == '__main__': 304 | server = EchoServer() 305 | server.run(ECHO_SERVER_HOST, ECHO_SERVER_PORT) 306 | print('Running EchoServer at %s:%s. Press Enter to terminate' % ( 307 | ECHO_SERVER_HOST, ECHO_SERVER_PORT)) 308 | input() 309 | -------------------------------------------------------------------------------- /brotab/tests/test_main.py: -------------------------------------------------------------------------------- 1 | from string import ascii_letters 2 | from time import sleep 3 | from typing import List 4 | from unittest import TestCase 5 | from unittest.mock import patch 6 | from uuid import uuid4 7 | 8 | from brotab.api import SingleMediatorAPI 9 | from brotab.env import http_iface 10 | from brotab.env import min_http_port 11 | from brotab.files import in_temp_dir 12 | from brotab.files import spit 13 | from brotab.inout import get_available_tcp_port 14 | from brotab.main import create_clients 15 | from brotab.main import run_commands 16 | from brotab.mediator.http_server import MediatorHttpServer 17 | from brotab.mediator.remote_api import default_remote_api 18 | from brotab.mediator.transport import Transport 19 | from brotab.tests.utils import assert_file_absent 20 | from brotab.tests.utils import assert_file_contents 21 | from brotab.tests.utils import assert_file_not_empty 22 | from brotab.tests.utils import assert_sqlite3_table_contents 23 | 24 | 25 | class MockedLoggingTransport(Transport): 26 | def __init__(self): 27 | self._sent = [] 28 | self._received = [] 29 | 30 | def reset(self): 31 | self._sent = [] 32 | self._received = [] 33 | 34 | @property 35 | def sent(self): 36 | return self._sent 37 | 38 | @property 39 | def received(self): 40 | return self._received 41 | 42 | def received_extend(self, values) -> None: 43 | for value in values: 44 | self._received.append(value) 45 | 46 | def send(self, message) -> None: 47 | self._sent.append(message) 48 | 49 | def recv(self): 50 | if self._received: 51 | return self._received.pop(0) 52 | 53 | def close(self): 54 | pass 55 | 56 | 57 | class MockedMediator: 58 | def __init__(self, prefix='a', port=None, remote_api=None): 59 | self.port = get_available_tcp_port() if port is None else port 60 | self.transport = MockedLoggingTransport() 61 | self.remote_api = default_remote_api(self.transport) if remote_api is None else remote_api 62 | self.server = MediatorHttpServer(http_iface(), self.port, self.remote_api, 0.050) 63 | self.thread = self.server.run.in_thread() 64 | self.transport.received_extend(['mocked']) 65 | self.api = SingleMediatorAPI(prefix, port=self.port, startup_timeout=1) 66 | assert self.api.browser == 'mocked' 67 | self.transport.reset() 68 | 69 | def join(self): 70 | self.server.shutdown() 71 | self.thread.join() 72 | 73 | def __enter__(self): 74 | return self 75 | 76 | def __exit__(self, type_, value, tb): 77 | self.join() 78 | 79 | 80 | class DummyBrowserRemoteAPI: 81 | """ 82 | Dummy version of browser API for integration smoke tests. 83 | """ 84 | 85 | def list_tabs(self): 86 | return ['1.1\ttitle\turl'] 87 | 88 | def query_tabs(self, query_info: str): 89 | raise NotImplementedError() 90 | 91 | def move_tabs(self, move_triplets: str): 92 | raise NotImplementedError() 93 | 94 | def open_urls(self, urls: List[str], window_id=None): 95 | raise NotImplementedError() 96 | 97 | def close_tabs(self, tab_ids: str): 98 | raise NotImplementedError() 99 | 100 | def new_tab(self, query): 101 | raise NotImplementedError() 102 | 103 | def activate_tab(self, tab_id: int, focused: bool): 104 | raise NotImplementedError() 105 | 106 | def get_active_tabs(self) -> str: 107 | return '1.1' 108 | 109 | def get_words(self, tab_id, match_regex, join_with): 110 | return ['a', 'b'] 111 | 112 | def get_text(self, delimiter_regex, replace_with): 113 | return ['1.1\ttitle\turl\tbody'] 114 | 115 | def get_html(self, delimiter_regex, replace_with): 116 | return ['1.1\ttitle\turl\tsome body'] 117 | 118 | def get_browser(self): 119 | return 'mocked' 120 | 121 | 122 | def run_mocked_mediators(count, default_port_offset, delay): 123 | """ 124 | How to run: 125 | 126 | python -c 'from brotab.tests.test_main import run_mocked_mediators as run; run(3, 0, 0)' 127 | python -c 'from brotab.tests.test_main import run_mocked_mediators as run; run(count=3, default_port_offset=10, delay=0)' 128 | """ 129 | assert count > 0 130 | print('Creating %d mediators' % count) 131 | start_port = min_http_port() + default_port_offset 132 | ports = range(start_port, start_port + count) 133 | mediators = [MockedMediator(letter, port, DummyBrowserRemoteAPI()) 134 | for i, letter, port in zip(range(count), ascii_letters, ports)] 135 | sleep(delay) 136 | print('Ready') 137 | for mediator in mediators: 138 | print(mediator.port) 139 | mediators[0].thread.join() 140 | 141 | 142 | def run_mocked_mediator_current_thread(port): 143 | """ 144 | How to run: 145 | 146 | python -c 'from brotab.tests.test_main import run_mocked_mediator_current_thread as run; run(4635)' 147 | """ 148 | remote_api = DummyBrowserRemoteAPI() 149 | port = get_available_tcp_port() if port is None else port 150 | server = MediatorHttpServer(http_iface(), port, remote_api, 0.050) 151 | server.run.here() 152 | 153 | 154 | class WithMediator(TestCase): 155 | def setUp(self): 156 | self.mediator = MockedMediator('a') 157 | 158 | def tearDown(self): 159 | self.mediator.join() 160 | 161 | def _run_commands(self, commands): 162 | with patch('brotab.main.get_mediator_ports') as mocked: 163 | mocked.side_effect = [range(self.mediator.port, self.mediator.port + 1)] 164 | return run_commands(commands) 165 | 166 | def _assert_init(self): 167 | """Pop get_browser commands from the beginning until we have none.""" 168 | expected = {'name': 'get_browser'} 169 | popped = 0 170 | while self.mediator.transport.sent: 171 | if expected != self.mediator.transport.sent[0]: 172 | break 173 | self.mediator.transport.sent.pop(0) 174 | popped += 1 175 | assert popped > 0, 'Expected to pop at least one get_browser command' 176 | 177 | 178 | class TestCreateClients(WithMediator): 179 | def test_default_target_hosts(self): 180 | with patch('brotab.main.get_mediator_ports') as mocked: 181 | mocked.side_effect = [range(self.mediator.port, self.mediator.port + 1)] 182 | clients = create_clients() 183 | assert 1 == len(clients) 184 | assert self.mediator.port == clients[0]._port 185 | 186 | def test_one_custom_target_hosts(self): 187 | clients = create_clients('127.0.0.1:%d' % self.mediator.port) 188 | assert 1 == len(clients) 189 | assert self.mediator.port == clients[0]._port 190 | 191 | def test_two_custom_target_hosts(self): 192 | clients = create_clients('127.0.0.1:%d,localhost:%d' % 193 | (self.mediator.port, self.mediator.port)) 194 | assert 2 == len(clients) 195 | assert self.mediator.port == clients[0]._port 196 | assert self.mediator.port == clients[1]._port 197 | 198 | 199 | class TestActivate(WithMediator): 200 | def test_activate_ok(self): 201 | self._run_commands(['activate', 'a.1.2']) 202 | self._assert_init() 203 | assert self.mediator.transport.sent == [ 204 | {'name': 'activate_tab', 'tab_id': 2, 'focused': False} 205 | ] 206 | 207 | def test_activate_focused_ok(self): 208 | self._run_commands(['activate', '--focused', 'a.1.2']) 209 | self._assert_init() 210 | assert self.mediator.transport.sent == [ 211 | {'name': 'activate_tab', 'tab_id': 2, 'focused': True} 212 | ] 213 | 214 | 215 | class TestText(WithMediator): 216 | def test_text_no_arguments_ok(self): 217 | self.mediator.transport.received_extend([ 218 | 'mocked', 219 | ['1.1\ttitle\turl\tbody'], 220 | ]) 221 | 222 | output = [] 223 | with patch('brotab.main.stdout_buffer_write', output.append): 224 | self._run_commands(['text']) 225 | self._assert_init() 226 | assert self.mediator.transport.sent == [ 227 | {'delimiter_regex': '/\\n|\\r|\\t/g', 'name': 'get_text', 'replace_with': '" "'}, 228 | ] 229 | assert output == [b'a.1.1\ttitle\turl\tbody\n'] 230 | 231 | def test_text_with_tab_id_ok(self): 232 | self.mediator.transport.received_extend([ 233 | 'mocked', 234 | [ 235 | '1.1\ttitle\turl\tbody', 236 | '1.2\ttitle\turl\tbody', 237 | '1.3\ttitle\turl\tbody', 238 | ], 239 | ]) 240 | 241 | output = [] 242 | with patch('brotab.main.stdout_buffer_write', output.append): 243 | self._run_commands(['text', 'a.1.2', 'a.1.3']) 244 | self._assert_init() 245 | assert self.mediator.transport.sent == [ 246 | {'delimiter_regex': '/\\n|\\r|\\t/g', 'name': 'get_text', 'replace_with': '" "'}, 247 | ] 248 | assert output == [b'a.1.2\ttitle\turl\tbody\na.1.3\ttitle\turl\tbody\n'] 249 | 250 | 251 | class TestHtml(WithMediator): 252 | def test_html_no_arguments_ok(self): 253 | self.mediator.transport.received_extend([ 254 | 'mocked', 255 | ['1.1\ttitle\turl\tbody'], 256 | ]) 257 | 258 | output = [] 259 | with patch('brotab.main.stdout_buffer_write', output.append): 260 | self._run_commands(['html']) 261 | self._assert_init() 262 | assert self.mediator.transport.sent == [ 263 | {'delimiter_regex': '/\\n|\\r|\\t/g', 'name': 'get_html', 'replace_with': '" "'}, 264 | ] 265 | assert output == [b'a.1.1\ttitle\turl\tbody\n'] 266 | 267 | def test_html_with_tab_id_ok(self): 268 | self.mediator.transport.received_extend([ 269 | 'mocked', 270 | [ 271 | '1.1\ttitle\turl\tbody', 272 | '1.2\ttitle\turl\tbody', 273 | '1.3\ttitle\turl\tbody', 274 | ], 275 | ]) 276 | 277 | output = [] 278 | with patch('brotab.main.stdout_buffer_write', output.append): 279 | self._run_commands(['html', 'a.1.2', 'a.1.3']) 280 | self._assert_init() 281 | assert self.mediator.transport.sent == [ 282 | {'delimiter_regex': '/\\n|\\r|\\t/g', 'name': 'get_html', 'replace_with': '" "'}, 283 | ] 284 | assert output == [b'a.1.2\ttitle\turl\tbody\na.1.3\ttitle\turl\tbody\n'] 285 | 286 | 287 | class TestIndex(WithMediator): 288 | def test_index_no_arguments_ok(self): 289 | self.mediator.transport.received_extend([ 290 | 'mocked', 291 | ['1.1\ttitle\turl\tbody'], 292 | ]) 293 | 294 | sqlite_filename = in_temp_dir('tabs.sqlite') 295 | tsv_filename = in_temp_dir('tabs.tsv') 296 | assert_file_absent(sqlite_filename) 297 | assert_file_absent(tsv_filename) 298 | output = [] 299 | with patch('brotab.main.stdout_buffer_write', output.append): 300 | self._run_commands(['index']) 301 | self._assert_init() 302 | assert self.mediator.transport.sent == [ 303 | {'delimiter_regex': '/\\n|\\r|\\t/g', 304 | 'name': 'get_text', 'replace_with': '" "'}, 305 | ] 306 | assert_file_not_empty(sqlite_filename) 307 | assert_file_not_empty(tsv_filename) 308 | assert_file_contents(tsv_filename, 'a.1.1\ttitle\turl\tbody\n') 309 | assert_sqlite3_table_contents( 310 | sqlite_filename, 'tabs', 'a.1.1\ttitle\turl\tbody') 311 | 312 | def test_index_custom_filename(self): 313 | self.mediator.transport.received_extend([ 314 | 'mocked', 315 | ['1.1\ttitle\turl\tbody'], 316 | ]) 317 | 318 | sqlite_filename = in_temp_dir(uuid4().hex + '.sqlite') 319 | tsv_filename = in_temp_dir(uuid4().hex + '.tsv') 320 | assert_file_absent(sqlite_filename) 321 | assert_file_absent(tsv_filename) 322 | spit(tsv_filename, 'a.1.1\ttitle\turl\tbody\n') 323 | 324 | output = [] 325 | with patch('brotab.main.stdout_buffer_write', output.append): 326 | self._run_commands( 327 | ['index', '--sqlite', sqlite_filename, '--tsv', tsv_filename]) 328 | assert self.mediator.transport.sent == [] 329 | assert_file_not_empty(sqlite_filename) 330 | assert_file_not_empty(tsv_filename) 331 | assert_file_contents(tsv_filename, 'a.1.1\ttitle\turl\tbody\n') 332 | assert_sqlite3_table_contents( 333 | sqlite_filename, 'tabs', 'a.1.1\ttitle\turl\tbody') 334 | assert_file_absent(sqlite_filename) 335 | assert_file_absent(tsv_filename) 336 | 337 | 338 | class TestOpen(WithMediator): 339 | def test_three_urls_ok(self): 340 | self.mediator.transport.received_extend([ 341 | 'mocked', 342 | ['1.1', '1.2', '1.3'], 343 | ]) 344 | 345 | urls = ['url1', 'url2', 'url3'] 346 | output = [] 347 | with patch('brotab.main.stdout_buffer_write', output.append): 348 | with patch('brotab.main.read_stdin_lines', return_value=urls): 349 | self._run_commands(['open', 'a.1']) 350 | self._assert_init() 351 | assert self.mediator.transport.sent == [ 352 | {'name': 'open_urls', 'urls': ['url1', 'url2', 'url3'], 'window_id': 1}, 353 | ] 354 | assert output == [b'a.1.1\na.1.2\na.1.3\n'] 355 | -------------------------------------------------------------------------------- /brotab/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from brotab.utils import split_tab_ids 4 | 5 | 6 | class TestUtils(TestCase): 7 | def test_split_tab_ids(self): 8 | text = 'c.1.0 c.1.1\tc.1.2\r\nc.1.3 \r\t\n' 9 | expected = ['c.1.0', 'c.1.1', 'c.1.2', 'c.1.3'] 10 | self.assertEqual(expected, split_tab_ids(text)) 11 | -------------------------------------------------------------------------------- /brotab/tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | 4 | import psutil 5 | 6 | 7 | def assert_file_absent(filename): 8 | if os.path.isfile(filename): 9 | os.remove(filename) 10 | assert not os.path.isfile(filename) 11 | 12 | 13 | def assert_file_not_empty(filename): 14 | assert os.path.isfile(filename) 15 | assert os.path.getsize(filename) > 0 16 | 17 | 18 | def assert_file_contents(filename, expected_contents): 19 | with open(filename) as file_: 20 | actual_contents = file_.read() 21 | assert expected_contents == actual_contents, \ 22 | '"%s" != "%s"' % (expected_contents, actual_contents) 23 | 24 | 25 | def assert_sqlite3_table_contents(db_filename, table_name, expected_contents): 26 | conn = sqlite3.connect(db_filename) 27 | cursor = conn.cursor() 28 | cursor.execute('select * from %s' % (table_name,)) 29 | actual_contents = '\n'.join(['\t'.join(line) for line in cursor.fetchall()]) 30 | assert expected_contents == actual_contents, \ 31 | '"%s" != "%s"' % (expected_contents, actual_contents) 32 | 33 | 34 | def kill_by_substring(substring): 35 | mypid = os.getpid() 36 | for proc in psutil.process_iter(): 37 | line = ' '.join(proc.cmdline()) 38 | if substring in line and proc.pid != mypid: 39 | print('>', proc.name(), line) 40 | proc.kill() 41 | 42 | 43 | class TimeoutException(Exception): 44 | pass 45 | -------------------------------------------------------------------------------- /brotab/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shutil 3 | from base64 import urlsafe_b64decode 4 | from base64 import urlsafe_b64encode 5 | from os.path import expanduser 6 | from os.path import expandvars 7 | from os.path import getsize 8 | 9 | 10 | def split_tab_ids(string): 11 | items = re.split(r'[ \t\r\n]+', string) 12 | return list(filter(None, items)) 13 | 14 | 15 | def encode_query(string): 16 | return str(urlsafe_b64encode(string.encode('utf-8')), 'utf-8') 17 | 18 | 19 | def decode_query(string): 20 | return urlsafe_b64decode(string).decode('utf-8') 21 | 22 | 23 | def get_file_size(path): 24 | try: 25 | return getsize(path) 26 | except FileNotFoundError: 27 | return None 28 | 29 | 30 | def which(program): 31 | paths = [None, '/usr/local/bin', '/usr/bin', '/bin', '~/bin', '~/.local/bin'] 32 | for path in paths: 33 | path = expanduser(expandvars(path)) if path else None 34 | path = shutil.which(program, path=path) 35 | if path: 36 | return path 37 | -------------------------------------------------------------------------------- /brotab/wait.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | class Waiter: 5 | def __init__(self, condition): 6 | self.condition = condition 7 | 8 | def wait(self, timeout: float) -> bool: 9 | expires_at = time.time() + timeout 10 | while time.time() < expires_at: 11 | if self.condition(): 12 | return True 13 | time.sleep(0.050) 14 | return False 15 | 16 | 17 | class ConditionTrue: 18 | def __init__(self, f): 19 | self.f = f 20 | 21 | def __call__(self): 22 | return self.f() 23 | 24 | 25 | class ConditionRaises: 26 | def __init__(self, e): 27 | self.e = e 28 | 29 | def __call__(self, f) -> bool: 30 | try: 31 | f() 32 | except self.e as e: 33 | return True 34 | return False 35 | -------------------------------------------------------------------------------- /fastentrypoints.py: -------------------------------------------------------------------------------- 1 | # noqa: D300,D400 2 | # Copyright (c) 2016, Aaron Christianson 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are 7 | # met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 12 | # 2. Redistributions in binary form must reproduce the above copyright 13 | # notice, this list of conditions and the following disclaimer in the 14 | # documentation and/or other materials provided with the distribution. 15 | # 16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 17 | # IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 18 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 19 | # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | # HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 22 | # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | ''' 28 | Monkey patch setuptools to write faster console_scripts with this format: 29 | 30 | import sys 31 | from mymodule import entry_function 32 | sys.exit(entry_function()) 33 | 34 | This is better. 35 | 36 | (c) 2016, Aaron Christianson 37 | http://github.com/ninjaaron/fast-entry_points 38 | ''' 39 | from setuptools.command import easy_install 40 | import re 41 | TEMPLATE = '''\ 42 | # -*- coding: utf-8 -*- 43 | # EASY-INSTALL-ENTRY-SCRIPT: '{3}','{4}','{5}' 44 | __requires__ = '{3}' 45 | import re 46 | import sys 47 | 48 | from {0} import {1} 49 | 50 | if __name__ == '__main__': 51 | sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0]) 52 | sys.exit({2}())''' 53 | 54 | 55 | @classmethod 56 | def get_args(cls, dist, header=None): # noqa: D205,D400 57 | """ 58 | Yield write_script() argument tuples for a distribution's 59 | console_scripts and gui_scripts entry points. 60 | """ 61 | if header is None: 62 | # pylint: disable=E1101 63 | header = cls.get_header() 64 | spec = str(dist.as_requirement()) 65 | for type_ in 'console', 'gui': 66 | group = type_ + '_scripts' 67 | for name, ep in dist.get_entry_map(group).items(): 68 | # ensure_safe_name 69 | if re.search(r'[\\/]', name): 70 | raise ValueError("Path separators not allowed in script names") 71 | script_text = TEMPLATE.format( 72 | ep.module_name, ep.attrs[0], '.'.join(ep.attrs), 73 | spec, group, name) 74 | # pylint: disable=E1101 75 | args = cls._get_script_args(type_, name, header, script_text) 76 | for res in args: 77 | yield res 78 | 79 | 80 | # pylint: disable=E1101 81 | easy_install.ScriptWriter.get_args = get_args 82 | 83 | 84 | def main(): 85 | import os 86 | import re 87 | import shutil 88 | import sys 89 | dests = sys.argv[1:] or ['.'] 90 | filename = re.sub('\.pyc$', '.py', __file__) 91 | 92 | for dst in dests: 93 | shutil.copy(filename, dst) 94 | manifest_path = os.path.join(dst, 'MANIFEST.in') 95 | setup_path = os.path.join(dst, 'setup.py') 96 | 97 | # Insert the include statement to MANIFEST.in if not present 98 | with open(manifest_path, 'a+') as manifest: 99 | manifest.seek(0) 100 | manifest_content = manifest.read() 101 | if 'include fastentrypoints.py' not in manifest_content: 102 | manifest.write(('\n' if manifest_content else '') + 103 | 'include fastentrypoints.py') 104 | 105 | # Insert the import statement to setup.py if not present 106 | with open(setup_path, 'a+') as setup: 107 | setup.seek(0) 108 | setup_content = setup.read() 109 | if 'import fastentrypoints' not in setup_content: 110 | setup.seek(0) 111 | setup.truncate() 112 | setup.write('import fastentrypoints\n' + setup_content) 113 | -------------------------------------------------------------------------------- /jess.Dockerfile: -------------------------------------------------------------------------------- 1 | # Adjusted based on: https://github.com/jessfraz/dockerfiles/blob/master/chromium/Dockerfile 2 | 3 | FROM debian:bullseye-slim 4 | LABEL maintainer "Jessie Frazelle " 5 | 6 | RUN apt-get update && apt-get install -y \ 7 | chromium \ 8 | chromium-l10n \ 9 | fonts-liberation \ 10 | fonts-roboto \ 11 | hicolor-icon-theme \ 12 | libcanberra-gtk-module \ 13 | libexif-dev \ 14 | libgl1-mesa-dri \ 15 | libgl1-mesa-glx \ 16 | libpangox-1.0-0 \ 17 | libv4l-0 \ 18 | fonts-symbola \ 19 | python3 python3-pip \ 20 | socat curl net-tools \ 21 | --no-install-recommends \ 22 | && rm -rf /var/lib/apt/lists/* \ 23 | && mkdir -p /etc/chromium.d/ \ 24 | && /bin/echo -e 'export GOOGLE_API_KEY="AIzaSyCkfPOPZXDKNn8hhgu3JrA62wIgC93d44k"\nexport GOOGLE_DEFAULT_CLIENT_ID="811574891467.apps.googleusercontent.com"\nexport GOOGLE_DEFAULT_CLIENT_SECRET="kdloedMFGdGla2P1zacGjAQh"' > /etc/chromium.d/googleapikeys 25 | 26 | # Add chromium user 27 | # RUN groupadd -r chromium && useradd -m -r -g chromium -G audio,video chromium \ 28 | # && mkdir -p /home/chromium/Downloads && chown -R chromium:chromium /home/chromium \ 29 | # && mkdir /brotab && chown -R chromium:chromium /brotab 30 | # # Run as non privileged user 31 | # USER chromium 32 | 33 | COPY requirements/base.txt /tmp/base.txt 34 | RUN pip3 install -r /tmp/base.txt 35 | 36 | COPY startup.sh /bin/startup.sh 37 | WORKDIR /brotab 38 | ENTRYPOINT [ "/bin/startup.sh" ] 39 | #ENTRYPOINT [ "/bin/bash" ] 40 | # ENTRYPOINT [ "/usr/bin/chromium" ] 41 | # CMD [ "--user-data-dir=/data" ] 42 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.2 2 | requests==2.24.0 3 | psutil==5.8.0 4 | Werkzeug<3.0 5 | setuptools==75.8.0 6 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r test.txt 2 | 3 | fastentrypoints 4 | ipython 5 | ipdb 6 | pudb 7 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | pytest 4 | pytest-cov 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pip install twine 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Save ~200ms on script startup time 15 | # See https://github.com/ninjaaron/fast-entry_points 16 | # import fastentrypoints 17 | 18 | # Package meta-data. 19 | NAME = 'brotab' 20 | DESCRIPTION = "Control your browser's tabs from the command line" 21 | URL = 'https://github.com/balta2ar/brotab' 22 | EMAIL = 'baltazar.bz@gmail.com' 23 | AUTHOR = 'Yuri Bochkarev' 24 | 25 | 26 | # What packages are required for this module to be executed? 27 | REQUIRED = [ 28 | # 'requests', 'maya', 'records', 29 | ] 30 | # requirements = list(pip.req.parse_requirements( 31 | # 'requirements.txt', session=pip.download.PipSession())) 32 | # REQUIRED = [requirement.name for requirement in requirements] 33 | # REQUIRED = [line.strip() for line in open('requirements/base.txt').readlines()] 34 | with open('requirements/base.txt') as f: 35 | requirements = [line.strip() for line in f if line.strip() and not line.startswith('#')] 36 | REQUIRED = requirements 37 | 38 | # The rest you shouldn't have to touch too much :) 39 | # ------------------------------------------------ 40 | # Except, perhaps the License and Trove Classifiers! 41 | # If you do change the License, remember to change the Trove Classifier for that! 42 | 43 | here = os.path.abspath(os.path.dirname(__file__)) 44 | 45 | # Import the README and use it as the long-description. 46 | # Note: this will only work if 'README.rst' is present in your MANIFEST.in file! 47 | # with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 48 | # long_description = '\n' + f.read() 49 | 50 | try: 51 | #import pypandoc 52 | #long_description = pypandoc.convert_file("README.md", "rst") 53 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 54 | long_description = '\n' + f.read() 55 | except ImportError: 56 | print('WARNING: description is empty, consider installing pypandoc: pip install --user pypandoc') 57 | long_description = '' 58 | 59 | # Load the package's __version__.py module as a dictionary. 60 | about = {} 61 | with open(os.path.join(here, NAME, '__version__.py')) as f: 62 | exec(f.read(), about) 63 | 64 | 65 | class UploadCommand(Command): 66 | """Support setup.py upload.""" 67 | 68 | description = 'Build and publish the package.' 69 | user_options = [] 70 | 71 | @staticmethod 72 | def status(s): 73 | """Prints things in bold.""" 74 | print('\033[1m{0}\033[0m'.format(s)) 75 | 76 | def initialize_options(self): 77 | pass 78 | 79 | def finalize_options(self): 80 | pass 81 | 82 | def run(self): 83 | try: 84 | self.status('Removing previous builds…') 85 | rmtree(os.path.join(here, 'dist')) 86 | except OSError: 87 | pass 88 | 89 | self.status('Building Source and Wheel (universal) distribution…') 90 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 91 | 92 | self.status('Uploading the package to PyPi via Twine…') 93 | os.system('twine upload dist/*') 94 | 95 | sys.exit() 96 | 97 | 98 | packages = find_packages( 99 | # where='brotab', 100 | # exclude=('brotab.tests', 'firefox_extension', 'firefox_mediator') 101 | #exclude=('tests', 'firefox_extension', 'firefox_mediator') 102 | include=( 103 | 'brotab', 104 | 'brotab.tests', 105 | 'brotab.search', 106 | 'brotab.mediator', 107 | 'brotab.albert', 108 | ), 109 | exclude=('firefox_extension', 'firefox_mediator') 110 | ) 111 | print('>>', packages) 112 | 113 | 114 | # Where the magic happens: 115 | setup( 116 | name=NAME, 117 | version=about['__version__'], 118 | description=DESCRIPTION, 119 | long_description=long_description, 120 | long_description_content_type='text/markdown', 121 | author=AUTHOR, 122 | author_email=EMAIL, 123 | url=URL, 124 | packages=packages, 125 | # packages=find_packages( 126 | # where='brotab', 127 | # # exclude=('brotab.tests', 'firefox_extension', 'firefox_mediator') 128 | # exclude=('tests', 'firefox_extension', 'firefox_mediator') 129 | # ), 130 | data_files=[ 131 | ('config', ['brotab/mediator/chromium_mediator.json', 132 | 'brotab/mediator/firefox_mediator.json', 133 | 'brotab/albert/Brotab.qss', 134 | ]), 135 | ], 136 | # If your package is a single module, use this instead of 'packages': 137 | # py_modules=['mypackage'], 138 | 139 | entry_points={ 140 | 'console_scripts': [ 141 | 'brotab=brotab.main:main', 142 | 'bt=brotab.main:main', 143 | 'bt_mediator=brotab.mediator.brotab_mediator:main', 144 | ], 145 | }, 146 | install_requires=REQUIRED, 147 | include_package_data=True, 148 | license='MIT', 149 | classifiers=[ 150 | # Trove classifiers 151 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 152 | 'License :: OSI Approved :: MIT License', 153 | 'Programming Language :: Python', 154 | 'Programming Language :: Python :: 3', 155 | 'Programming Language :: Python :: 3.10', 156 | 'Programming Language :: Python :: 3.11', 157 | 'Programming Language :: Python :: 3.12', 158 | 'Programming Language :: Python :: Implementation :: CPython' 159 | ], 160 | # $ setup.py publish support. 161 | cmdclass={ 162 | 'upload': UploadCommand, 163 | }, 164 | ) 165 | -------------------------------------------------------------------------------- /shell/brotab-fzf-zsh.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #FZF_COMMON="-m --no-sort --reverse --header-lines=1 --inline-info --toggle-sort=\`" 4 | 5 | # Tab ID completion for bt close 6 | _fzf_complete_bt() { 7 | ARGS="$@" 8 | if [[ $ARGS == 'bt close'* ]] || \ 9 | [[ $ARGS == 'bt activate'* ]] || \ 10 | [[ $ARGS == 'bt text'* ]] || \ 11 | [[ $ARGS == 'bt html'* ]] || \ 12 | [[ $ARGS == 'bt words'* ]]; \ 13 | then 14 | _fzf_complete --multi --no-sort --inline-info --toggle-sort=\` -- "$@" < <( 15 | { bt list } 16 | ) 17 | else 18 | eval "zle ${fzf_default_completion:-expand-or-complete}" 19 | fi 20 | } 21 | 22 | _fzf_complete_bt_post() { 23 | cut -f1 -d$'\t' 24 | } 25 | 26 | -------------------------------------------------------------------------------- /shell/brotab.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 4 | # These are shell helpers for browser tab client (brotab). 5 | # 6 | 7 | export BROTAB_CLIENT="$HOME/rc.arch/bz/brotab/brotab_client.py" 8 | 9 | 10 | # 11 | # Browser Close tabs script 12 | # 13 | function fcl() { 14 | if [ $# -eq 0 ]; then 15 | result=$(_list_tabs | _colorize_tabs | fzf --ansi -m --no-sort --prompt="close> " --toggle-sort=\`) 16 | if [ $? -ne 0 ]; then return $?; fi 17 | else 18 | result=`echo "$*" | tr " " "\n"` 19 | fi 20 | echo "$result" | while read -r line; do 21 | id=`echo "$line" | cut -f1 -d' '` 22 | # echo "Closing tab: $line" >&2 23 | echo "$id" 24 | done | xargs bt close 25 | #done | xargs $BROTAB_CLIENT close_tabs 26 | } 27 | 28 | function _colorize_tabs() { 29 | local YELLOW='\x1b[0;33m' 30 | local GREEN='\x1b[0;32m' 31 | local BLUE='\x1b[0;34m' 32 | local LIGHTGRAY='\x1b[0;37m' 33 | local NOCOLOR='\x1b[m' 34 | sed -r "s/(.+)\t(.+)\t(.+)/$YELLOW\1 $GREEN\2 $LIGHTGRAY\3/" 35 | } 36 | 37 | function _decolorize_tabs() { 38 | sed -r "s/\x1b\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g" 39 | } 40 | 41 | function _activate_browser() { 42 | if [[ $1 == f.* ]]; then 43 | wmctrl -R firefox 44 | elif [[ $1 == c.* ]]; then 45 | wmctrl -R chromium 46 | fi 47 | } 48 | 49 | function _activate_tab() { 50 | local strWindowTab=$1 51 | # echo "Activating tab: $result" 52 | #$BROTAB_CLIENT activate_tab $strWindowTab 53 | bt activate $strWindowTab 54 | _activate_browser $strWindowTab 55 | } 56 | 57 | function _close_tabs() { 58 | bt close 59 | #$BROTAB_CLIENT close_tabs $* 60 | } 61 | 62 | function _list_tabs() { 63 | bt list 64 | #$BROTAB_CLIENT list_tabs 1000 65 | } 66 | 67 | 68 | # 69 | # Browser Open tab script 70 | # 71 | function fopen() { 72 | if [ $# -eq 0 ]; then 73 | result=$(_list_tabs | _colorize_tabs | fzf --ansi --no-sort --prompt="open> " --toggle-sort=\`) 74 | if [ $? -ne 0 ]; then return $?; fi 75 | else 76 | result=$* 77 | fi 78 | strWindowTab=`echo "$result" | cut -f1 -d' '` 79 | _activate_tab "$strWindowTab" 80 | } 81 | 82 | 83 | # 84 | # Browser Search tab script 85 | # 86 | function fsearch() { 87 | if [ $# -eq 0 ]; then 88 | echo "Usage: fsearch " 89 | return 1 90 | fi 91 | 92 | $BROTAB_CLIENT new_tab $* 93 | if [ $? -ne 0 ]; then return $?; fi 94 | _activate_browser $1 95 | } 96 | -------------------------------------------------------------------------------- /shell/rofi_activate_tab.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source $HOME/rc.arch/bz/brotab/brotab.sh 4 | 5 | DEFAULT_WIDTH=90 6 | 7 | if [ "$@" ]; then 8 | echo "$@" | cut -d$'\t' -f1 | xargs -L1 bt activate 9 | else 10 | active_window=`bt active | \grep firefox | awk '{print $1}'` 11 | selected=`cached_bt_list \ 12 | | rofi -dmenu -i -multi-select -select "$active_window" -p "Activate tab" -width $DEFAULT_WIDTH \ 13 | | head -1 \ 14 | | cut -d$'\t' -f1` 15 | if [ "$selected" ]; then 16 | echo "$selected" | xargs -L1 bt activate 17 | fi 18 | fi 19 | -------------------------------------------------------------------------------- /shell/rofi_close_tabs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source $HOME/rc.arch/bz/brotab/brotab.sh 4 | 5 | DEFAULT_WIDTH=90 6 | 7 | if [ "$@" ]; then 8 | echo "$@" | cut -d$'\t' -f1 | xargs -L1 bt close 9 | bt list 10 | else 11 | active_window=`bt active | \grep firefox | awk '{print $1}'` 12 | selected=`cached_bt_list \ 13 | | rofi -dmenu -i -multi-select -select "$active_window" -p "Close tab" -width $DEFAULT_WIDTH \ 14 | | cut -d$'\t' -f1` 15 | if [ "$selected" ]; then 16 | echo "$selected" | xargs -L1 bt close 17 | fi 18 | fi 19 | -------------------------------------------------------------------------------- /shell/rofi_dispatcher.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ACTIVE_WINDOW=/tmp/rofi_active_window.txt 4 | SELECTED_LINES=/tmp/rofi_selected_lines.txt 5 | ACTION=/tmp/rofi_action.txt 6 | MODE=${1:-rt} 7 | 8 | # Reset state 9 | echo -n "" > $SELECTED_LINES 10 | echo -n "" > $ACTION 11 | xdotool getactivewindow > $ACTIVE_WINDOW 12 | 13 | DEFAULT_WIDTH=90 14 | 15 | MODI="" 16 | SELECT="" 17 | if [ "$MODE" == "tab" ]; then 18 | MODI+=",activate_tab:rofi_activate_tab.sh" 19 | MODI+=",close_tabs:rofi_close_tabs.sh" 20 | DEFAULT_MODE=activate_tab 21 | else 22 | echo "Unknown mode: $MODE. Known modes: rt, tab" 23 | exit 1 24 | fi 25 | 26 | # source $HOME/rc.arch/bz/.config/fzf/bz-completion.zsh 27 | # Mode scripts will use this var 28 | export SECOND_COL_WIDTH=97 29 | 30 | tab_menu_mode() { 31 | mode=$(echo -e "activate\nclose" | rofi -dmenu -p "What would you like to do with browser tabs?" -width $DEFAULT_WIDTH) 32 | if [ "$mode" == "activate" ]; then 33 | rofi_activate_tab.sh 34 | elif [ "$mode" == "close" ]; then 35 | rofi_close_tabs.sh 36 | fi 37 | } 38 | 39 | if [ "$MODE" == "tab" ]; then 40 | tab_menu_mode 41 | fi 42 | -------------------------------------------------------------------------------- /smoke.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | WORKDIR /app 4 | 5 | COPY "requirements/base.txt" /tmp/base.txt 6 | RUN pip install --no-cache-dir -r "/tmp/base.txt" 7 | 8 | COPY dist/* ./ 9 | COPY test_build_install_run.sh . 10 | 11 | CMD [ "/app/test_build_install_run.sh" ] 12 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate clap; 2 | extern crate reqwest; 3 | extern crate tempfile; 4 | 5 | mod net; 6 | 7 | // use std::io; 8 | use std::env; 9 | use std::io::Read; 10 | use std::thread; 11 | use std::thread::JoinHandle; 12 | use std::process::Command; 13 | use std::fs::File; 14 | use std::io::Write; 15 | 16 | use clap::{App, Arg, SubCommand}; 17 | 18 | use net::get_available_ports; 19 | 20 | static ASCII_LOWERCASE: &'static str = "abcdefghijklmnopqrstuvwxyz"; 21 | 22 | 23 | /// Create clients for all available ports 24 | fn create_clients() -> Vec { 25 | get_available_ports().iter().map(|x| BrowserClient::new(*x)).collect() 26 | } 27 | 28 | struct BrowserClient { 29 | port: u16 30 | } 31 | 32 | impl BrowserClient { 33 | fn new(port: u16) -> BrowserClient { 34 | BrowserClient{ port: port } 35 | } 36 | 37 | fn list_tabs(&self) -> String { 38 | let url = format!("http://localhost:{}/list_tabs", self.port); 39 | let mut response = reqwest::get(url.as_str()).expect("Request failed"); 40 | 41 | let mut buf = String::new(); 42 | response 43 | .read_to_string(&mut buf) 44 | .expect("Cannot read response"); 45 | 46 | buf 47 | } 48 | } 49 | 50 | fn get_editor_command() -> String { 51 | env::var("EDITOR").unwrap_or("nvim".to_string()) 52 | } 53 | 54 | fn edit_text_in_editor(text: &String) -> Option { 55 | // let mut tmpfile: File = tempfile::tempfile().unwrap(); 56 | 57 | let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); 58 | write!(tmpfile, "{}", "before"); 59 | let path = tmpfile.path().to_str().unwrap(); 60 | println!("{}", path); 61 | 62 | let output = Command::new(get_editor_command()).arg(path).status().expect("Could not run 63 | nvim"); 64 | if output.success() { 65 | let mut file = File::open(path).unwrap(); 66 | let mut contents = String::new(); 67 | file.read_to_string(&mut contents); 68 | return Some(contents); 69 | 70 | //return Some(String::from_utf8_lossy(&output.stdout).to_string()) 71 | 72 | // return Some("".to_string()) 73 | } else { 74 | println!("Editor quit with non zero exit code"); 75 | return None 76 | } 77 | } 78 | 79 | fn bt_move() { 80 | let clients = create_clients(); 81 | let before_tabs = clients[0].list_tabs(); 82 | // println!("TABS: {}", before_tabs); 83 | if let Some(after_tabs) = edit_text_in_editor(&before_tabs) { 84 | println!("AFTER: {}", after_tabs); 85 | } 86 | } 87 | 88 | /// Ask all mediators to provide a list of their tabs and print them 89 | fn bt_list() { 90 | let mut children: Vec> = vec![]; 91 | for port in get_available_ports() { 92 | children.push(std::thread::spawn(move || -> String { 93 | let client = BrowserClient::new(port); 94 | client.list_tabs() 95 | })); 96 | } 97 | for (ch, child) in ASCII_LOWERCASE.chars().zip(children) { 98 | let unprefixed: String = child.join().unwrap(); 99 | let prefixed = unprefixed 100 | .lines() 101 | .map(|x| format!("{}.{}", ch, x)) 102 | .collect::>() 103 | .join("\n"); 104 | println!("{}", prefixed); 105 | } 106 | } 107 | 108 | /// List all available mediators (browser clients) 109 | fn bt_clients() { 110 | // let checked_ports: Vec<_> = (0..10) 111 | // .map(|x| x + 4625) 112 | // .collect(); 113 | // let mut children = vec![]; 114 | // for port in checked_ports { 115 | // children.push(thread::spawn(move || -> u16 { 116 | // if can_connect(port) { port } else { 0 } 117 | // })); 118 | // } 119 | // let available_ports = children.map() 120 | // 121 | // let ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"; 122 | // ascii_lowercase 123 | // .chars() 124 | // .zip(available_ports) 125 | // .for_each(|(ch, port)| println!("{}.\tlocalhost:{}", ch, port)); 126 | 127 | // let available_ports: Vec<_> = (0..10) 128 | // .map(|x| x + 4625) 129 | // .filter(|&x| can_connect(x)) 130 | // .collect(); 131 | let available_ports = get_available_ports(); 132 | // let ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"; 133 | ASCII_LOWERCASE 134 | .chars() 135 | .zip(available_ports) 136 | .for_each(|(ch, port)| println!("{}.\tlocalhost:{}", ch, port)); 137 | } 138 | 139 | fn main() { 140 | let matches = App::new("BroTab Browser Tab management") 141 | .version("0.1.0") 142 | .author("Yuri Bochkarev ") 143 | .about("Helps you win at browser tab management") 144 | .subcommand( 145 | SubCommand::with_name("list") 146 | .about("List all available tabs") 147 | ) 148 | .subcommand( 149 | SubCommand::with_name("move") 150 | .about("Move tabs around using your favorite editor") 151 | ) 152 | .subcommand( 153 | SubCommand::with_name("clients") 154 | .about("List all available browser clients (mediators)") 155 | ) 156 | .get_matches(); 157 | 158 | match matches.subcommand() { 159 | ("list", Some(_m)) => bt_list(), 160 | ("move", Some(_m)) => bt_move(), 161 | ("clients", Some(_m)) => bt_clients(), 162 | _ => println!("No command or unknown specified. Get help using --help."), 163 | } 164 | 165 | // let args: Vec = env::args().collect(); 166 | // match args.len() { 167 | // 2 => { 168 | // let command = &args[1]; 169 | // // println!("Your arguments: {:?}", command); 170 | // match &command[..] { 171 | // "list" => bt_list(), 172 | // "clients" => bt_clients(), 173 | // _ => { 174 | // eprintln!("Invalid command: {:?}", command); 175 | // } 176 | // } 177 | // } 178 | // _ => { 179 | // println!("Not enough arguments: bt "); 180 | // return; 181 | // } 182 | // } 183 | } 184 | -------------------------------------------------------------------------------- /src/net.rs: -------------------------------------------------------------------------------- 1 | 2 | use std::net::{SocketAddr, TcpStream}; 3 | use std::time::Duration; 4 | 5 | 6 | /// Check whether specified port can be connected to 7 | pub fn can_connect(port: u16) -> bool { 8 | let addr = SocketAddr::from(([127, 0, 0, 1], port)); 9 | TcpStream::connect_timeout(&addr, Duration::from_millis(50)).is_ok() 10 | } 11 | 12 | /// Get a list of available ports starting from 4625 13 | pub fn get_available_ports() -> Vec { 14 | // Check first 10 ports starting from 4625 15 | (4625..).take(10).filter(|&x| can_connect(x)).collect() 16 | } 17 | -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | IF_PUBLIC=172.17.0.2 4 | IF_PRIVATE=127.0.0.1 5 | 6 | socat TCP4-LISTEN:9222,fork,reuseaddr,bind=$IF_PUBLIC TCP4:$IF_PRIVATE:9222 & 7 | socat TCP4-LISTEN:4625,fork,reuseaddr,bind=$IF_PUBLIC TCP4:$IF_PRIVATE:4625 & 8 | 9 | cd /brotab \ 10 | && pip install -e . \ 11 | && bt install --tests \ 12 | && chromium --no-sandbox --disable-gpu --remote-debugging-address=$IF_PRIVATE --remote-debugging-port=9222 --load-extension=/brotab/brotab/extension/chrome file:/// 13 | -------------------------------------------------------------------------------- /test_build_install_run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # How to use: 4 | # rm -rf ./dist && python setup.py sdist bdist_wheel && docker build -t brotab-buildinstallrun . && docker run -it brotab-buildinstallrun 5 | 6 | # Fail on any error 7 | set -e 8 | 9 | pip install $(find . -name *.whl -type f) 10 | 11 | python -c 'from brotab.tests.test_main import run_mocked_mediators as run; run(count=3, default_port_offset=0, delay=0)' & 12 | sleep 3 13 | 14 | function run() { 15 | echo "Running: $*" 16 | $* 17 | } 18 | 19 | run bt list 20 | run bt windows 21 | run bt clients 22 | run bt active 23 | run bt words 24 | run bt text 25 | run bt html 26 | -------------------------------------------------------------------------------- /xvfb-chromium: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | _kill_procs() { 4 | kill -TERM $chromium 5 | wait $chromium 6 | kill -TERM $xvfb 7 | } 8 | 9 | # Setup a trap to catch SIGTERM and relay it to child processes 10 | trap _kill_procs SIGTERM 11 | 12 | XVFB_WHD=${XVFB_WHD:-1280x720x16} 13 | 14 | # Start Xvfb 15 | Xvfb :99 -ac -screen 0 $XVFB_WHD -nolisten tcp & 16 | xvfb=$! 17 | 18 | export DISPLAY=:99 19 | export DBUS_SESSION_BUS_ADDRESS=/dev/null 20 | 21 | #chromium-browser --no-sandbox $@ & 22 | chromium-browser $@ & 23 | chromium=$! 24 | 25 | wait $chromium 26 | wait $xvfb 27 | --------------------------------------------------------------------------------